diff --git a/migrations/0059_global_agent_controls.sql b/migrations/0059_global_agent_controls.sql new file mode 100644 index 0000000000..9debead35d --- /dev/null +++ b/migrations/0059_global_agent_controls.sql @@ -0,0 +1,10 @@ +-- Global agent kill-switch (#audit-§5.2). A DB-backed emergency brake an operator can flip with one row +-- (no redeploy), complementing the env-var AGENT_ACTIONS_PAUSED hard backstop. `frozen = 1` halts ALL agent +-- write actions across every repo within ~one evaluation cycle. Singleton: exactly one row, id = 'singleton'. +CREATE TABLE IF NOT EXISTS global_agent_controls ( + id TEXT PRIMARY KEY, + frozen INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by TEXT +); +INSERT OR IGNORE INTO global_agent_controls (id, frozen) VALUES ('singleton', 0); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 48e6bda531..7da0b60389 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1936,6 +1936,27 @@ export async function getProductUsageRollupStatus( }; } +// Global agent kill-switch (#audit-§5.2). A DB-backed emergency brake an operator flips with one row (no +// redeploy), complementing the env-var AGENT_ACTIONS_PAUSED hard backstop. Fail-OPEN on a read error (return +// false): a transient D1 hiccup must not by itself halt the whole fleet, and the env var is the hard backstop. +export async function isGlobalAgentFrozen(env: Env): Promise { + try { + const row = await env.DB.prepare("SELECT frozen FROM global_agent_controls WHERE id = 'singleton'").first<{ frozen: number }>(); + return row?.frozen === 1; + } catch { + return false; + } +} + +/** Flip the DB-backed global kill-switch (operator emergency brake; no redeploy required). */ +export async function setGlobalAgentFrozen(env: Env, frozen: boolean, updatedBy?: string | null): Promise { + await env.DB.prepare( + "INSERT INTO global_agent_controls (id, frozen, updated_at, updated_by) VALUES ('singleton', ?, CURRENT_TIMESTAMP, ?) ON CONFLICT(id) DO UPDATE SET frozen = excluded.frozen, updated_at = excluded.updated_at, updated_by = excluded.updated_by", + ) + .bind(frozen ? 1 : 0, updatedBy ?? null) + .run(); +} + export async function recordAuditEvent(env: Env, event: AuditEventRecord): Promise { const db = getDb(env.DB); await db.insert(auditEvents).values({ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c5c1646d68..b3060f1fdf 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -20,6 +20,7 @@ import { getPendingAgentAction, getRepository, getRepositorySettings, + isGlobalAgentFrozen, getRepoQueueTrendSnapshot, listAgentAuditEvents, listCheckSummaries, @@ -2270,7 +2271,7 @@ export class GittensoryMcp { const autonomy = settings.autonomy; const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); const installation = repo?.installationId ? await getInstallation(this.env, repo.installationId) : null; - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env) || (await isGlobalAgentFrozen(this.env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null }); return { summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pendingActionCount} pending approval(s).`, diff --git a/src/queue/processors.ts b/src/queue/processors.ts index fc9bfdb7e1..760446a4f3 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -43,6 +43,7 @@ import { recordAuditEvent, recordGateBlockOutcome, getGateBlockOutcome, + isGlobalAgentFrozen, markGateOutcomeOverridden, recordProductUsageEvent, persistSignalSnapshot, @@ -534,7 +535,7 @@ async function sweepRepoRegate(env: Env, repoFullName: string | undefined): Prom // Defensive: a repo can lose its acting autonomy between fan-out and processing. if (!isAgentConfigured(settings.autonomy)) return; const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), // env brake OR DB kill-switch (#audit-§5.2) agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); diff --git a/src/review/ops.ts b/src/review/ops.ts index 7571568591..0311b4815e 100644 --- a/src/review/ops.ts +++ b/src/review/ops.ts @@ -155,7 +155,16 @@ export interface OpsHealthDeps { export const defaultOpsHealthDeps: OpsHealthDeps = { validateAgentConfig: () => [], - isFrozen: async () => false, + // The DB-backed global kill-switch (#audit-§5.2): /status now reports the REAL freeze state instead of a + // hardcoded false. Raw SQL keeps this module self-contained; fail-open on a read error. + isFrozen: async (env) => { + try { + const row = await env.DB.prepare("SELECT frozen FROM global_agent_controls WHERE id = 'singleton'").first<{ frozen: number }>(); + return row?.frozen === 1; + } catch { + return false; + } + }, isHoldOnly: async () => false, }; diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index aa22f0de20..c769ff55eb 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -1,4 +1,4 @@ -import { bumpPullRequestMergeAttempt, createPendingAgentActionIfAbsent, insertNotificationDeliveryIfAbsent, markPullRequestApproved, markPullRequestMergeBlocked, recordAuditEvent } from "../db/repositories"; +import { bumpPullRequestMergeAttempt, createPendingAgentActionIfAbsent, insertNotificationDeliveryIfAbsent, isGlobalAgentFrozen, markPullRequestApproved, markPullRequestMergeBlocked, recordAuditEvent } from "../db/repositories"; import { classifyMergeFailure, MERGE_RETRY_CAP } from "./merge-failure"; import { notifyActionToDiscord, type NotifyOutcome } from "./notify-discord"; import { ensurePullRequestLabel, removePullRequestLabel } from "../github/labels"; @@ -55,7 +55,9 @@ export function pendingClosureLabelApplied(plan: PlannedAgentAction[], outcomes: export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionExecutionContext, planned: PlannedAgentAction[]): Promise { const outcomes: AgentActionOutcome[] = []; const targetKey = `${ctx.repoFullName}#${ctx.pullNumber}`; - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); + // globalPaused folds the env-var brake AND the DB-backed kill-switch (#audit-§5.2) so an operator can halt the + // fleet instantly via one DB row, without a redeploy. + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); for (const action of planned) { const autonomyLevel = resolveAutonomy(ctx.autonomy, action.actionClass); diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts index 6a3eea3157..b92275f924 100644 --- a/test/unit/agent-action-executor.test.ts +++ b/test/unit/agent-action-executor.test.ts @@ -17,6 +17,7 @@ import { ensurePullRequestLabel, removePullRequestLabel } from "../../src/github import { actionParams, executeAgentMaintenanceActions, pendingClosureLabelApplied, type AgentActionExecutionContext, type AgentActionOutcome } from "../../src/services/agent-action-executor"; import type { PlannedAgentAction } from "../../src/settings/agent-actions"; import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules"; +import { isGlobalAgentFrozen, setGlobalAgentFrozen } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; function ctx(over: Partial = {}): AgentActionExecutionContext { @@ -128,6 +129,24 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { expect(mergePullRequest).not.toHaveBeenCalled(); }); + it("DB-backed global freeze halts everything without a redeploy (#audit-§5.2)", async () => { + const env = createTestEnv({}); // env-var brake OFF + await setGlobalAgentFrozen(env, true, "operator"); + const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false }), [merge]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(mergePullRequest).not.toHaveBeenCalled(); + // ...and clearing the freeze restores normal execution. + await setGlobalAgentFrozen(env, false); + const after = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false }), [merge]); + expect(after[0]?.outcome).toBe("completed"); + expect(mergePullRequest).toHaveBeenCalled(); + }); + + it("isGlobalAgentFrozen fails open (false) on a read error — a D1 hiccup never freezes the fleet by itself", async () => { + const broken = { ...createTestEnv({}), DB: null } as unknown as Env; + expect(await isGlobalAgentFrozen(broken)).toBe(false); + }); + it("auto_with_approval: stages the action (queued) instead of executing", async () => { const env = createTestEnv({}); const outcomes = await executeAgentMaintenanceActions(env, ctx(), [{ ...merge, requiresApproval: true }]); diff --git a/test/unit/ops.test.ts b/test/unit/ops.test.ts index 5cd269c6ee..5499c22d5e 100644 --- a/test/unit/ops.test.ts +++ b/test/unit/ops.test.ts @@ -2,11 +2,25 @@ import { describe, expect, it } from "vitest"; import { computeAgentHealth, computeCalibration, + defaultOpsHealthDeps, handleInternalCalibration, handleInternalDecision, handleInternalStatus, type OpsAgentConfig, } from "../../src/review/ops"; +import { setGlobalAgentFrozen } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +describe("defaultOpsHealthDeps.isFrozen — DB-backed global freeze (#audit-§5.2)", () => { + it("reports the live DB freeze state and fails open on a read error", async () => { + const env = createTestEnv(); + expect(await defaultOpsHealthDeps.isFrozen(env, "owner/repo")).toBe(false); // default singleton frozen=0 + await setGlobalAgentFrozen(env, true); + expect(await defaultOpsHealthDeps.isFrozen(env, "owner/repo")).toBe(true); + const broken = { ...env, DB: null } as unknown as Env; + expect(await defaultOpsHealthDeps.isFrozen(broken, "owner/repo")).toBe(false); // fail-open on a read error + }); +}); // ── computeCalibration (ported from reviewbot test/calibration.test.ts) ──────────────────────────