Skip to content

Commit da8a339

Browse files
authored
feat(agent): DB-backed global kill-switch (instant freeze, no redeploy) (#1243)
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.
1 parent 8e95fa6 commit da8a339

8 files changed

Lines changed: 82 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Global agent kill-switch (#audit-§5.2). A DB-backed emergency brake an operator can flip with one row
2+
-- (no redeploy), complementing the env-var AGENT_ACTIONS_PAUSED hard backstop. `frozen = 1` halts ALL agent
3+
-- write actions across every repo within ~one evaluation cycle. Singleton: exactly one row, id = 'singleton'.
4+
CREATE TABLE IF NOT EXISTS global_agent_controls (
5+
id TEXT PRIMARY KEY,
6+
frozen INTEGER NOT NULL DEFAULT 0,
7+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
updated_by TEXT
9+
);
10+
INSERT OR IGNORE INTO global_agent_controls (id, frozen) VALUES ('singleton', 0);

src/db/repositories.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,6 +1936,27 @@ export async function getProductUsageRollupStatus(
19361936
};
19371937
}
19381938

1939+
// Global agent kill-switch (#audit-§5.2). A DB-backed emergency brake an operator flips with one row (no
1940+
// redeploy), complementing the env-var AGENT_ACTIONS_PAUSED hard backstop. Fail-OPEN on a read error (return
1941+
// false): a transient D1 hiccup must not by itself halt the whole fleet, and the env var is the hard backstop.
1942+
export async function isGlobalAgentFrozen(env: Env): Promise<boolean> {
1943+
try {
1944+
const row = await env.DB.prepare("SELECT frozen FROM global_agent_controls WHERE id = 'singleton'").first<{ frozen: number }>();
1945+
return row?.frozen === 1;
1946+
} catch {
1947+
return false;
1948+
}
1949+
}
1950+
1951+
/** Flip the DB-backed global kill-switch (operator emergency brake; no redeploy required). */
1952+
export async function setGlobalAgentFrozen(env: Env, frozen: boolean, updatedBy?: string | null): Promise<void> {
1953+
await env.DB.prepare(
1954+
"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",
1955+
)
1956+
.bind(frozen ? 1 : 0, updatedBy ?? null)
1957+
.run();
1958+
}
1959+
19391960
export async function recordAuditEvent(env: Env, event: AuditEventRecord): Promise<void> {
19401961
const db = getDb(env.DB);
19411962
await db.insert(auditEvents).values({

src/mcp/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getPendingAgentAction,
2121
getRepository,
2222
getRepositorySettings,
23+
isGlobalAgentFrozen,
2324
getRepoQueueTrendSnapshot,
2425
listAgentAuditEvents,
2526
listCheckSummaries,
@@ -2270,7 +2271,7 @@ export class GittensoryMcp {
22702271
const autonomy = settings.autonomy;
22712272
const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass)));
22722273
const installation = repo?.installationId ? await getInstallation(this.env, repo.installationId) : null;
2273-
const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun });
2274+
const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env) || (await isGlobalAgentFrozen(this.env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun });
22742275
const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null });
22752276
return {
22762277
summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pendingActionCount} pending approval(s).`,

src/queue/processors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
recordAuditEvent,
4444
recordGateBlockOutcome,
4545
getGateBlockOutcome,
46+
isGlobalAgentFrozen,
4647
markGateOutcomeOverridden,
4748
recordProductUsageEvent,
4849
persistSignalSnapshot,
@@ -534,7 +535,7 @@ async function sweepRepoRegate(env: Env, repoFullName: string | undefined): Prom
534535
// Defensive: a repo can lose its acting autonomy between fan-out and processing.
535536
if (!isAgentConfigured(settings.autonomy)) return;
536537
const mode = resolveAgentActionMode({
537-
globalPaused: isGlobalAgentPause(env),
538+
globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), // env brake OR DB kill-switch (#audit-§5.2)
538539
agentPaused: settings.agentPaused,
539540
agentDryRun: settings.agentDryRun,
540541
});

src/review/ops.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,16 @@ export interface OpsHealthDeps {
155155

156156
export const defaultOpsHealthDeps: OpsHealthDeps = {
157157
validateAgentConfig: () => [],
158-
isFrozen: async () => false,
158+
// The DB-backed global kill-switch (#audit-§5.2): /status now reports the REAL freeze state instead of a
159+
// hardcoded false. Raw SQL keeps this module self-contained; fail-open on a read error.
160+
isFrozen: async (env) => {
161+
try {
162+
const row = await env.DB.prepare("SELECT frozen FROM global_agent_controls WHERE id = 'singleton'").first<{ frozen: number }>();
163+
return row?.frozen === 1;
164+
} catch {
165+
return false;
166+
}
167+
},
159168
isHoldOnly: async () => false,
160169
};
161170

src/services/agent-action-executor.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { bumpPullRequestMergeAttempt, createPendingAgentActionIfAbsent, insertNotificationDeliveryIfAbsent, markPullRequestApproved, markPullRequestMergeBlocked, recordAuditEvent } from "../db/repositories";
1+
import { bumpPullRequestMergeAttempt, createPendingAgentActionIfAbsent, insertNotificationDeliveryIfAbsent, isGlobalAgentFrozen, markPullRequestApproved, markPullRequestMergeBlocked, recordAuditEvent } from "../db/repositories";
22
import { classifyMergeFailure, MERGE_RETRY_CAP } from "./merge-failure";
33
import { notifyActionToDiscord, type NotifyOutcome } from "./notify-discord";
44
import { ensurePullRequestLabel, removePullRequestLabel } from "../github/labels";
@@ -55,7 +55,9 @@ export function pendingClosureLabelApplied(plan: PlannedAgentAction[], outcomes:
5555
export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionExecutionContext, planned: PlannedAgentAction[]): Promise<AgentActionOutcome[]> {
5656
const outcomes: AgentActionOutcome[] = [];
5757
const targetKey = `${ctx.repoFullName}#${ctx.pullNumber}`;
58-
const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun });
58+
// globalPaused folds the env-var brake AND the DB-backed kill-switch (#audit-§5.2) so an operator can halt the
59+
// fleet instantly via one DB row, without a redeploy.
60+
const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun });
5961

