Skip to content

feat(oncall-agent): read-only Slack-triggered cluster triage agent - #293

Draft
manan164 wants to merge 38 commits into
mainfrom
feat/oncall-triage-agent
Draft

feat(oncall-agent): read-only Slack-triggered cluster triage agent#293
manan164 wants to merge 38 commits into
mainfrom
feat/oncall-triage-agent

Conversation

@manan164

@manan164 manan164 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

feat(oncall-agent): read-only Slack-triggered cluster triage agent

Draft. The scaffold, the read-only investigation loop, a per-alert-type triage
playbook for all 17 health-check alert types
, and the full live Slack→triage→reply
loop against ah5r-prod
now work. What remains is a hypothesis-quality eval.

What this is

An Agentspan agent that triages Orkes SaaS cluster health-check alerts. It polls the Slack
alert channel (Web API, no Socket Mode), parses the executionId from the failing
health_check execution URL, runs read-only agent-handler commands against the ah5r-prod
Conductor API to investigate, and replies in-thread with a root-cause hypothesis.

It is strictly read-only and advisory — it never takes a remediating action. SQL is gated by
a deterministic SELECT-only guard (sql_guard.py), not by trusting the model. The agent reads
organizationId / clusterName / cloudEnvironmentTag off the failing execution, so the LLM
only threads the executionId into each tool — it cannot target the wrong cluster and no secrets
pass through tool args.

Done ✅

  • Slack ingestion — Web API poller (conversations.history + chat.postMessage), bot token
    only, run-once / --loop, state-file dedup. Matches sdk/python/examples/91_slack_autofix_agent.py.

  • Alert parsing (alert.py) — extracts execution id + severity + org/cluster from the alert text.

  • Conductor dispatch (conductor_client.py) — starts agent-handler command workflows on
    ah5r-prod via conductor-python (app key/secret), polls to completion, derives + caches cluster context.

  • Read-only tool set (tools.py) — get_incident_details, get_cluster_metrics,
    get_infrastructure_metrics, get_pods_data, get_deployments_info, get_pod_events,
    get_top_output, pull_pod_logs, get_ingress_info, run_sql_select (SELECT-guarded).

  • SQL safety guard (sql_guard.py) — SELECT/WITH/EXPLAIN/SHOW only; rejects every mutation,
    multi-statement, and comment-smuggling case before it reaches the DB.

  • Per-alert-type triage playbook for all 17 health-check alert types (agent.py) — symptom →
    evidence-to-gather → what-to-cite, matched off the issues text. Keeps the strong Redis /
    decider-queue / CPU / heap guidance and extends to component-down, pod, networking, and
    self-describing alerts (table below).

  • Tests (deterministic, no LLM in the assertion path, per CLAUDE.md) — test_sql_guard.py,
    test_alert.py, test_poller.py, test_conductor_client.py, test_tools_readonly.py
    (read-only safety guard, pinned to the real AgentHandlerCommand enum names),
    test_tools_dispatch.py (each tool dispatches its expected agent-handler command). 45 passing.

  • Local run path (python -m oncall_agent.main [triage <execId>]), .env.example, README.

  • Live end-to-end run (2026-07-22) — the FULL production path: poller read a real MAJOR
    alert from a Slack channel (collective-staging, Pod orkes-agent-deployment-* Failed,
    exec d552305e-85ce-11f1), parsed it, ran the triage agent with real read-only agent-handler
    dispatches to ah5r-prod (~80s, recurrence check + pods + events + logs + metrics), and posted
    a correct root-cause hypothesis in-thread (stale Failed pod superseded by a 2026-07-20
    rollout; cluster already self-healed). The first run caught a real bug at the last step —
    the runtime returns result.output as a dict ({result, finishReason, ...}) and the Slack
    post crashed concatenating it; fixed via runtime_compat.summary_text() with a repro test
    that failed with the exact live TypeError first. An earlier CLI-path run (2026-07-03,
    triage <execId>) had validated the investigation loop.

  • Digest-channel support (2026-07-22) — polls the alert-aggregator channel (one message per
    (cluster, alert-type) incident, edited in place with an occurrence counter → flapper dedup for
    free). Block-text flattening (alert.message_text); validated live (At-Bay HEAP_HIGH triaged
    in-thread). Token-based recurrence matching fixed the flapper-reported-as-NEW miss.

  • Eval batch tooling (scripts/eval_batch.py + eval_select.py) — dedupes the raw stream to
    unique incidents and replays them into a human-scorable markdown report. First run: 6/6 unique
    48h incidents triaged clean (2× pod-failed, 3× CPU, 1× heap), ~90s each.

  • Containerization (Dockerfile, deploy/k8s.yaml) — two-container pod (agentspan server
    sidecar + poller), digest channel default, single-writer state on PVC, kill switch = scale to 0.
    Image build + in-container import smoke verified.

