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
7 changes: 7 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,13 @@ settings:
# Bool. Default: false.
agentDryRun: false

# Per-repo override of the GLOBAL DB-backed agent freeze (the operator kill-switch an operator flips with
# one row, no redeploy): when true, THIS repo's actions execute even while the global freeze is on, so an
# operator can re-activate one repo at a time without lifting the fleet-wide brake. Never overrides the
# AGENT_ACTIONS_PAUSED env var (that hard stop always wins), and agentPaused above on this same repo still
# wins over this too. Bool. Default: false.
agentGlobalFreezeOverride: false

# Four independent label families, none of which gates or silently disables another (#label-decoupling,
# #label-scoping):
# 1. Context label (`gittensorLabel`, gated by `autoLabelEnabled` above) — the base per-PR marker shown
Expand Down
3 changes: 3 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9317,6 +9317,9 @@
"copycatGateMinScore": {
"type": "number",
"nullable": true
},
"agentGlobalFreezeOverride": {
"type": "boolean"
}
},
"required": [
Expand Down
7 changes: 7 additions & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,13 @@ settings:
# Bool. Default: false.
agentDryRun: false

# Per-repo override of the GLOBAL DB-backed agent freeze (the operator kill-switch an operator flips with
# one row, no redeploy): when true, THIS repo's actions execute even while the global freeze is on, so an
# operator can re-activate one repo at a time without lifting the fleet-wide brake. Never overrides the
# AGENT_ACTIONS_PAUSED env var (that hard stop always wins), and agentPaused above on this same repo still
# wins over this too. Bool. Default: false.
agentGlobalFreezeOverride: false

# Four independent label families, none of which gates or silently disables another (#label-decoupling,
# #label-scoping):
# 1. Context label (`gittensorLabel`, gated by `autoLabelEnabled` above) — the base per-PR marker shown
Expand Down
14 changes: 14 additions & 0 deletions migrations/0127_agent_global_freeze_override.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Per-repo override of the DB-backed global agent freeze (#4372, incident follow-up): `global_agent_controls`
-- (migrations/0044-adjacent singleton, see isGlobalAgentFrozen/setGlobalAgentFrozen in src/db/repositories.ts)
-- has no per-repo scoping today -- flipping it affects every repo at once, which is what caused a real
-- multi-repo incident (live merges/closes fired for repos that were meant to stay paused). This column lets an
-- operator keep the global DB kill-switch ON as the safe default while opting ONE repo at a time back into live
-- execution via that repo's `.gittensory.yml` (`settings.agentGlobalFreezeOverride: true`), the same
-- global-default + per-repo-override shape every other gittensory setting already uses.
--
-- Deliberately does NOT touch `AGENT_ACTIONS_PAUSED` (isGlobalAgentPause): that env-var hard stop is checked
-- independently and remains absolute -- no per-repo setting may ever bypass it. A repo's own `agent_paused =
-- true` also still always wins over this override (the pausing direction stays deny-toward-safety; only the
-- un-pausing direction becomes something a repo can opt into). Default 0 (off) -- additive, every existing repo
-- keeps today's behavior (global frozen ⇒ frozen everywhere) until explicitly opted in.
ALTER TABLE repository_settings ADD COLUMN agent_global_freeze_override INTEGER NOT NULL DEFAULT 0;
3 changes: 2 additions & 1 deletion packages/gittensory-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ export type FocusManifestSettings = Partial<
| "autoMaintain"
| "agentPaused"
| "agentDryRun"
| "agentGlobalFreezeOverride"
| "commandAuthorization"
| "contributorBlacklist"
| "blacklistLabel"
Expand Down Expand Up @@ -1654,7 +1655,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
}
const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings);
if (publicSurface !== null) out.publicSurface = publicSurface;
for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun"] as const) {
for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun", "agentGlobalFreezeOverride"] as const) {
const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings);
if (flag !== null) out[key] = flag;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/gittensory-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,11 @@ export type RepositorySettings = {
/** Per-repo dry-run/shadow mode (#776): when true, the action layer records what it WOULD do without
* performing any GitHub mutation. Default false. */
agentDryRun?: boolean | undefined;
/** Per-repo override of the global DB-backed agent freeze (#4372): when true, this repo's actions execute
* even while `global_agent_controls.frozen` is set, so an operator can re-activate one repo at a time
* without lifting the fleet-wide brake. Never overrides the `AGENT_ACTIONS_PAUSED` env var, and
* {@link agentPaused} on this same repo still wins over it. Default false. */
agentGlobalFreezeOverride?: boolean | undefined;
/** Moderation-rules engine (#selfhost-mod-engine): whether the whole layer runs on THIS repo. `"inherit"`
* (the DB default) defers to `global_moderation_config.enabled`; `"off"`/`"enabled"` force this repo
* regardless of the global default. Always populated by the DB layer; optional so existing settings
Expand Down
17 changes: 17 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
publicQualityMetrics: false,
agentPaused: false,
agentDryRun: false,
agentGlobalFreezeOverride: false,
commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy,
contributorBlacklist: [],
autonomy: {},
Expand Down Expand Up @@ -619,6 +620,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
publicQualityMetrics: row.publicQualityMetrics,
agentPaused: row.agentPaused,
agentDryRun: row.agentDryRun,
agentGlobalFreezeOverride: row.agentGlobalFreezeOverride,
commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson),
contributorBlacklist: parseContributorBlacklist(row.contributorBlacklistJson),
autonomy: parseAutonomyPolicy(row.autonomyJson),
Expand Down Expand Up @@ -740,6 +742,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
publicQualityMetrics: settings.publicQualityMetrics ?? false,
agentPaused: settings.agentPaused ?? false,
agentDryRun: settings.agentDryRun ?? false,
agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride ?? false,
commandAuthorization: normalizeCommandAuthorizationPolicy(settings.commandAuthorization).policy,
contributorBlacklist: normalizeContributorBlacklist(settings.contributorBlacklist).entries,
autonomy: normalizeAutonomyPolicy(settings.autonomy),
Expand Down Expand Up @@ -820,6 +823,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
publicQualityMetrics: resolved.publicQualityMetrics,
agentPaused: resolved.agentPaused,
agentDryRun: resolved.agentDryRun,
agentGlobalFreezeOverride: resolved.agentGlobalFreezeOverride,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
contributorBlacklistJson: jsonString(resolved.contributorBlacklist),
autonomyJson: jsonString(resolved.autonomy),
Expand Down Expand Up @@ -905,6 +909,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
publicQualityMetrics: resolved.publicQualityMetrics,
agentPaused: resolved.agentPaused,
agentDryRun: resolved.agentDryRun,
agentGlobalFreezeOverride: resolved.agentGlobalFreezeOverride,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
contributorBlacklistJson: jsonString(resolved.contributorBlacklist),
autonomyJson: jsonString(resolved.autonomy),
Expand Down Expand Up @@ -2498,6 +2503,18 @@ export async function isGlobalAgentFrozen(env: Env): Promise<boolean> {
}
}

/** Per-repo override of the DB-backed global kill-switch (#4372, incident follow-up): lets an operator keep
* `global_agent_controls.frozen` ON as the fleet-wide safe default while opting ONE repo at a time back into
* live execution via that repo's `agentGlobalFreezeOverride` setting — the same global-default +
* per-repo-override shape every other gittensory setting already uses. Deliberately does NOT take the
* `AGENT_ACTIONS_PAUSED` env var into account: callers must still OR this result with {@link isGlobalAgentPause}
* themselves (matching every existing `resolveAgentActionMode({ globalPaused: ... })` call site), so the env
* var stays an absolute, non-overridable hard stop no repo setting can ever bypass. */
export async function isDbFrozenForRepo(env: Env, agentGlobalFreezeOverride: boolean | null | undefined): Promise<boolean> {
if (agentGlobalFreezeOverride === true) return false;
return isGlobalAgentFrozen(env);
}

/** Atomic re-gate fan-out dedup (#audit-fanout-dedup): claim the global fan-out slot for this window. The
* conditional UPDATE on the singleton matches only when the last fan-out is unset or older than `windowMs`. D1
* serializes writes, so when a BURST of fan-out jobs runs at once (a deploy-restart cron catch-up, or fan-out
Expand Down
4 changes: 4 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ export const repositorySettings = sqliteTable("repository_settings", {
autoMaintainJson: text("auto_maintain_json").notNull().default("{}"),
agentPaused: integer("agent_paused", { mode: "boolean" }).notNull().default(false),
agentDryRun: integer("agent_dry_run", { mode: "boolean" }).notNull().default(false),
// Per-repo override of the global DB-backed agent freeze (#4372): when true, THIS repo bypasses
// isGlobalAgentFrozen while the global kill-switch stays frozen for every other repo. Never bypasses the
// AGENT_ACTIONS_PAUSED env var, and agentPaused above still wins over this if both are set. Default false.
agentGlobalFreezeOverride: integer("agent_global_freeze_override", { mode: "boolean" }).notNull().default(false),
// Per-contributor open PR/issue caps (#2270, anti-abuse): null = no cap (default). Enforcement lands separately.
contributorOpenPrCap: integer("contributor_open_pr_cap"),
contributorOpenIssueCap: integer("contributor_open_issue_cap"),
Expand Down
11 changes: 6 additions & 5 deletions src/github/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Octokit } from "@octokit/core";
import { isGlobalAgentFrozen, recordAuditEvent } from "../db/repositories";
import { isDbFrozenForRepo, recordAuditEvent } from "../db/repositories";
import { isGlobalAgentPause, resolveAgentActionMode, type AgentActionMode } from "../settings/agent-execution";
import { incr } from "../selfhost/metrics";
import type { RepositorySettings } from "../types";
Expand Down Expand Up @@ -582,12 +582,13 @@ const WRITE_METHODS = new Set(["POST", "PATCH", "PUT", "DELETE"]);

/**
* Resolve a repo's agent action mode the SAME way the executor does: the env emergency brake OR the DB global
* freeze OR the per-repo pause/dry-run. Call this ONCE per review and thread the result into every surface write
* — it performs one isGlobalAgentFrozen() read, so it must never sit on a per-write hot path.
* freeze (unless THIS repo's `agentGlobalFreezeOverride` opts out of it) OR the per-repo pause/dry-run. Call
* this ONCE per review and thread the result into every surface write — it performs one isDbFrozenForRepo()
* read, so it must never sit on a per-write hot path.
*/
export async function resolveRepoActionMode(env: Env, settings: Pick<RepositorySettings, "agentPaused" | "agentDryRun"> | null | undefined): Promise<AgentActionMode> {
export async function resolveRepoActionMode(env: Env, settings: Pick<RepositorySettings, "agentPaused" | "agentDryRun" | "agentGlobalFreezeOverride"> | null | undefined): Promise<AgentActionMode> {
return resolveAgentActionMode({
globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)),
globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings?.agentGlobalFreezeOverride)),
agentPaused: settings?.agentPaused,
agentDryRun: settings?.agentDryRun,
});
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
getPendingAgentAction,
getPullRequest,
getRepository,
isGlobalAgentFrozen,
isDbFrozenForRepo,
getRepoQueueTrendSnapshot,
listAgentAuditEvents,
listCheckSummaries,
Expand Down Expand Up @@ -3097,7 +3097,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) || (await isGlobalAgentFrozen(this.env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun });
const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env) || (await isDbFrozenForRepo(this.env, settings.agentGlobalFreezeOverride)), 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
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,7 @@ export const RepositorySettingsSchema = z
autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(),
agentPaused: z.boolean().optional(),
agentDryRun: z.boolean().optional(),
agentGlobalFreezeOverride: z.boolean().optional(),
contributorOpenPrCap: z.number().int().positive().max(MAX_CONTRIBUTOR_OPEN_ITEM_CAP).nullable().optional(),
contributorOpenIssueCap: z.number().int().positive().max(MAX_CONTRIBUTOR_OPEN_ITEM_CAP).nullable().optional(),
contributorCapLabel: z.string().nullable().optional(),
Expand Down
Loading
Loading