data, String nextCursor, boolean hasMore)` returned by `AgentEventRepository.queryPage`
- `AgentEventListener` — callback interface for agent events
- `RouteStateRegistry` — tracks per-agent route states
diff --git a/.claude/rules/ui.md b/.claude/rules/ui.md
index f673d9ac..16044b03 100644
--- a/.claude/rules/ui.md
+++ b/.claude/rules/ui.md
@@ -43,7 +43,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
- `AllAlertsPage.tsx` — env-wide list with state-chip filter.
- `HistoryPage.tsx` — RESOLVED alerts.
- `RulesListPage.tsx` — CRUD + enable/disable toggle + env-promotion dropdown (pure UI prefill, no new endpoint).
- - `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (`initialForm` / `toRequest` / `validateStep`). Six condition-form subcomponents under `RuleEditor/condition-forms/`.
+ - `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (`initialForm` / `toRequest` / `validateStep`). Seven condition-form subcomponents under `RuleEditor/condition-forms/` — including `AgentLifecycleForm.tsx` (multi-select event-type chips for the six-entry `AgentLifecycleEventType` allowlist + lookback-window input).
- `SilencesPage.tsx` — matcher-based create + end-early.
- `AlertRow.tsx` shared list row; `alerts-page.module.css` shared styling.
- **Components**:
diff --git a/CLAUDE.md b/CLAUDE.md
index be7b1d8b..d1f05a8c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -71,6 +71,8 @@ PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
- V12 — Alerting tables (alert_rules, alert_rule_targets, alert_instances, alert_notifications, alert_reads, alert_silences)
- V13 — alert_instances open-rule unique index (alert_instances_open_rule_uq partial index on rule_id WHERE state IN PENDING/FIRING/ACKNOWLEDGED)
- V14 — Repair EXCHANGE_MATCH alert_rules persisted with fireMode=null (sets fireMode=PER_EXCHANGE + perExchangeLingerSeconds=300); paired with stricter `ExchangeMatchCondition` ctor that now rejects null fireMode.
+- V15 — Discriminate open-instance uniqueness by `context->'exchange'->>'id'` so EXCHANGE_MATCH/PER_EXCHANGE emits one alert_instance per matching exchange; scalar kinds resolve to `''` and keep one-open-per-rule.
+- V16 — Generalise the V15 discriminator to prefer `context->>'_subjectFingerprint'` (falls back to the V15 `exchange.id` expression for legacy rows). Enables AGENT_LIFECYCLE to emit one alert_instance per `(agent, eventType, timestamp)` via a canonical fingerprint in the evaluator firing's context.
ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup)
@@ -98,7 +100,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **cameleer-server** (8527 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **cameleer-server** (8603 symbols, 22281 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluator.java
new file mode 100644
index 00000000..9eb15d6e
--- /dev/null
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluator.java
@@ -0,0 +1,95 @@
+package com.cameleer.server.app.alerting.eval;
+
+import com.cameleer.server.core.agent.AgentEventRecord;
+import com.cameleer.server.core.agent.AgentEventRepository;
+import com.cameleer.server.core.alerting.AgentLifecycleCondition;
+import com.cameleer.server.core.alerting.AgentLifecycleEventType;
+import com.cameleer.server.core.alerting.AlertRule;
+import com.cameleer.server.core.alerting.AlertScope;
+import com.cameleer.server.core.alerting.ConditionKind;
+import com.cameleer.server.core.runtime.EnvironmentRepository;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Evaluator for {@link AgentLifecycleCondition}.
+ *
+ * Each matching row in {@code agent_events} produces its own {@link EvalResult.Firing}
+ * in an {@link EvalResult.Batch}, so every {@code (agent, eventType, timestamp)}
+ * tuple gets its own {@code AlertInstance} — operationally distinct outages /
+ * restarts / shutdowns are independently ackable. Deduplication across ticks
+ * is enforced by {@code alert_instances_open_rule_uq} via the canonical
+ * {@code _subjectFingerprint} key in the instance context (see V16 migration).
+ */
+@Component
+public class AgentLifecycleEvaluator implements ConditionEvaluator {
+
+ /** Hard cap on rows returned per tick — prevents a flood of stale events from overwhelming the job. */
+ private static final int MAX_EVENTS_PER_TICK = 500;
+
+ private final AgentEventRepository eventRepo;
+ private final EnvironmentRepository envRepo;
+
+ public AgentLifecycleEvaluator(AgentEventRepository eventRepo, EnvironmentRepository envRepo) {
+ this.eventRepo = eventRepo;
+ this.envRepo = envRepo;
+ }
+
+ @Override
+ public ConditionKind kind() { return ConditionKind.AGENT_LIFECYCLE; }
+
+ @Override
+ public EvalResult evaluate(AgentLifecycleCondition c, AlertRule rule, EvalContext ctx) {
+ String envSlug = envRepo.findById(rule.environmentId())
+ .map(e -> e.slug())
+ .orElse(null);
+ if (envSlug == null) return EvalResult.Clear.INSTANCE;
+
+ AlertScope scope = c.scope();
+ String appSlug = scope != null ? scope.appSlug() : null;
+ String agentId = scope != null ? scope.agentId() : null;
+
+ List typeNames = c.eventTypes().stream()
+ .map(AgentLifecycleEventType::name)
+ .toList();
+
+ Instant from = ctx.now().minusSeconds(c.withinSeconds());
+ Instant to = ctx.now();
+
+ List matches = eventRepo.findInWindow(
+ envSlug, appSlug, agentId, typeNames, from, to, MAX_EVENTS_PER_TICK);
+
+ if (matches.isEmpty()) return new EvalResult.Batch(List.of());
+
+ List firings = new ArrayList<>(matches.size());
+ for (AgentEventRecord ev : matches) {
+ firings.add(toFiring(ev));
+ }
+ return new EvalResult.Batch(firings);
+ }
+
+ private static EvalResult.Firing toFiring(AgentEventRecord ev) {
+ String fingerprint = (ev.instanceId() == null ? "" : ev.instanceId())
+ + ":" + (ev.eventType() == null ? "" : ev.eventType())
+ + ":" + (ev.timestamp() == null ? "0" : Long.toString(ev.timestamp().toEpochMilli()));
+
+ Map context = new LinkedHashMap<>();
+ context.put("agent", Map.of(
+ "id", ev.instanceId() == null ? "" : ev.instanceId(),
+ "app", ev.applicationId() == null ? "" : ev.applicationId()
+ ));
+ context.put("event", Map.of(
+ "type", ev.eventType() == null ? "" : ev.eventType(),
+ "timestamp", ev.timestamp() == null ? "" : ev.timestamp().toString(),
+ "detail", ev.detail() == null ? "" : ev.detail()
+ ));
+ context.put("_subjectFingerprint", fingerprint);
+
+ return new EvalResult.Firing(1.0, null, context);
+ }
+}
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java
index 41f0ce31..50e4db43 100644
--- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java
@@ -64,6 +64,10 @@ public class NotificationContextBuilder {
ctx.put("agent", subtree(instance, "agent.id", "agent.name", "agent.state"));
ctx.put("app", subtree(instance, "app.slug", "app.id"));
}
+ case AGENT_LIFECYCLE -> {
+ ctx.put("agent", subtree(instance, "agent.id", "agent.app"));
+ ctx.put("event", subtree(instance, "event.type", "event.timestamp", "event.detail"));
+ }
case DEPLOYMENT_STATE -> {
ctx.put("deployment", subtree(instance, "deployment.id", "deployment.status"));
ctx.put("app", subtree(instance, "app.slug", "app.id"));
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/ClickHouseAgentEventRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/ClickHouseAgentEventRepository.java
index 817cf2a1..fe4c25c6 100644
--- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/ClickHouseAgentEventRepository.java
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/ClickHouseAgentEventRepository.java
@@ -106,4 +106,57 @@ public class ClickHouseAgentEventRepository implements AgentEventRepository {
return new AgentEventPage(results, nextCursor, hasMore);
}
+
+ @Override
+ public List findInWindow(String environment,
+ String applicationId,
+ String instanceId,
+ List eventTypes,
+ Instant fromInclusive,
+ Instant toExclusive,
+ int limit) {
+ if (eventTypes == null || eventTypes.isEmpty()) {
+ throw new IllegalArgumentException("eventTypes must not be empty");
+ }
+ if (fromInclusive == null || toExclusive == null) {
+ throw new IllegalArgumentException("from/to must not be null");
+ }
+
+ // `event_type IN (?, ?, …)` — one placeholder per type.
+ String placeholders = String.join(",", java.util.Collections.nCopies(eventTypes.size(), "?"));
+ var sql = new StringBuilder(SELECT_BASE);
+ var params = new ArrayList