6062
for (const action of planned) {
6163
const autonomyLevel = resolveAutonomy(ctx.autonomy, action.actionClass);

test/unit/agent-action-executor.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ensurePullRequestLabel, removePullRequestLabel } from "../../src/github
1717
import { actionParams, executeAgentMaintenanceActions, pendingClosureLabelApplied, type AgentActionExecutionContext, type AgentActionOutcome } from "../../src/services/agent-action-executor";
1818
import type { PlannedAgentAction } from "../../src/settings/agent-actions";
1919
import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules";
20+
import { isGlobalAgentFrozen, setGlobalAgentFrozen } from "../../src/db/repositories";
2021
import { createTestEnv } from "../helpers/d1";
2122

2223
function ctx(over: Partial<AgentActionExecutionContext> = {}): AgentActionExecutionContext {
@@ -128,6 +129,24 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
128129
expect(mergePullRequest).not.toHaveBeenCalled();
129130
});
130131

132+
it("DB-backed global freeze halts everything without a redeploy (#audit-§5.2)", async () => {
133+
const env = createTestEnv({}); // env-var brake OFF
134+
await setGlobalAgentFrozen(env, true, "operator");
135+
const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false }), [merge]);
136+
expect(outcomes[0]?.outcome).toBe("denied");
137+
expect(mergePullRequest).not.toHaveBeenCalled();
138+
// ...and clearing the freeze restores normal execution.
139+
await setGlobalAgentFrozen(env, false);
140+
const after = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false }), [merge]);
141+
expect(after[0]?.outcome).toBe("completed");
142+
expect(mergePullRequest).toHaveBeenCalled();
143+
});
144+
145+
it("isGlobalAgentFrozen fails open (false) on a read error — a D1 hiccup never freezes the fleet by itself", async () => {
146+
const broken = { ...createTestEnv({}), DB: null } as unknown as Env;
147+
expect(await isGlobalAgentFrozen(broken)).toBe(false);
148+
});
149+
131150
it("auto_with_approval: stages the action (queued) instead of executing", async () => {
132151
const env = createTestEnv({});
133152
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [{ ...merge, requiresApproval: true }]);

test/unit/ops.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,25 @@ import { describe, expect, it } from "vitest";
22
import {
33
computeAgentHealth,
44
computeCalibration,
5+
defaultOpsHealthDeps,
56
handleInternalCalibration,
67
handleInternalDecision,
78
handleInternalStatus,
89
type OpsAgentConfig,
910
} from "../../src/review/ops";
11+
import { setGlobalAgentFrozen } from "../../src/db/repositories";
12+
import { createTestEnv } from "../helpers/d1";
13+
14+
describe("defaultOpsHealthDeps.isFrozen — DB-backed global freeze (#audit-§5.2)", () => {
15+
it("reports the live DB freeze state and fails open on a read error", async () => {
16+
const env = createTestEnv();
17+
expect(await defaultOpsHealthDeps.isFrozen(env, "owner/repo")).toBe(false); // default singleton frozen=0
18+
await setGlobalAgentFrozen(env, true);
19+
expect(await defaultOpsHealthDeps.isFrozen(env, "owner/repo")).toBe(true);
20+
const broken = { ...env, DB: null } as unknown as Env;
21+
expect(await defaultOpsHealthDeps.isFrozen(broken, "owner/repo")).toBe(false); // fail-open on a read error
22+
});
23+
});
1024

1125
// ── computeCalibration (ported from reviewbot test/calibration.test.ts) ──────────────────────────
1226

0 commit comments

Comments
 (0)