To do 🚧

  • Human scoring of the eval report — the production gate (≥80% useful) before kubectl apply.
  • Remediation — deliberately out of scope for v1; when added it must go behind the Agentspan
    HITL approval gate.

The 17 health-check alert types — all now have a playbook

Source of truth: HealthIssue enum in
orkes-saas/.../worker/HealthCheckIssuesWorker.java. "Approach" = how agent.py triages it.

# Alert type Sev Triage approach
1 REDIS_CRITICAL_USAGE CRITICAL decider-queue backlog → server/worker logs
2 REDIS_HIGH_USAGE MAJOR decider-queue backlog → server/worker logs
3 CONDUCTOR_HIGH_HEAP_USAGE MAJOR top + infra metrics → logs grep OutOfMemory/GC
4 CONDUCTOR_HIGH_CPU_USAGE MAJOR top + infra metrics → hot-pod logs
5 CONDUCTOR_ERROR_LOGS_COUNT_EXCEEDED_THRESHOLD MAJOR server logs → name dominant exception
6 CONDUCTOR_WARN_LOGS_COUNT_EXCEEDED_THRESHOLD MINOR server logs → name dominant warning
7 CONDUCTOR_HEALTHY (failed) CRITICAL conductor pod events + logs (crashloop/OOM/image)
8 WORKERS_HEALTHY (failed) CRITICAL worker pod events + logs
9 PROMETHEUS_NOT_RUNNING MAJOR prometheus pod events + logs (note: metrics may be stale)
10 POD_NOT_RUNNING MAJOR pod events (schedule/image/OOM) + logs
11 POD_RESTARTED MAJOR pod events for reason + pre-crash logs
12 DNS_HEALTHY (failed) CRITICAL get_ingress_info — no address ⇒ LB unprovisioned; else external → infra
13 DOMAIN_RESOLUTION CRITICAL get_ingress_info → resolve vs escalate to infra
14 DOMAIN_REACHABILITY CRITICAL get_ingress_info → reachable vs escalate to infra
15 AUTH_STALE MAJOR self-describing → relay + rotate cluster API key (remediation)
16 DOMAIN_CERTIFICATE_WILL_EXPIRE MINOR self-describing (domain + days in msg) → renew cert
17 RESPONSE_TIME MINOR optionally correlate CPU/heap/restarts, else relay latency

On testing the playbook: the playbook is prompt text — by CLAUDE.md rule 1 it can't be
LLM-judged in unit tests and isn't deterministically assertable, so it has no unit test by design.
What is tested deterministically: the read-only safety guard and the per-tool dispatch contract
(get_ingress_infoGET_INGRESS_INFO, mutating commands stay unreachable). Playbook quality is
validated in the eval step above.

Testing

cd oncall-agent
PYTHONPATH=src python -m pytest -q   # 35 passing

Out of scope (v1)

Triage + read-only investigation only. No remediation (restart/scale/rollback).

