Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions migrations/0059_global_agent_controls.sql
Original file line number Diff line number Diff line change
@@ -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);
21 changes: 21 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<void> {
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<void> {
const db = getDb(env.DB);
await db.insert(auditEvents).values({
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getPendingAgentAction,
getRepository,
getRepositorySettings,
isGlobalAgentFrozen,
getRepoQueueTrendSnapshot,
listAgentAuditEvents,
listCheckSummaries,
Expand Down Expand Up @@ -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).`,
Expand Down
3 changes: 2 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
recordAuditEvent,
recordGateBlockOutcome,
getGateBlockOutcome,
isGlobalAgentFrozen,
markGateOutcomeOverridden,
recordProductUsageEvent,
persistSignalSnapshot,
Expand Down Expand Up @@ -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,
});
Expand Down
11 changes: 10 additions & 1 deletion src/review/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
6 changes: 4 additions & 2 deletions src/services/agent-action-executor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -55,7 +55,9 @@ export function pendingClosureLabelApplied(plan: PlannedAgentAction[], outcomes:
export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionExecutionContext, planned: PlannedAgentAction[]): Promise<AgentActionOutcome[]> {
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);
Expand Down
19 changes: 19 additions & 0 deletions test/unit/agent-action-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): AgentActionExecutionContext {
Expand Down Expand Up @@ -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 }]);
Expand Down
14 changes: 14 additions & 0 deletions test/unit/ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────────────

Expand Down
Loading