From 36b474a1c564b002ca684dfc2813d6b66e427737 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:07:17 -0700 Subject: [PATCH] feat(agent): DB-backed global kill-switch (instant freeze, no redeploy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-ports reviewbot's instant global freeze that the convergence dropped (audit §5.2). The only global brake was the env var AGENT_ACTIONS_PAUSED, which needs a Worker redeploy/secret push to flip, and the /status isFrozen field was a hardcoded `false` stub. Adds a singleton global_agent_controls row an operator can flip with one SQL statement (no redeploy). The action-mode resolver now folds the DB freeze into globalPaused at all three call sites (executor, the queue re-gate sweep, and the MCP activation preview), so a freeze halts every agent write action within one evaluation cycle. /status now reports the real freeze state. The read fails OPEN (the env var remains the hard backstop) so a transient D1 hiccup can't by itself halt the fleet. The per-PR SubmissionLock / consumer-concurrency piece of §5.3 is a larger Durable-Object port and the audit rated its race refuted-to-low (the planner is deterministic and all side-effect writes are idempotent upserts keyed by repo#pr, with the merge SHA-pin from #1227 as the hard backstop); deferred. --- migrations/0059_global_agent_controls.sql | 10 ++++++++++ src/db/repositories.ts | 21 +++++++++++++++++++++ src/mcp/server.ts | 3 ++- src/queue/processors.ts | 3 ++- src/review/ops.ts | 11 ++++++++++- src/services/agent-action-executor.ts | 6 ++++-- test/unit/agent-action-executor.test.ts | 19 +++++++++++++++++++ test/unit/ops.test.ts | 14 ++++++++++++++ 8 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 migrations/0059_global_agent_controls.sql 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) ──────────────────────────