manan164 and others added 30 commits June 15, 2026 19:32
Scaffolds an Agentspan agent that triages Orkes SaaS health-check alerts.
It listens on the Slack alert channel, reads the failing health_check
execution by id, and runs READ-ONLY agent-handler commands against the
ah5r-prod Conductor API to investigate, then replies in-thread with a
root-cause hypothesis. Advisory/dry-run only — no remediating actions.

- sql_guard: deterministic SELECT-only guard (not LLM-trusted) for the
  run_sql_select tool; rejects DML/DDL, multi-statement, comment-smuggling.
- conductor_client: dispatch read-only agent-handler workflows + poll;
  reads org/cluster/cloudEnvironmentTag off the failing execution so only
  the executionId is threaded into tools.
- tools: 9 read-only investigation tools mapped to agent-handler commands.
- agent: Claude triage loop with a component->investigation runbook.
- slack_app: Socket Mode listener -> triage -> threaded reply.
- tests: sql_guard + alert parsing, deterministic (no LLM), validated by
  proving each fails before passing per CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ention

Follows the pattern in sdk/python/examples/91_slack_autofix_agent.py
(per PR #135): poll the alert channel with conversations.history + reply
via chat.postMessage using a bot token only — no slack_bolt / Socket Mode,
no app-level token. Run-once or --loop, dedup via a local state file.

Slack I/O lives in a deterministic poller; the triage agent stays pure
(investigates a given execution id). Adds test_poller.py covering
alert-only triage, cross-poll dedup, and failure reporting (fakes, no
network/LLM; validated fail-then-pass per CLAUDE.md).

Config: drop SLACK_APP_TOKEN; add SLACK_ALERT_CHANNEL (required),
ONCALL_POLL_INTERVAL, ONCALL_STATE_FILE. requirements: drop slack-bolt,
add requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- test_tools_readonly: source-level invariant that no mutating/privileged
  agent-handler command is wired into tools.py, and SQL goes through the
  SELECT guard. Validated by adding DELETE_POD and confirming failure.
- scripts/smoke_dispatch.py: LLM-free live check against ah5r-prod — reads
  the failing execution's cluster context, dispatches read-only commands
  (get_pods_data, get_cluster_metrics, SELECT 1), asserts COMPLETED + output
  shape. The L1 verification step; run with the Conductor app key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r domain

Two bugs found during the first live run against ah5r-prod (viz-stage):

1. cloudEnvironmentTag is NOT in the health_check workflow input (it's
   produced by prepare_agent_handler's output), but sql_conductor reads it
   from workflow.input — derive it as c<orgId[:5]>-<clusterName> when absent.

2. Dispatched commands set no task_to_domain, so customer-cluster tasks (e.g.
   collect_metrics) sat in the default queue and the in-cluster agent never
   polled them -> TIMED_OUT. Mirror the control plane: wildcard "*" -> the
   cluster domain (orgId#-#clusterName), with orchestration tasks pinned to
   NO_DOMAIN. Switched dispatch to StartWorkflowRequest to carry task_to_domain.

Verified live: GET_PODS_DATA and PULL_LOGS now COMPLETE end-to-end with real
viz-stage data. Adds deterministic regression tests (fake client) for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validated end-to-end against ah5r-prod (viz-stage Redis-critical alert): the
agent now autonomously reads the health-check data and pulls server + worker
logs to reach a root-cause hypothesis.

Fixes found during the live run:
- runtime_compat: on macOS, run conductor tool-workers as THREADS, not forked
  processes. Forked children segfault in getaddrinfo (Network.framework is not
  fork-safe) and 'spawn' can't pickle the worker's thread lock. agentspan ships
  a thread shim but gates it to Windows; reuse it on macOS. No-op on Linux
  (where fork is safe — how this runs in prod). Wired into triage + slack paths.
- get_incident_details: surface parse_conductor_cluster_data (redis.usage,
  decider_queue_size = running workflows, indexer_queue_size, heap, cpu,
  postgres) so the agent reads the queue numbers from the health-check JSON
  instead of deriving them via SQL.
- runbook: treat queue/usage as the symptom; find the cause in CONDUCTOR SERVER
  and WORKER pod logs (+ pod events). Explicitly forbid ad-hoc SQL on large
  tables like `workflow` (decider_queue_size already is the running-workflow
  count). run_sql_select is a last resort, not the primary tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…check alerts

- agent.py: add a compact alert-type playbook covering all 17 HealthIssue types
  (resource saturation, component-down, pod, networking, self-describing). Preserves
  the existing Redis/CPU/heap guidance; routes the model symptom -> evidence -> cite.
- tools.py: add read-only get_ingress_info (GET_INGRESS_INFO) for DNS / domain
  resolution / reachability alerts (empty ingress address = LB not provisioned).
- test_tools_dispatch.py: assert each tool dispatches its expected agent-handler
  command (incl. get_ingress_info) via a fake dispatcher. Validated fail-then-pass.
- test_tools_readonly.py: fix command names to match the AgentHandlerCommand enum
  (ROLLOUT_RESTART, KUBECTL_UNRESTRICTED, ...) so the guard actually catches them;
  add heavy/disruptive commands (DOWNLOAD_HEAP_DUMP, etc.) to the deny list.
…verage

Verified against the source workflow/worker (not assumed):
- get_incident_details ref "issues" matches health_check.json taskReferenceName, and
  the issues task output carries the per-issue severity+description text the playbook
  matches on (HealthCheckIssuesWorker) — the 17-type playbook is actually reachable.
- alert.py: the real Slack text is markdown (*`[CRITICAL]`* … _Org's_ env *`cluster`*)
  + emoji + appended execution URL. The old _CLUSTER_RE choked on the italic/bold markers
  and returned org/cluster=None. Strip *,` decoration and tolerate italic _ so org/cluster
  parse; execution id + severity were already fine. Added a test built from the exact
  worker+notify format; validated fail-then-pass.
- test_playbook_coverage.py: deterministic guard that all 17 HealthIssue types stay in the
  playbook; validated it fails when a type is dropped.
…ai.agents

The main merge into this branch (cd689ae) renamed the Python SDK package from
`agentspan` to `conductor` (dist `conductor-agent-sdk`), import root
`conductor.ai.agents`. oncall-agent still imported `agentspan.agents`, so the module
could not import its SDK at all on the post-merge branch. Swap the five SDK imports
(Agent, tool, AgentRuntime, worker_manager shim) to the new namespace.

Left the local `agentspan_server_url` config field + AGENTSPAN_SERVER_URL env var
as-is — they're our own naming, not SDK symbols, and the env var is documented.

Full suite passes (37) against the new namespace.
…spatcher

Runs the actual conductor.ai.agents AgentRuntime + agent reasoning + tool-calling against
the local server, with the Conductor dispatcher replaced by canned fixtures so it never
touches ah5r-prod. Validation is deterministic (CLAUDE.md): asserts every dispatched
command is read-only and the runtime returned output — does NOT LLM-judge the hypothesis.

Verified live: agent reads the incident first, then dispatches only read-only commands
(GET_CLUSTER_METRICS, GET_PODS_DATA, GET_POD_EVENTS, PULL_LOGS x2), follows the
Redis->decider-queue->logs playbook, and emits the Issue/Findings/Root-cause/Next-step
summary. Proves the SDK migration runs end-to-end and the read-only guard holds at runtime.
…DOMAIN_REACHABILITY playbook

Two-step logic for the 'domain X is down' (reachability/502) alert: first rule out
Conductor itself (server pod crashloop/OOM via pods/events/logs); if the server is
healthy, treat the 502 as the network/ingress layer. Encodes the ingress-nginx
stale-endpoint failure mode (pod restart -> new IP -> a controller replica keeps
routing to the dead IP, inconsistent across replicas), with graceful degradation
if the ingress namespace isn't visible to the read-only tools. Fixes the prior
blind spot where a provisioned-LB reachability alert was reflexively called
'external'. No coverage-test change: 'domain X is down' anchor preserved.
…CHRONIC

The agent triaged every alert as a fresh incident, missing that a chronic
flapper (this one fired on ~30% of recent one-staging health-checks) is a
standing capacity problem, not a page. Adds get_alert_recurrence: one search
filtered by the unique clusterId UUID (NOT the fuzzy cluster name), classified
locally by a pure, retention-aware summarize_recurrence(). Instructions now run
the recurrence check early and lead the summary with NEW/RECURRING + the
retention caveat (true onset may predate the search window -> Slack/Prometheus).

Tests: pure classifier, validity proven by mutation; ISO-8601 start_time parsing
locked (caught live).
The server returns agent output as {result, finishReason, context,
rejectionReason}; the Slack reply path concatenated that dict into the
message header and crashed (TypeError) at the last step of the live
e2e run. Extract the text via runtime_compat.summary_text (accepts both
the dict shape and the older plain-string output) in both the poller
and the CLI triage path. Repro test: DictOutputRuntime in test_poller,
failed with the exact live TypeError before the fix.
Live e2e showed 'I have all the evidence needed. Let me compile...'
leaking into the Slack post; the final message is posted verbatim.
The instruction-only fix didn't hold — the second live run still leaked
'I have all the data I need...' into the Slack reply. Enforce the output
contract in summary_text(): drop everything before the mandated *Issue*:
header. Test extended with the real leaked-preamble shape; failed before.
…r reported NEW

Live miss (2026-07-22): agent passed signature 'Pod Failed' (pod id
correctly stripped per instructions) but the reason text is
'Pod orkes-agent-…-shldc Failed' — substring match found nothing, so a
100/100-runs chronic flapper was reported first-seen/NEW. Match every
significant signature word as a token in any position; drop numeric
tokens on both sides (percentages vary per firing). Live re-check now
yields RECURRING/CHRONIC matched=100/100.
The digest channel posts one message per (cluster, alert-type) incident
and edits it in place with an occurrence counter — polling it gives
flapper dedup for free. The headline text carries no execution URL; the
original alert is quoted inside a section block. Flatten text + block
mrkdwn (message_text) before parsing, so both raw-channel and digest
messages parse with the same parser, and the occurrence count reaches
the triage prompt. Fixture is the live At-Bay HEAP_HIGH digest.
…scorable report

Production gate: human-scored hypotheses over real alerts. eval_select
dedupes the flapper-dominated stream to unique incidents (cluster +
number-stripped token set, same normalization as recurrence matching;
poll-timeout noise excluded). scripts/eval_batch.py replays each through
the agent (one shared runtime) and writes a markdown report with a
Useful/Partly/Wrong score line per incident. Reports are generated
output and are not committed.
…ifests

Two-container pod: agentspan/server:latest sidecar (ANTHROPIC_API_KEY
from secret at boot — the known cold-start requirement) + poller image
(python:3.11-slim, SDK installed from sdk/python). Entrypoint waits for
the sidecar's /health so the SDK never tries to auto-install a CLI in
the container. Digest channel is the default source; Recreate strategy
keeps the dedup state single-writer (PVC); kill switch = scale to 0.
Image builds and imports verified (11 tools registered).
… not -Xmx bumps

Team guidance from Manan: raising the heap ceiling by default masks
leaks. The playbook now mandates heap dump -> dominant retainers (MAT)
-> map to recently deployed changes; rolling restart only as short-term
relief; limit increase only after the dump justifies it.
…P_DUMP

Team decision: for heap alerts the agent should not tell the engineer
to capture a dump — it dispatches ah5r-prod's download_heap_dump on the
single highest-heap pod (once per incident; jmap is stop-the-world, so
the tool and playbook both forbid multi-pod dumps and non-memory use)
and reports the stored dump paths for MAT + recent-changes analysis.
DOWNLOAD_HEAP_DUMP moved off the banned list with the rationale recorded
in the guard test. Dispatch contract covered by deterministic tests.
SLACK_ALERT_CHANNEL now accepts a comma-separated list; dedup state is
kept per channel (ts values are channel-scoped in Slack), the legacy
single-channel state shape migrates onto the first configured channel,
and a failing channel cannot starve the others. k8s manifest watches
both the raw alert channel and the aggregator digest channel.
…or CPU triage

download_thread_dump(execution_id, pod) dispatches ah5r-prod's
download_thread_dump workflow (jstack — cheap, near-zero pause) and
returns the stored dump paths. Playbook CPU EVIDENCE RULE: when a CPU
alert's cause isn't visible in logs, dump the hottest pod's threads and
include the paths in the summary. DOWNLOAD_THREAD_DUMP moved off the
banned list alongside DOWNLOAD_HEAP_DUMP with rationale in the guard
test; DOWNLOAD_ALL_POD_LOGS stays banned.
…e it

If the draft next step tells the engineer to check/count/verify something
the agent's own read-only tools can answer, the agent must do it and move
the answer into Findings; the final next step may contain only actions it
cannot take (remediation, offline analysis, business decisions). When a
check is impossible read-only, say exactly why instead of delegating it.
…window

Live: the raw channel fired the same shailesh-test-gcp TIMED_OUT alert
4x in <1h (fresh execution id each firing) and each got a full LLM
triage + thread posts. alert_signature() = sorted word tokens with URLs
stripped and number/hex tokens dropped — stable across firings of one
incident, distinct across clusters/types. Within ONCALL_SIGNATURE_COOLDOWN
(default 3600s) a repeated signature is marked processed but not
re-triaged and posts nothing; suppression is cross-channel, so an
incident seen in both raw and digest channels is triaged once.
…fail playbook

run_kubectl_read dispatches KUBECTL_UNRESTRICTED behind a deterministic
allowlist guard (kubectl_guard: get/describe/logs/top/events/explain/
auth can-i/rollout history|status; shell metacharacters rejected) —
same philosophy as sql_guard, validated live on orkes-wvuf-prod
(namespaced reads work; the agent SA is namespace-scoped so -A is
Forbidden, documented). Playbook: TIMED_OUT/lost-telemetry alerts now
fast-fail to AGENT_DOWN after one hung probe — every tool executes
through the in-cluster agent, so when the agent is down there is no
read-only path (kubectl_unrestricted included: RunKubectlWorker runs
in orkes-saas-agent) and a human with kubectl/cloud-API access is
required.
…ture

Live: one-staging fired the same CPU-100% alert twice in 10 min naming a
different conductor pod each time (…-65pkn vs …-pv6m5) — the 5-char k8s
pod suffix survived the hex-only filter, so the cooldown saw two
incidents. Drop every mixed digit+letter token (identifiers by nature:
uuid/hex fragments, ReplicaSet hashes, pod suffixes); subsumes the old
hexish rule.
…t wording

Live (orkes-prod): the same CPU condition fired as 'following issue'
naming pod …-q9h9d, then as 'following 2 issues' adding pod …-pvxjg —
an all-letter k8s suffix the digit+letter rule misses, plus plural
boilerplate. k8s suffixes are vowel-free by design, so drop short
vowel-less tokens; normalize issues->issue. Repro test from the real
message pair failed before.
…g triage once

Live: the raw channel's TIMED_OUT message and the digest channel's
aggregated message referenced the same execution but tokenize
differently, so signature suppression missed the pair and the same
execution was triaged twice. An execution id, once triaged, is never
triaged again (state['executions'], newest-500 cap); signature cooldown
still handles fresh executions of a flapping incident.
manan164 added 8 commits July 23, 2026 09:15
Live failure (2026-07-23, twice): a transient network blip left
conductor-python's shared httpx client with a dead socket ('Bad file
descriptor') and a poisoned auth token; every retry reused the broken
client, tools failed indefinitely, and the sequential poll loop wedged
for ~4h. ConductorDispatcher now routes every client call through
_call(): on failure it rebuilds the workflow client (fresh pool + fresh
token exchange) and retries once. Injectable client_factory for tests.
…runs

Live: the arm was set in memory before a multi-minute triage but only
saved after it; an exception escaping mid-triage (failed Slack post) is
absorbed by the per-channel guard and silently discarded the arm — the
state file stayed 30+ min stale while duplicates of orkes-prod and
zweorksksp001 re-triaged. Save immediately after arming; the post-triage
save still records processed/last_ts. Crash-mid-triage now trades a
duplicate triage for a possible dangling 'starting' marker (visible,
rare, cheaper).
…on, no queue-number-only attributions

Manan's review: CPU alerts were converging on 'decider backlog' with
empty ERROR greps as the only log step — sweeper churn logs at INFO,
so the grep found nothing and the agent inferred causation from the
queue size. Now mandatory on CPU alerts: unfiltered 300-line tail of
the hottest pod with dominant-pattern-by-volume cited as evidence,
INFO-marker greps (sweeper/decider/timed-out/S3-abort/broken-pipe),
then thread dump; queue-number-only attribution is forbidden — the
summary must cite log volume or state the logs did not confirm.
Manan's review: 'fired 11 of the last 100 health-checks (11%)' counts
against ALL runs — most of which pass — and buries the ratio on-call
actually needs: of the checks that FAILED, how many were this alert?
RecurrenceReport now carries failing_count and fraction_of_failing, and
the RECURRING summary reads 'N of the M failing checks (X% of
failures)' alongside the window count.
…ing them

Manan's review: every repeat firing of a known incident burned a full
LLM triage. Now the first firing runs the full investigation and its
*Likely root cause* is remembered per signature (state['incidents']);
repeat firings within ONCALL_FULL_TRIAGE_INTERVAL (default 6h) get a
deterministic in-thread update built from memory — prior diagnosis,
firing count, span — with zero LLM tokens. A full re-investigation runs
after the interval WITH the prior diagnosis in the prompt (verify +
report delta, not rediscover). Signature changes (severity escalation,
new issue set) bypass memory by design. Chronic clusters drop from ~24
to ~4 LLM triages/day.
…nt, one memory

Live: endpoint-dev's memory was seeded from the raw-channel form, then
the digest form of the same incident signed differently (headline +
occurrence tokens) and bought a duplicate full triage. signable_text()
extracts the blockquoted original alert from digest messages (raw
messages pass through), so raw and digest forms share a signature —
cross-channel dedup and incident memory now cover both.
…ent top-level

Live 2026-07-27: twilio-non-prod-us paged 12 times (24 agent pods Failed) and
both triages posted an invented root cause — "the cluster has been deregistered
from the Orkes control plane" — because get_context saw clusterId AND clusterName
as null and the model reasoned from the nulls.

Twilio's health_check schedulers pass only organizationId top-level and put the
cluster under agentHandlerRequest.clusterName; Ocean's pass clusterName/clusterId
top-level, which is why this never surfaced. clusterName is load-bearing: dispatch
builds the agent routing domain from it, so it became "<org>#-#None",
prepare_agent_handler FAILED, and every read-only tool returned nothing. Verified
against the live execution — with the fallback, prepare_agent_handler completes and
get_pods_data / kubectl reads return real cluster data.

clusterId stays unresolved for these clusters (it appears nowhere in the
execution, all 28 tasks scanned), so get_alert_recurrence still reports
no_cluster_id and GET_CLUSTER_METRICS times out for them — the incident's own
clusterData covers the metrics. Fixing that belongs in the scheduler definitions.
…26-07-30 outage

- never diagnose from a single execution (compare prior runs; invariant
  signature = persistent, not transient/GC)
- Conductor-has-failed + healthy pods -> probe serving path (:5000 UI
  front-end vs :8080 API), not the JVM
- capture evidence (events, kill -3 stacks, live ingress logs) before
  recommending restarts; restarts destroyed evidence and fixed nothing
- repeated identical failure after a restart = retry-storm re-wedge:
  recommend load-shed before any further restart
- always surface FailedScheduling / zero-headroom when pods churn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant