Skip to content

Commit 78debc2

Browse files
committed
feat(governor): page PagerDuty on kill-switch trips
Wire the miner AMS kill-switch trip path into the existing PagerDuty alerting pattern instead of a new mechanism. packages/loopover-engine's kill-switch.ts gains a pure buildMinerKillSwitchPagerDutyAlert builder (mirrors buildMinerKillSwitchTransitionGovernorLedgerEvent's own no-op-unless-changed gate, but only on a TRIP, never a resume). packages/loopover-miner's governor-kill-switch.ts gains the IO wrapper notifyMinerKillSwitchPagerDuty, mirroring src/services/notify-pagerduty.ts's Events API v2 contract (LOOPOVER_ENABLE_PAGERDUTY flag, PAGERDUTY_ROUTING_KEY, dedup_key) with the same no-D1/Worker-Env simplification control-plane's own mirror (#7667) used. recordMinerKillSwitchTransition now pages fire-and-forget after the ledger row lands, wrapped so a paging failure can never block or mask the ledger write. Closes #7666
1 parent c2c8697 commit 78debc2

6 files changed

Lines changed: 540 additions & 5 deletions

File tree

apps/loopover-ui/content/docs/ams-kill-switch-incident.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ nothing should hold a stale lock.
3939

4040
## 1. Detection
4141

42-
Flag a misbehaving loop from any of:
42+
Since #7666, a kill-switch **trip** (not a resume) also pages PagerDuty automatically — the same
43+
opt-in `LOOPOVER_ENABLE_PAGERDUTY` / `PAGERDUTY_ROUTING_KEY` contract as ORB's own alerting
44+
(`src/services/notify-pagerduty.ts`) — so the operator "on the page" below no longer has to already
45+
be watching a dashboard to notice the halt. Absent that, or as a second signal, flag a misbehaving
46+
loop from any of:
4347

4448
1. Direct observation — destructive/off-scope file changes, a PR touching the wrong surface, repeated nonsensical commits.
4549
2. Soft-claim inventory — confirm what is in flight before you act:

packages/loopover-engine/src/governor/kill-switch.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,51 @@ export function buildMinerKillSwitchTransitionGovernorLedgerEvent(input: {
7373
payload: { previousScope: input.previousScope, scope: input.scope },
7474
};
7575
}
76+
77+
/** Same literal set as ORB's hosted `PagerDutySeverity` (`src/services/notify-pagerduty.ts`) — kept as a local
78+
* literal union rather than importing that module, since it lives in the main app, not this shared package. */
79+
export type MinerKillSwitchPagerDutySeverity = "critical" | "error" | "warning" | "info";
80+
81+
/** Pure PagerDuty alert payload for a kill-switch TRIP (#7666). Never built for a resume — clearing a halt is
82+
* relief, not an incident. */
83+
export type MinerKillSwitchPagerDutyAlert = {
84+
repoFullName: string | null;
85+
scope: MinerKillSwitchScope;
86+
actionClass: string;
87+
summary: string;
88+
severity: MinerKillSwitchPagerDutySeverity;
89+
dedupKey: string;
90+
customDetails: Record<string, unknown>;
91+
};
92+
93+
/**
94+
* Build the PagerDuty alert payload for a kill-switch TRIP transition (#7666) — the paging counterpart to
95+
* {@link buildMinerKillSwitchTransitionGovernorLedgerEvent}, sharing its exact "no-op unless the scope actually
96+
* changed" gate, but narrower: it additionally returns `null` on a transition INTO `"none"` (a resume), since
97+
* paging on "the halt cleared" would be noise, not an incident that needs a human. DETECTOR ONLY — no IO, same
98+
* as this whole module: `packages/loopover-miner/lib/governor-kill-switch.ts` performs the actual PagerDuty
99+
* Events API v2 call, mirroring how it (not this module) also performs the ledger IO for the sibling ledger-event
100+
* builder above. `dedupKey` intentionally omits `actionClass` — a repo/scope kill-switch trip is one incident
101+
* regardless of which action class first observed it, so PagerDuty's own dedup_key coalescing collapses repeats
102+
* into the same incident instead of opening a new one per action class.
103+
*/
104+
export function buildMinerKillSwitchPagerDutyAlert(input: {
105+
repoFullName?: string | null | undefined;
106+
actionClass: string;
107+
previousScope: MinerKillSwitchScope;
108+
scope: MinerKillSwitchScope;
109+
}): MinerKillSwitchPagerDutyAlert | null {
110+
if (input.previousScope === input.scope) return null;
111+
if (!isMinerKillSwitchActive(input.scope)) return null;
112+
const repoFullName = input.repoFullName ?? null;
113+
const target = repoFullName ?? "global";
114+
return {
115+
repoFullName,
116+
scope: input.scope,
117+
actionClass: input.actionClass,
118+
summary: `AMS miner kill-switch tripped (${input.scope}) — ${input.actionClass} halted for ${target}`,
119+
severity: "critical",
120+
dedupKey: `miner_kill_switch_tripped:${input.scope}:${target}`,
121+
customDetails: { scope: input.scope, previousScope: input.previousScope, repoFullName, actionClass: input.actionClass },
122+
};
123+
}

packages/loopover-engine/test/kill-switch.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { test } from "node:test";
33

44
import {
55
MINER_KILL_SWITCH_ENV_VAR,
6+
buildMinerKillSwitchPagerDutyAlert,
67
buildMinerKillSwitchTransitionGovernorLedgerEvent,
78
isGlobalMinerKillSwitch,
89
isMinerKillSwitchActive,
@@ -14,6 +15,7 @@ test("barrel: the public entrypoint re-exports the kill-switch primitive (#2341)
1415
assert.equal(typeof resolveMinerKillSwitch, "function");
1516
assert.equal(typeof isMinerKillSwitchActive, "function");
1617
assert.equal(typeof buildMinerKillSwitchTransitionGovernorLedgerEvent, "function");
18+
assert.equal(typeof buildMinerKillSwitchPagerDutyAlert, "function");
1719
assert.equal(MINER_KILL_SWITCH_ENV_VAR, "LOOPOVER_MINER_KILL_SWITCH");
1820
});
1921

@@ -105,3 +107,65 @@ test("buildMinerKillSwitchTransitionGovernorLedgerEvent: clearing the switch rec
105107
payload: { previousScope: "global", scope: "none" },
106108
});
107109
});
110+
111+
test("buildMinerKillSwitchPagerDutyAlert: no-op when the scope has not changed (#7666)", () => {
112+
assert.equal(
113+
buildMinerKillSwitchPagerDutyAlert({ actionClass: "open_pr", previousScope: "none", scope: "none" }),
114+
null,
115+
);
116+
assert.equal(
117+
buildMinerKillSwitchPagerDutyAlert({ actionClass: "open_pr", previousScope: "repo", scope: "repo" }),
118+
null,
119+
);
120+
});
121+
122+
test("buildMinerKillSwitchPagerDutyAlert: no-op on a resume transition -- only a trip pages (#7666)", () => {
123+
assert.equal(
124+
buildMinerKillSwitchPagerDutyAlert({
125+
repoFullName: "acme/widgets",
126+
actionClass: "open_pr",
127+
previousScope: "repo",
128+
scope: "none",
129+
}),
130+
null,
131+
);
132+
assert.equal(
133+
buildMinerKillSwitchPagerDutyAlert({ actionClass: "open_pr", previousScope: "global", scope: "none" }),
134+
null,
135+
);
136+
});
137+
138+
test("buildMinerKillSwitchPagerDutyAlert: a repo trip builds a critical alert with a repo-scoped dedup key (#7666)", () => {
139+
const alert = buildMinerKillSwitchPagerDutyAlert({
140+
repoFullName: "acme/widgets",
141+
actionClass: "open_pr",
142+
previousScope: "none",
143+
scope: "repo",
144+
});
145+
assert.deepEqual(alert, {
146+
repoFullName: "acme/widgets",
147+
scope: "repo",
148+
actionClass: "open_pr",
149+
summary: "AMS miner kill-switch tripped (repo) — open_pr halted for acme/widgets",
150+
severity: "critical",
151+
dedupKey: "miner_kill_switch_tripped:repo:acme/widgets",
152+
customDetails: { scope: "repo", previousScope: "none", repoFullName: "acme/widgets", actionClass: "open_pr" },
153+
});
154+
});
155+
156+
test("buildMinerKillSwitchPagerDutyAlert: a global trip with no repoFullName dedups on 'global', not null (#7666)", () => {
157+
const alert = buildMinerKillSwitchPagerDutyAlert({
158+
actionClass: "open_pr",
159+
previousScope: "none",
160+
scope: "global",
161+
});
162+
assert.deepEqual(alert, {
163+
repoFullName: null,
164+
scope: "global",
165+
actionClass: "open_pr",
166+
summary: "AMS miner kill-switch tripped (global) — open_pr halted for global",
167+
severity: "critical",
168+
dedupKey: "miner_kill_switch_tripped:global:global",
169+
customDetails: { scope: "global", previousScope: "none", repoFullName: null, actionClass: "open_pr" },
170+
});
171+
});

packages/loopover-miner/lib/governor-kill-switch.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,86 @@
22
// env, or for one repo, via its .loopover-miner.yml MinerGoalSpec) and records STATE TRANSITIONS to the
33
// append-only governor ledger. Every-check allow/deny recording for a real write action is the fail-closed
44
// Governor chokepoint's job (#2340), which consults this module first in its "safest wins" precedence.
5+
//
6+
// PagerDuty paging (#7666): a TRIP transition also fires a page, mirroring ORB's hosted `triggerPagerDutyIncident`
7+
// (src/services/notify-pagerduty.ts) Events API v2 contract -- same LOOPOVER_ENABLE_PAGERDUTY flag, same
8+
// PAGERDUTY_ROUTING_KEY, same enqueue URL/payload shape -- with the same simplification #7667's control-plane
9+
// mirror (control-plane/src/pagerduty-notify.ts) used: no D1/Worker Env here either (the miner is a plain Node
10+
// process), so no per-repo routing-key map and no severity-threshold/cooldown DB query; PagerDuty's own
11+
// `dedup_key` still coalesces duplicate incidents. Best-effort: paging can never block or throw past the ledger
12+
// write it accompanies.
513

614
import {
15+
buildMinerKillSwitchPagerDutyAlert,
716
buildMinerKillSwitchTransitionGovernorLedgerEvent,
817
isGlobalMinerKillSwitch,
918
isMinerKillSwitchActive,
1019
resolveMinerKillSwitch,
1120
} from "@loopover/engine";
12-
import type { MinerKillSwitchScope } from "@loopover/engine";
21+
import type { MinerKillSwitchPagerDutyAlert, MinerKillSwitchScope } from "@loopover/engine";
1322
import { appendGovernorEvent } from "./governor-ledger.js";
1423
import type { AppendGovernorEventInput, GovernorLedgerEntry } from "./governor-ledger.js";
1524

25+
const PAGERDUTY_EVENTS_URL = "https://events.pagerduty.com/v2/enqueue";
26+
// PagerDuty routing/integration keys are 32 lowercase hex characters.
27+
const ROUTING_KEY_RE = /^[a-f0-9]{32}$/i;
28+
const TRUTHY_ENV = /^(1|true|yes|on)$/i;
29+
30+
export type NotifyMinerKillSwitchPagerDuty = (
31+
alert: MinerKillSwitchPagerDutyAlert,
32+
env: Record<string, string | undefined>,
33+
) => void | Promise<void>;
34+
35+
function envString(env: Record<string, string | undefined>, name: string): string | undefined {
36+
const value = env[name];
37+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
38+
}
39+
40+
function warnMinerKillSwitchPagerDutyFailed(dedupKey: string, error: unknown): void {
41+
const message = (error instanceof Error ? error.message : String(error)).slice(0, 200);
42+
console.warn(JSON.stringify({ event: "miner_kill_switch_pagerduty_failed", dedupKey, message }));
43+
}
44+
45+
/** Miner-side mirror of ORB's `triggerPagerDutyIncident` (src/services/notify-pagerduty.ts) Events API v2
46+
* contract, same simplification #7667's control-plane mirror used (no D1/Worker Env here either): same
47+
* LOOPOVER_ENABLE_PAGERDUTY flag, same global PAGERDUTY_ROUTING_KEY, same enqueue URL/payload shape. PagerDuty's
48+
* own dedup_key still coalesces duplicate incidents. Best-effort: never throws -- a paging failure must never
49+
* block or mask the governor ledger write it is reporting on. */
50+
export async function notifyMinerKillSwitchPagerDuty(
51+
alert: MinerKillSwitchPagerDutyAlert,
52+
env: Record<string, string | undefined> = process.env,
53+
): Promise<void> {
54+
if (!TRUTHY_ENV.test((env.LOOPOVER_ENABLE_PAGERDUTY ?? "").trim())) return;
55+
const routingKey = envString(env, "PAGERDUTY_ROUTING_KEY");
56+
if (!routingKey || !ROUTING_KEY_RE.test(routingKey)) return;
57+
58+
try {
59+
const response = await fetch(PAGERDUTY_EVENTS_URL, {
60+
method: "POST",
61+
headers: { "content-type": "application/json" },
62+
body: JSON.stringify({
63+
routing_key: routingKey,
64+
event_action: "trigger",
65+
dedup_key: alert.dedupKey,
66+
payload: {
67+
summary: alert.summary.slice(0, 1024),
68+
source: "loopover-miner",
69+
severity: alert.severity,
70+
timestamp: new Date().toISOString(),
71+
component: alert.repoFullName ?? "global",
72+
custom_details: alert.customDetails,
73+
},
74+
}),
75+
signal: AbortSignal.timeout(5000),
76+
});
77+
if (!response.ok) {
78+
console.warn(JSON.stringify({ event: "miner_kill_switch_pagerduty_failed", dedupKey: alert.dedupKey, status: response.status }));
79+
}
80+
} catch (error) {
81+
warnMinerKillSwitchPagerDutyFailed(alert.dedupKey, error);
82+
}
83+
}
84+
1685
export type CheckMinerKillSwitchInput = {
1786
repoPaused?: boolean;
1887
env?: Record<string, string | undefined>;
@@ -41,17 +110,46 @@ export type RecordMinerKillSwitchTransitionInput = {
41110
scope: MinerKillSwitchScope;
42111
};
43112

113+
export type RecordMinerKillSwitchTransitionOptions = {
114+
append?: (event: AppendGovernorEventInput) => GovernorLedgerEntry;
115+
/** Injectable for tests; defaults to the real {@link notifyMinerKillSwitchPagerDuty} Events API v2 call. */
116+
notify?: NotifyMinerKillSwitchPagerDuty;
117+
/** Defaults to `process.env`, matching {@link notifyMinerKillSwitchPagerDuty}'s own default. */
118+
env?: Record<string, string | undefined>;
119+
};
120+
44121
/**
45122
* Record a kill-switch state transition to the governor ledger. No-op (returns null, appends nothing) when the
46123
* scope has not actually changed since the previous check — callers own tracking the previous scope (in-memory
47124
* or persisted); this module holds no state of its own.
125+
*
126+
* On a TRIP (not a resume), also pages PagerDuty (#7666) via {@link notifyMinerKillSwitchPagerDuty}: the ledger
127+
* row is appended FIRST, then paging is fired fire-and-forget (wrapped in both a sync try/catch and a `.catch`
128+
* on its returned promise, so neither a synchronous throw nor an async rejection from the notify hook can ever
129+
* block or mask the ledger write that already landed).
48130
*/
49131
export function recordMinerKillSwitchTransition(
50132
input: RecordMinerKillSwitchTransitionInput,
51-
options: { append?: (event: AppendGovernorEventInput) => GovernorLedgerEntry } = {},
133+
options: RecordMinerKillSwitchTransitionOptions = {},
52134
): GovernorLedgerEntry | null {
53135
const event = buildMinerKillSwitchTransitionGovernorLedgerEvent(input);
54136
if (!event) return null;
55137
const append = options.append ?? appendGovernorEvent;
56-
return append(event as AppendGovernorEventInput);
138+
const entry = append(event as AppendGovernorEventInput);
139+
140+
const alert = buildMinerKillSwitchPagerDutyAlert(input);
141+
if (alert) {
142+
const notify = options.notify ?? notifyMinerKillSwitchPagerDuty;
143+
const env = options.env ?? process.env;
144+
try {
145+
const result = notify(alert, env);
146+
if (result && typeof (result as Promise<void>).catch === "function") {
147+
(result as Promise<void>).catch((error: unknown) => warnMinerKillSwitchPagerDutyFailed(alert.dedupKey, error));
148+
}
149+
} catch (error) {
150+
warnMinerKillSwitchPagerDutyFailed(alert.dedupKey, error);
151+
}
152+
}
153+
154+
return entry;
57155
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Pure-builder tests for buildMinerKillSwitchPagerDutyAlert (#7666) -- the PagerDuty-paging counterpart to
2+
// buildMinerKillSwitchTransitionGovernorLedgerEvent (see governor-run-halt.test.ts / kill-switch-incident-
3+
// runbook.test.ts for the same "test the engine's pure calculator directly" convention). The IO wrapper that
4+
// actually fires the Events API v2 call (packages/loopover-miner/lib/governor-kill-switch.ts's
5+
// notifyMinerKillSwitchPagerDuty) is covered by test/unit/miner-governor-kill-switch.test.ts.
6+
import { describe, expect, it } from "vitest";
7+
import { buildMinerKillSwitchPagerDutyAlert } from "../../packages/loopover-engine/src/governor/kill-switch";
8+
9+
describe("buildMinerKillSwitchPagerDutyAlert (#7666)", () => {
10+
it("no-op when the scope has not changed", () => {
11+
expect(buildMinerKillSwitchPagerDutyAlert({ actionClass: "open_pr", previousScope: "none", scope: "none" })).toBeNull();
12+
expect(buildMinerKillSwitchPagerDutyAlert({ actionClass: "open_pr", previousScope: "repo", scope: "repo" })).toBeNull();
13+
expect(buildMinerKillSwitchPagerDutyAlert({ actionClass: "open_pr", previousScope: "global", scope: "global" })).toBeNull();
14+
});
15+
16+
it("no-op on a resume transition (a transition INTO 'none') -- only a trip pages, never a resume", () => {
17+
expect(
18+
buildMinerKillSwitchPagerDutyAlert({
19+
repoFullName: "acme/widgets",
20+
actionClass: "open_pr",
21+
previousScope: "repo",
22+
scope: "none",
23+
}),
24+
).toBeNull();
25+
expect(buildMinerKillSwitchPagerDutyAlert({ actionClass: "open_pr", previousScope: "global", scope: "none" })).toBeNull();
26+
});
27+
28+
it("a repo trip builds a critical alert with a repo-scoped dedup key and component", () => {
29+
const alert = buildMinerKillSwitchPagerDutyAlert({
30+
repoFullName: "acme/widgets",
31+
actionClass: "open_pr",
32+
previousScope: "none",
33+
scope: "repo",
34+
});
35+
expect(alert).toEqual({
36+
repoFullName: "acme/widgets",
37+
scope: "repo",
38+
actionClass: "open_pr",
39+
summary: "AMS miner kill-switch tripped (repo) — open_pr halted for acme/widgets",
40+
severity: "critical",
41+
dedupKey: "miner_kill_switch_tripped:repo:acme/widgets",
42+
customDetails: { scope: "repo", previousScope: "none", repoFullName: "acme/widgets", actionClass: "open_pr" },
43+
});
44+
});
45+
46+
it("a global trip with no repoFullName supplied dedups/reports on the literal 'global' target, not null or omitted", () => {
47+
const alert = buildMinerKillSwitchPagerDutyAlert({
48+
actionClass: "open_pr",
49+
previousScope: "none",
50+
scope: "global",
51+
});
52+
expect(alert).toEqual({
53+
repoFullName: null,
54+
scope: "global",
55+
actionClass: "open_pr",
56+
summary: "AMS miner kill-switch tripped (global) — open_pr halted for global",
57+
severity: "critical",
58+
dedupKey: "miner_kill_switch_tripped:global:global",
59+
customDetails: { scope: "global", previousScope: "none", repoFullName: null, actionClass: "open_pr" },
60+
});
61+
});
62+
63+
it("REGRESSION: a global trip that also carries a repoFullName still dedups per-repo, matching component", () => {
64+
// Not a real-world combination (global halts every repo at once) but the builder must not silently drop
65+
// an unexpectedly-present repoFullName -- it should behave identically to the repo-scope case for targeting.
66+
const alert = buildMinerKillSwitchPagerDutyAlert({
67+
repoFullName: "acme/widgets",
68+
actionClass: "open_pr",
69+
previousScope: "none",
70+
scope: "global",
71+
});
72+
expect(alert?.dedupKey).toBe("miner_kill_switch_tripped:global:acme/widgets");
73+
expect(alert?.repoFullName).toBe("acme/widgets");
74+
});
75+
});

0 commit comments

Comments
 (0)