From f0fa00035e7672ea56e593a59fcd4f6b276f5389 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:38:36 -0700 Subject: [PATCH 1/2] docs: rename gittensory prose to loopover in .loopover.yml.example .loopover.yml.example had drifted from config/examples/loopover.full.yml after the repo rename -- several prose comments (CLA gate description, autonomy-outcome label names, the plugins/registry-lane section) still said "gittensory" while the full example already said "loopover". --- .loopover.yml.example | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/.loopover.yml.example b/.loopover.yml.example index 9d7ae80587..5ed5177d12 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -1,8 +1,8 @@ # ============================================================================ -# .loopover.yml — per-repo configuration for gittensory CI & gittensory review +# .loopover.yml — per-repo configuration for loopover CI & loopover review # ============================================================================ # -# Drop this file at the root of any repo gittensory watches to tune how the +# Drop this file at the root of any repo loopover watches to tune how the # review engine scores, gates, and comments on its pull requests — as # config-as-code, versioned alongside the project it governs. # @@ -219,14 +219,14 @@ gate: lockfileIntegrity: off # CLA / license-compatibility gate (#2564). Confirms contributor license-agreement consent before a PR - # can auto-merge — the gittensory analog of a "CLA assistant" bot. off | advisory | block. Default: off. + # can auto-merge — the loopover analog of a "CLA assistant" bot. off | advisory | block. Default: off. # advisory — surfaces a cla_consent_missing finding but never blocks. # block — also hard-blocks (one-shot close for a contributor) when neither detection method # below confirms consent. # Config-as-code only — no DB column or dashboard toggle; this can only be set here. claMode: off cla: - # A phrase gittensory looks for in the PR description (case-insensitive substring match), mirroring + # A phrase loopover looks for in the PR description (case-insensitive substring match), mirroring # review.pre_merge_checks' descriptionContains. String or null. Default: null (not configured). consentPhrase: "I have read and agree to the CLA" # Name of a separate CLA-bot check-run this repo also runs (e.g. a CLA Assistant GitHub Action). A @@ -722,7 +722,7 @@ settings: # from the PR title; priority (or any other category you register) ONLY from # `linkedIssueLabelPropagation` above — never inferred from title, files, AI, or existing PR # labels (#priority-linked-issue-gate). - # 3. Autonomy-outcome labels (`gittensory:ready-to-merge` etc.) — gated by the `review_state_label` + # 3. Autonomy-outcome labels (`loopover:ready-to-merge` etc.) — gated by the `review_state_label` # autonomy class below, advisory commentary on the bot's own verdict. # 4. Anti-abuse enforcement labels (blacklist/contributor-cap/review-nag) — gated by the `close` # autonomy class below, applied alongside the close action they accompany. @@ -736,8 +736,8 @@ settings: # labels (blacklist/contributor-cap/review-nag) ride on the SAME dial as their accompanying close # (`close`, below) — set `close: auto` and they close-and-label together with no `label` grant needed. # - `review_state_label` gates the bot's own disposition-communication labels only: - # gittensory:ready-to-merge / gittensory:changes-requested / gittensory:needs-human-review / - # gittensory:migration-collision. These are advisory signals about the bot's own verdict, not + # loopover:ready-to-merge / loopover:changes-requested / loopover:needs-human-review / + # loopover:migration-collision. These are advisory signals about the bot's own verdict, not # enforcement — for a one-shot review model (merge/close/hold through the required gate check, no # back-and-forth) leave this at the default `observe` so they never appear; set it to `auto` only if # you specifically want that running commentary as GitHub labels. @@ -824,7 +824,7 @@ settings: # List of GitHub logins. Default: [] (no logins watched). # reviewNagMonitoredMentions: [your-maintainer-login] - # Shared repo-scoped exemption list (#2463): GitHub logins never throttled/closed by gittensory's + # Shared repo-scoped exemption list (#2463): GitHub logins never throttled/closed by loopover's # deterministic anti-abuse mechanisms (review-nag cooldown today; the per-contributor open-item cap # above will reuse this list too), on top of the standing owner/admin/automation-bot exemption. # List of GitHub logins. Default: [] (no additional exemptions). @@ -856,8 +856,8 @@ settings: # moderationBannedLabel: mod:banned # Label applied at >= the ban threshold. Default: the global config's bannedLabel. # Review-evasion protection (#review-evasion-protection, anti-abuse): a contributor closing or converting - # their own PR to draft while gittensory has an ACTIVE review pass running against it is dodging the - # one-shot review process, not making an ordinary close. When enabled, gittensory reopens (if needed) and + # their own PR to draft while loopover has an ACTIVE review pass running against it is dodging the + # one-shot review process, not making an ordinary close. When enabled, loopover reopens (if needed) and # re-closes the PR as the App -- a close the contributor cannot themselves reopen (#one-shot-reopen) -- # posts an explanation comment, applies the configured label, and records a `review_evasion` moderation # strike (subject to moderationRules above including it). CLOSE BY DEFAULT as of #4011 -- "off" is an @@ -1057,7 +1057,7 @@ settings: # url_template: "https://pr-{number}.myapp.workers.dev" # routes: # # An explicit, always-screenshotted route list. When non-empty, REPLACES automatic file-to-route -# # inference entirely -- for a repo whose routing convention isn't gittensory-ui's TanStack file-based +# # inference entirely -- for a repo whose routing convention isn't loopover-ui's TanStack file-based # # one. Empty/default ⇒ automatic inference (falling back to "/" when nothing matches). # paths: # - "/pricing" @@ -1095,11 +1095,11 @@ settings: # # .github/workflows/visual-capture-fallback.yml -- a fork-safe GitHub Actions job (contents: read, no # # secrets) that builds, serves, and screenshots the PR's own code, and use its captured PNGs as the # # "after" shot instead. Requires that workflow file to be present in this repo (copy it from -# # JSONbored/gittensory unmodified -- see the workflow's own header for setup). Bool. Default: false (no -# # dispatch, byte-identical to today). Not needed for gittensory-ui / metagraphed, which already have +# # JSONbored/loopover unmodified -- see the workflow's own header for setup). Bool. Default: false (no +# # dispatch, byte-identical to today). Not needed for loopover-ui / metagraphed, which already have # # their own preview-deploy pipeline. (#4112, part of the #3607 visual-capture convergence epic) # actions_fallback: false -# # Maintainer overrides for the public review-panel CONTENT (not what gittensory measures). The +# # Maintainer overrides for the public review-panel CONTENT (not what loopover measures). The # # Gittensor attribution + register link is always appended to the footer regardless; maintainer text # # failing the public-safe filter is dropped, never published. # footer: @@ -1156,20 +1156,20 @@ settings: # screenshots: false # improvementSignal: false -# Optional ecosystem/network PLUGINS -- distinct from `features:` above, which only toggles gittensory's own +# Optional ecosystem/network PLUGINS -- distinct from `features:` above, which only toggles loopover's own # converged review capabilities. Each key here couples this instance to an external system and is OFF unless # BOTH a deployment-wide LOOPOVER_EXPERIMENTAL_* env kill-switch AND an explicit per-repo `true` are set (no # LOOPOVER_REVIEW_REPOS allowlist fallback -- unlike `features:`, there is no default-on path). `gittensor` -# is the first plugin: gittensory's original subnet mining-registry/scoring integration (per-repo emission +# is the first plugin: loopover's original subnet mining-registry/scoring integration (per-repo emission # share, maintainer cut, label multipliers pulled from the gittensor subnet's registry), now opt-in rather # than a core dependency -- a self-host instance that never sets this has zero footprint from it: no fetch # from the gittensor subnet registry, no local tracking/backfill of other repos on that subnet. Future -# plugins land in this same block as gittensory broadens beyond gittensor. +# plugins land in this same block as loopover broadens beyond gittensor. # experimental: # gittensor: true -# Registry-review lane (#2435): lets a self-hosted maintainer point gittensory at their OWN structured -# registry (e.g. a subnet/plugin/package catalog) without a gittensory code change -- reviewing additions +# Registry-review lane (#2435): lets a self-hosted maintainer point loopover at their OWN structured +# registry (e.g. a subnet/plugin/package catalog) without a loopover code change -- reviewing additions # to a data file the same way it reviews code. Uncomment and set at least entryFileGlob + collectionField # (both required; the block is ignored with a warning if either is missing). # contentLane: @@ -1204,7 +1204,7 @@ settings: # Cross-repo maintainer recap digest (#1963, #2250): config-as-code override for the CRON-scheduled digest # that folds gate-precision + outcome-calibration across every scanned repo into one report (distinct from -# the single-repo reviewRecap above). Operator-level, not per-repo -- only meaningful on the gittensory +# the single-repo reviewRecap above). Operator-level, not per-repo -- only meaningful on the loopover # self-repo's own manifest (the repo this instance identifies as); a present block there wins over the # LOOPOVER_MAINTAINER_RECAP / LOOPOVER_RECAP_CADENCE env vars, which stay the fallback when absent. # maintainerRecap: From e05a8791752a1744e0307e250bd3170a327456e7 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:38:53 -0700 Subject: [PATCH 2/2] fix(agent): make the fleet-wide DB freeze absolute, drop the per-repo bypass global_agent_controls.frozen (isGlobalAgentFrozen) previously had a per-repo escape hatch: repository_settings.agent_global_freeze_override (also settable from an operator's private .loopover.yml, source: "api_record") let one repo opt out of the fleet-wide freeze while every other repo stayed frozen. That mechanism required either a raw DB write or an operator-only config field with no config-as-code parity with any other repo setting, and made the freeze state depend on two independently mutable sources of truth instead of one. Remove agentGlobalFreezeOverride entirely. global_agent_controls.frozen is now an absolute brake, same tier as the AGENT_ACTIONS_PAUSED env var -- no per-repo setting can bypass it. Day-to-day per-repo enable/disable is settings.agentPaused instead, which is already resolved through the normal global-default + per-repo-override .loopover.yml config layering with no DB coupling. Drops the repository_settings.agent_global_freeze_override column (migrations/0150), the FocusManifestSettings field and its source-gated parser branch, the OpenAPI field, the now-unnecessary stripMaintainerFocusManifestSettings maintainer-write guard, and every isDbFrozenForRepo call site (now isGlobalAgentFrozen directly). --- apps/loopover-ui/public/openapi.json | 3 - ...0150_drop_agent_global_freeze_override.sql | 11 ++++ .../loopover-engine/src/focus-manifest.ts | 28 +------- .../src/types/manifest-deps-types.ts | 5 -- scripts/check-docs-drift.mjs | 16 +---- src/api/routes.ts | 16 +---- src/db/repositories.ts | 17 ----- src/db/schema.ts | 4 -- src/github/client.ts | 12 ++-- src/mcp/server.ts | 4 +- src/openapi/schemas.ts | 1 - src/queue/processors.ts | 39 +++++------ src/queue/review-evasion.ts | 4 +- src/services/agent-action-executor.ts | 12 +--- src/services/agent-approval-queue.ts | 1 - src/services/contributor-issue-draft.ts | 8 +-- src/types.ts | 5 -- test/integration/api.test.ts | 14 ---- test/unit/agent-action-executor.test.ts | 58 +++------------- test/unit/check-docs-drift-script.test.ts | 14 ++-- test/unit/data-spine.test.ts | 6 -- test/unit/focus-manifest.test.ts | 37 +---------- test/unit/github-client.test.ts | 12 ++-- test/unit/routes-focus-manifest.test.ts | 66 +------------------ 24 files changed, 73 insertions(+), 320 deletions(-) create mode 100644 migrations/0150_drop_agent_global_freeze_override.sql diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 96922b6024..98bbf3511c 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -9399,9 +9399,6 @@ "agentDryRun": { "type": "boolean" }, - "agentGlobalFreezeOverride": { - "type": "boolean" - }, "contributorOpenPrCap": { "type": "integer", "nullable": true, diff --git a/migrations/0150_drop_agent_global_freeze_override.sql b/migrations/0150_drop_agent_global_freeze_override.sql new file mode 100644 index 0000000000..0c4feec3b8 --- /dev/null +++ b/migrations/0150_drop_agent_global_freeze_override.sql @@ -0,0 +1,11 @@ +-- Dead-column cleanup: removes the per-repo escape hatch from the global DB kill-switch (#4372/#4391). +-- global_agent_controls.frozen (isGlobalAgentFrozen) is now an absolute fleet-wide brake with no per-repo +-- bypass, same tier as the AGENT_ACTIONS_PAUSED env var -- day-to-day repo enable/disable is +-- repository_settings.agent_paused instead, which is already resolved through the normal global-default + +-- per-repo-override .loopover.yml config layering (settings.agentPaused). This column let one repo opt out of +-- a fleet-wide freeze while every other repo stayed frozen, but that required either a raw DB write or a +-- container-private config edit gated to source: "api_record" -- an operator-only lever with no real config- +-- as-code parity with every other repository_settings field, and the exact kind of DB-controlled behavior +-- this project's config-as-code convention rules out. SQLite 3.35+ / D1 supports DROP COLUMN directly (same +-- precedent as 0122_drop_private_trust_enabled.sql / 0146_drop_gate_check_mode.sql). +ALTER TABLE repository_settings DROP COLUMN agent_global_freeze_override; diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index 8ba9da8c98..8f5e14e78d 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -403,7 +403,6 @@ export type FocusManifestSettings = Partial< | "autoMaintain" | "agentPaused" | "agentDryRun" - | "agentGlobalFreezeOverride" | "commandAuthorization" | "contributorBlacklist" | "blacklistLabel" @@ -1798,7 +1797,7 @@ const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365; * Parse the optional `settings:` mapping — a partial repository-settings override. Only recognized * fields are kept; unknown/invalid values are dropped with a warning and never throw. */ -function parseSettingsOverride(value: JsonValue | undefined, warnings: string[], source?: FocusManifestSource): FocusManifestSettings { +function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]): FocusManifestSettings { if (value === undefined || value === null) return {}; if (typeof value !== "object" || Array.isArray(value)) { warnings.push(`Manifest field "settings" must be a mapping; ignoring it.`); @@ -1862,29 +1861,6 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[], const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } - // agentGlobalFreezeOverride is deliberately NOT in the generic boolean loop above (#4372/#4391/operator-only- - // freeze-fix): it is an OPERATOR-ONLY emergency lever ("re-activate this one repo while the fleet-wide kill- - // switch stays on elsewhere"), and every OTHER settings field in that loop is readable from BOTH the public, - // maintainer-owned `.loopover.yml` committed in the repo's own git history (source: "repo_file") AND the - // operator's private, container-local self-host config (source: "api_record") -- see loadRepoFocusManifestWithCachePolicy - // in focus-manifest-loader.ts for how each source is produced. A repo MAINTAINER must never be able to grant - // their own repo an exemption from the operator's fleet-wide freeze via their own committed yml (that is - // exactly the "scope leak" #4391 closed by stripping this field from the shared loop entirely). But the - // OPERATOR's own private config source is a fundamentally different trust boundary -- it is edited only by - // whoever has filesystem access to the container's private config directory, not by any repo's maintainers -- - // and #4391 over-corrected by also removing the operator's own legitimate, config-as-code path for this lever, - // forcing raw undocumented DB writes as the only remaining mechanism (violating this project's config-as-code - // convention: every operator-facing control belongs in the global-default + per-repo-override config files, - // env vars are for bootstrap only). Restore it, gated STRICTLY to the private source. - if (source === "api_record") { - const agentGlobalFreezeOverride = normalizeOptionalBoolean(r.agentGlobalFreezeOverride, "settings.agentGlobalFreezeOverride", warnings); - if (agentGlobalFreezeOverride !== null) out.agentGlobalFreezeOverride = agentGlobalFreezeOverride; - } else if (r.agentGlobalFreezeOverride !== undefined) { - // A public/maintainer-owned manifest attempting to set this is silently dropped, not surfaced as a normal - // "invalid value" warning -- warnings are public-safe text that can reach a contributor-facing preview, and - // this should not teach a non-operator that the field exists or that they almost bypassed the fleet freeze. - warnings.push("Ignored settings.agentGlobalFreezeOverride: operator-only, not settable from a repo-owned manifest."); - } // Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it // when at least one valid class→level pair survives normalization, so a malformed block never blanks the // DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay. @@ -3102,7 +3078,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): maintainerNotes: normalizeStringList(record.maintainerNotes, "maintainerNotes", warnings), publicNotes: normalizeStringList(record.publicNotes, "publicNotes", warnings).filter(isFocusManifestPublicSafe), gate: parseGateConfig(record.gate, warnings), - settings: parseSettingsOverride(record.settings, warnings, resolvedSource), + settings: parseSettingsOverride(record.settings, warnings), review: parseReviewConfig(record.review, warnings), features: parseFeaturesConfig(record.features, warnings), experimental: parseExperimentalConfig(record.experimental, warnings), diff --git a/packages/loopover-engine/src/types/manifest-deps-types.ts b/packages/loopover-engine/src/types/manifest-deps-types.ts index 35e2373379..f40c5f5017 100644 --- a/packages/loopover-engine/src/types/manifest-deps-types.ts +++ b/packages/loopover-engine/src/types/manifest-deps-types.ts @@ -527,11 +527,6 @@ 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 diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs index 5a20df76a7..2a62945439 100644 --- a/scripts/check-docs-drift.mjs +++ b/scripts/check-docs-drift.mjs @@ -7,8 +7,8 @@ // against .loopover.yml.example. Nothing else in CI catches a docs page/example silently falling behind when // a new flag/command/gate-mode/settings/manifest field is added to source but the place documenting that // surface is never updated -- a reviewer has to notice by eye, and often doesn't (#4617's own audit found -// `agentGlobalFreezeOverride` and `review.visual.production_url` this way: both fully live in code, neither -// mentioned anywhere a maintainer would think to look). +// `review.visual.production_url` this way: fully live in code, but not mentioned anywhere a maintainer would +// think to look). import { readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -90,20 +90,11 @@ export function extractRepositorySettingsFields(typesText) { } /** RepositorySettings fields deliberately excluded from the "every field must have SOME - * `.loopover.yml.example` mention" check below, for three distinct reasons -- flagging any as "undocumented" + * `.loopover.yml.example` mention" check below, for two distinct reasons -- flagging any as "undocumented" * would be a false drift signal, not a real gap: * - Not a maintainer-settable knob at all: `repoFullName` is the row's own identity key (set once at * creation, the opposite of something a maintainer overrides via config); `createdAt`/`updatedAt` are * DB-row bookkeeping timestamps. - * - `agentGlobalFreezeOverride`: genuinely settable, but DELIBERATELY never documented in the PUBLIC - * `.loopover.yml.example` -- it is settable only from the self-host operator's own PRIVATE config - * (`source: "api_record"` in `parseSettingsOverride`, packages/loopover-engine/src/focus-manifest.ts), - * never from a repo's own committed, maintainer-owned manifest (#4391's scope-leak fix). Documenting it in - * the public example would misleadingly suggest a repo maintainer can set it themselves -- see the same - * exclusion, with the same rationale, in `SETTINGS_OPERATOR_ONLY_FIELDS` in - * test/unit/focus-manifest.test.ts's `.loopover.yml.example field-exhaustiveness` suite. (An #4617 audit - * pass first flagged this field as an undocumented gap without that context; cross-checking the existing - * exhaustiveness suite before "fixing" it here caught the false positive.) * - `skipAutomationBotAuthors`: genuinely settable (global env default + per-repo `inherit`/`off`/`enabled` * override, mirroring `moderationGateMode`'s shape), but DELIBERATELY not wired into the * FocusManifest/`.loopover.yml` parsing path -- DB-only for now, confirmed as an intentional scope choice @@ -113,7 +104,6 @@ const NOT_YML_CONFIGURABLE_SETTINGS_FIELDS = new Set([ "repoFullName", "createdAt", "updatedAt", - "agentGlobalFreezeOverride", "skipAutomationBotAuthors", ]); diff --git a/src/api/routes.ts b/src/api/routes.ts index d6bd5f4ce0..2c9ef9cbf8 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -2386,7 +2386,7 @@ export function createApp() { if (gate instanceof Response) return gate; const body = await c.req.json().catch(() => null); if (body === null) return c.json({ error: "invalid_json" }, 400); - const manifest = await upsertRepoFocusManifest(c.env, fullName, stripMaintainerFocusManifestSettings(body), "api_record"); + const manifest = await upsertRepoFocusManifest(c.env, fullName, body, "api_record"); return c.json({ repoFullName: fullName, manifest, policy: compileFocusManifestPolicy(manifest) }); }); @@ -5431,20 +5431,6 @@ const LINT_PR_TEXT_PATH = "/v1/lint/pr-text"; const VALIDATE_FOCUS_MANIFEST_PATH = "/v1/validate/focus-manifest"; const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk"; const LINT_ISSUE_SLOP_PATH = "/v1/lint/issue-slop"; -function stripMaintainerFocusManifestSettings(raw: unknown): unknown { - // Split out from the rest of the guard below: this call site's only caller already 400s on a null body - // before ever reaching here, so this specific arm is unreachable in practice -- kept as defense-in-depth - // (typeof null === "object" in JS, so without it a null raw would fall through to the property access - // below and throw) for any future caller of this currently-unexported function. - /* v8 ignore next */ - if (raw === null) return raw; - if (typeof raw !== "object" || Array.isArray(raw)) return raw; - const record = raw as Record; - const settings = record.settings; - if (settings === null || typeof settings !== "object" || Array.isArray(settings) || !("agentGlobalFreezeOverride" in settings)) return raw; - const { agentGlobalFreezeOverride: _agentGlobalFreezeOverride, ...safeSettings } = settings; - return { ...record, settings: safeSettings }; -} // Contributor (miner) side of the extension (#556). Minted for NON-maintainer sign-ins; strictly // self-only — a token may only reach `/v1/extension/contributors//*`, enforced by the coarse // path check below plus `requireContributorAccess` (actor === login) in every handler. diff --git a/src/db/repositories.ts b/src/db/repositories.ts index f8bfaf90bc..01bec668da 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -563,7 +563,6 @@ 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: {}, @@ -644,7 +643,6 @@ 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), @@ -760,7 +758,6 @@ export async function upsertRepositorySettings(env: Env, settings: Partial { } } -/** 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 { - 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 diff --git a/src/db/schema.ts b/src/db/schema.ts index 71ca70cea4..b793b0041a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -123,10 +123,6 @@ 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"), diff --git a/src/github/client.ts b/src/github/client.ts index 295f534270..cdee07aa5b 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -1,5 +1,5 @@ import { Octokit } from "@octokit/core"; -import { isDbFrozenForRepo, recordAuditEvent } from "../db/repositories"; +import { isGlobalAgentFrozen, recordAuditEvent } from "../db/repositories"; import { isGlobalAgentPause, resolveAgentActionMode, type AgentActionMode } from "../settings/agent-execution"; import { incr } from "../selfhost/metrics"; import type { RepositorySettings } from "../types"; @@ -635,13 +635,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 (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. + * freeze (absolute — no per-repo bypass) 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. */ -export async function resolveRepoActionMode(env: Env, settings: Pick | null | undefined): Promise { +export async function resolveRepoActionMode(env: Env, settings: Pick | null | undefined): Promise { return resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings?.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings?.agentPaused, agentDryRun: settings?.agentDryRun, }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 162d91f479..ae6d3d2c64 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -44,7 +44,7 @@ import { getPendingAgentAction, getPullRequest, getRepository, - isDbFrozenForRepo, + isGlobalAgentFrozen, getRepoQueueTrendSnapshot, listAgentAuditEvents, listCheckSummaries, @@ -3570,7 +3570,7 @@ export class LoopoverMcp { 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 isDbFrozenForRepo(this.env, settings.agentGlobalFreezeOverride)), 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/openapi/schemas.ts b/src/openapi/schemas.ts index cbca794cfe..c192992f5d 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -811,7 +811,6 @@ 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(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 905e965628..d744ac4b96 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -67,7 +67,7 @@ import { hasAuditEventForHeadSha, recordGateBlockOutcome, getActiveReviewStartedAt, - isDbFrozenForRepo, + isGlobalAgentFrozen, markGateOutcomeOverridden, markPullRequestLinkedIssueHardRuleViolated, startActiveReviewTracking, @@ -1334,7 +1334,7 @@ export async function sweepRepoRegate( ) return; const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), // env brake OR DB kill-switch (#audit-§5.2) + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), // env brake OR DB kill-switch (#audit-§5.2) agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -1707,7 +1707,7 @@ export async function sweepRepoBacklogConvergence( const settings = await resolveRepositorySettings(env, repoFullName); if (!(isConvergenceRepoAllowed(env, repoFullName) || isAgentConfigured(settings.autonomy))) return; const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -2752,7 +2752,7 @@ async function runAgentMaintenancePlanAndExecute( } if (isNewAccount && resolveAutonomy(settings.autonomy, "review_state_label") === "auto") { const newAccountMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -3106,7 +3106,6 @@ async function runAgentMaintenancePlanAndExecute( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, - agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions, authorLogin: pr.authorLogin, mergeTrainMode: settings.mergeTrainMode, @@ -3453,7 +3452,6 @@ async function prReadyForReview( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, - agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions: installation?.permissions ?? null, authorLogin: pr.authorLogin, }, @@ -3762,7 +3760,6 @@ async function maybeForceFreshRebase( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, - agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, /* v8 ignore next -- an installed-App PR webhook always carries an installation record; the null is defensive (mirrors runAgentMaintenancePlanAndExecute's own identical merge-time read). */ installationPermissions: installation?.permissions ?? null, authorLogin: pr.authorLogin, @@ -5031,7 +5028,6 @@ async function maybeCloseIssueOverContributorCap( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, - agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, authorLogin, moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel }, }, @@ -5115,7 +5111,6 @@ async function maybeCloseIssueOverContributorCap( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, - agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, authorLogin, moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel }, }, @@ -6063,7 +6058,7 @@ async function handleIssueWebhookEvent( if (await isBelowAccountAgeThreshold(env, installationId, authorLogin, accountAgeThresholdDays)) { if (resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") { const newAccountMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, issueSettings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: issueSettings.agentPaused, agentDryRun: issueSettings.agentDryRun, }); @@ -7518,7 +7513,7 @@ async function maybeApplyManifestPolicyGate( // the cost of a maintainer choosing to ask twice). const alreadyTriggered = await hasAuditEventForHeadSha(env, "github_app.e2e_tests_generation", e2eTargetKey, args.pr.headSha); if (!alreadyTriggered) { - const e2eMode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, args.settings.agentGlobalFreezeOverride)), agentPaused: args.settings.agentPaused, agentDryRun: args.settings.agentDryRun }); + const e2eMode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: args.settings.agentPaused, agentDryRun: args.settings.agentDryRun }); if (e2eMode === "live") { await runE2eTestGenerationAndDeliver(env, { repoFullName: args.repoFullName, @@ -10549,7 +10544,7 @@ async function maybeProcessGateOverrideCommand( // an operator's pause or the DB kill-switch does not stop a maintainer's @loopover gate-override from // flipping the live Gate check-run to neutral and posting a real confirmation comment. const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -10715,7 +10710,7 @@ async function maybeProcessResolveCommand(env: Env, deliveryId: string, payload: const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings, null, undefined, pr.slopRisk ?? null)); const selection = selectWarningsForResolve(gate.warnings, findingRef); if (selection.reason === "finding_not_found") { await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: selection.reason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: selection.reason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: selection.reason } }); return true; } - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); if (mode !== "live") { const skipReason = mode === "dry_run" ? "dry_run" : "agent_paused"; await recordAuditEvent(env, { eventType: "github_app.finding_resolved_skipped", actor: req.actor, targetKey, outcome: "completed", detail: skipReason, metadata: { deliveryId, repoFullName: req.repoFullName, reason: skipReason } }); await recordGithubProductUsage(env, "finding_resolved_skipped", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "skipped", metadata: { reason: skipReason } }); return true; } const reviewManifest = await loadRepoFocusManifest(env, req.repoFullName).catch(() => null); const reviewMemoryEnabled = shouldApplyReviewMemory(env, resolveReviewMemoryManifestToggle(reviewManifest)); @@ -10767,7 +10762,7 @@ async function maybeProcessReviewCommand(env: Env, deliveryId: string, payload: } // Same dry-run/paused gate every other action command respects (pause/resolve/explain/gate-override/ // generate-tests) -- a paused or dry-run repo must not dispatch a live re-review or post a confirmation. - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); if (mode !== "live") { await recordReviewCommandSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, mode === "dry_run" ? "dry_run" : "agent_paused"); return true; @@ -11020,7 +11015,7 @@ async function maybeProcessGenerateTestsCommand(env: Env, deliveryId: string, pa } // Same dry-run/paused gate every other action command respects (mirrors maybeProcessResolveCommand's own // resolveAgentActionMode check) — an agent-paused or dry-run repo gets no generated content posted at all. - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); if (mode !== "live") { const skipReason = mode === "dry_run" ? "dry_run" : "agent_paused"; await recordGenerateTestsSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, skipReason); @@ -11231,7 +11226,7 @@ async function maybeProcessConfigurationCommand( return true; } const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -11354,7 +11349,7 @@ async function maybeProcessPlanCommand( // incurs the AI cost speculatively — mirroring how the reopen-reclose handler skips its write uniformly for // both dry_run and paused, not just paused. const planMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -11740,7 +11735,7 @@ async function maybeProcessPrPanelGenerateTests( await recordGenerateTestsSkip(env, deliveryId, repoFullName, `${repoFullName}#${pr.number}`, actor, "feature_disabled"); return true; } - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); if (mode !== "live") { const skipReason = mode === "dry_run" ? "dry_run" : "agent_paused"; await recordGenerateTestsSkip(env, deliveryId, repoFullName, `${repoFullName}#${pr.number}`, actor, skipReason); @@ -12074,7 +12069,7 @@ async function maybeThrottleReviewNagPing( if (pingCount <= maxPings) return false; // under threshold — normal command processing proceeds unchanged const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -12156,7 +12151,6 @@ async function maybeThrottleReviewNagPing( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, - agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions: installation?.permissions ?? null, authorLogin: pr.authorLogin, moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel }, @@ -12267,7 +12261,7 @@ async function maybeThrottleMonitoredMentions( if (pingCount <= maxPings) return false; const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); @@ -12342,7 +12336,6 @@ async function maybeThrottleMonitoredMentions( autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, - agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions: installation?.permissions ?? null, authorLogin: pr.authorLogin, moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel }, @@ -12718,7 +12711,7 @@ async function maybeProcessLoopOverMentionCommand( // Respect pause/dry-run/global-freeze like every other agent-driven write in this file (#2258) — the answer // card is a live public comment post, same as gate-override's confirmation comment. const mentionMode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); diff --git a/src/queue/review-evasion.ts b/src/queue/review-evasion.ts index 3d4c3fe300..139c85e05a 100644 --- a/src/queue/review-evasion.ts +++ b/src/queue/review-evasion.ts @@ -10,7 +10,7 @@ import { getGateBlockOutcome, getInstallation, hasActiveReviewForHeadSha, - isDbFrozenForRepo, + isGlobalAgentFrozen, recordAuditEvent, terminalizeActiveReviewTracking, } from "../db/repositories"; @@ -109,7 +109,7 @@ async function evaluateCloseEnforcementGate(args: { }): Promise { const { env, installationId, repoFullName, pr, settings, eventType, targetKey } = args; const mode = resolveAgentActionMode({ - globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, settings.agentGlobalFreezeOverride)), + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 7889078a3c..a618f93224 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -5,7 +5,7 @@ import { getGlobalContributorBlacklist, getGlobalModerationConfig, insertNotificationDeliveryIfAbsent, - isDbFrozenForRepo, + isGlobalAgentFrozen, listOtherOpenPullRequests, listRepoPullRequestFilePaths, markPullRequestApproved, @@ -157,9 +157,6 @@ export type AgentActionExecutionContext = { autonomy: AutonomyPolicy | null | undefined; agentPaused?: boolean | undefined; agentDryRun?: boolean | undefined; - // Per-repo override of the DB-backed global freeze (#4372) -- resolved by the CALLER from - // RepositorySettings, same "the executor has no settings access" shape as agentPaused/agentDryRun above. - agentGlobalFreezeOverride?: boolean | undefined; installationPermissions: Record | null | undefined; // PR author login — surfaced as the "Submitter" in the per-repo Discord action notification. authorLogin?: string | null | undefined; @@ -256,7 +253,7 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE const targetKey = `${ctx.repoFullName}#${ctx.pullNumber}`; // 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 isDbFrozenForRepo(env, ctx.agentGlobalFreezeOverride)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); for (const action of planned) { // #label-scoping: a `label` action may be authorized by a class OTHER than `label` itself (an anti-abuse @@ -734,9 +731,6 @@ export type IssueActionExecutionContext = { autonomy: AutonomyPolicy | null | undefined; agentPaused?: boolean | undefined; agentDryRun?: boolean | undefined; - // Per-repo override of the DB-backed global freeze (#4372) -- resolved by the CALLER from - // RepositorySettings, same "the executor has no settings access" shape as agentPaused/agentDryRun above. - agentGlobalFreezeOverride?: boolean | undefined; // Issue author login -- needed for the moderation-rules engine's violation ledger (#selfhost-mod-engine). authorLogin?: string | null | undefined; moderationSettings?: ModerationContextSettings | undefined; @@ -759,7 +753,7 @@ export type IssueActionExecutionContext = { export async function executeIssueMaintenanceActions(env: Env, ctx: IssueActionExecutionContext, planned: PlannedAgentAction[]): Promise { const outcomes: AgentActionOutcome[] = []; const targetKey = `${ctx.repoFullName}#${ctx.issueNumber}`; - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, ctx.agentGlobalFreezeOverride)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); for (const action of planned) { // #label-scoping: a `label` action may be authorized by a class OTHER than `label` itself (an anti-abuse diff --git a/src/services/agent-approval-queue.ts b/src/services/agent-approval-queue.ts index fc65116d7e..a061e386f7 100644 --- a/src/services/agent-approval-queue.ts +++ b/src/services/agent-approval-queue.ts @@ -406,7 +406,6 @@ export async function decidePendingAgentAction(env: Env, input: { id: string; de autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, - agentGlobalFreezeOverride: settings.agentGlobalFreezeOverride, installationPermissions: installation ? installation.permissions : null, mergeTrainMode: settings.mergeTrainMode, pullRequestCreatedAt: pr?.createdAt, diff --git a/src/services/contributor-issue-draft.ts b/src/services/contributor-issue-draft.ts index bf6a6e4c49..e298a728f4 100644 --- a/src/services/contributor-issue-draft.ts +++ b/src/services/contributor-issue-draft.ts @@ -10,7 +10,7 @@ import { countOpenIssues, countOpenPullRequests, getLatestRepoGithubTotalsSnapshot, - isDbFrozenForRepo, + isGlobalAgentFrozen, listUpstreamDriftReports, recordAuditEvent, } from "../db/repositories"; @@ -261,9 +261,9 @@ export async function generateContributorIssueDrafts( // The caller's dryRun flag, OVERLAID with the global agent kill-switch: a paused/frozen agent must not file // contributor issues even when a caller passes {dryRun:false}. These POSTs use a raw token outside the // installation-Octokit dry-run chokepoint (#dry-run-chokepoint), so the brake is applied here. (#audit-rawfetch-pause) - // isDbFrozenForRepo (#4372) lets THIS repo's agentGlobalFreezeOverride bypass the DB freeze while other - // repos stay frozen -- the env-var hard stop (isGlobalAgentPause) is never overridable. - const dryRun = options.dryRun !== false || isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, context.settings.agentGlobalFreezeOverride)); + // isGlobalAgentFrozen is an absolute fleet-wide brake with no per-repo bypass, same tier as the env-var + // hard stop (isGlobalAgentPause); day-to-day per-repo enable/disable is settings.agentPaused instead. + const dryRun = options.dryRun !== false || isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)); const createRequested = options.create === true; const limit = Math.min(MAX_LIMIT, Math.max(1, options.limit ?? DEFAULT_LIMIT)); const candidates = buildContributorIssueDraftCandidates(context).slice(0, limit); diff --git a/src/types.ts b/src/types.ts index 41d8577931..9939ea6430 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1132,11 +1132,6 @@ export type RepositorySettings = { * `agentPaused`, never this field). Default false. Independent of the gate check's own {@link gateDryRun} * preview -- the two "dry run" fields gate entirely disjoint layers with no shared code path. */ 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): gates ONLY the single shared, cross-repo violation * tally across the anti-abuse mechanisms that already short-circuit a PR/issue's disposition on their * own independent settings (contributor cap, blacklist, review-nag, review-evasion) -- it does NOT diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 01689c96c1..77f700ff31 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1029,20 +1029,6 @@ describe("api routes", () => { const settingsMalformed = await app.request("/v1/repos/entrius/allways-ui/settings", { method: "PUT", headers: apiHeaders(env), body: "{" }, env); expect(settingsMalformed.status).toBe(400); - // REGRESSION (#4372 security finding): agentGlobalFreezeOverride is an operator-only emergency lever - // (set via the private .loopover.yml, never the maintainer-facing settings API) — a maintainer PUT - // must silently strip it, not persist it, even when explicitly sent alongside otherwise-valid fields. - const freezeOverrideAttempt = await app.request( - "/v1/repos/entrius/allways-ui/settings", - { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ firstTimeContributorGrace: false, agentGlobalFreezeOverride: true }) }, - env, - ); - expect(freezeOverrideAttempt.status).toBe(200); - const freezeOverrideBody = (await freezeOverrideAttempt.json()) as Record; - expect(freezeOverrideBody.agentGlobalFreezeOverride).not.toBe(true); - const freezeOverrideRefetch = await app.request("/v1/repos/entrius/allways-ui/settings", { headers: apiHeaders(env) }, env); - await expect(freezeOverrideRefetch.json()).resolves.toMatchObject({ agentGlobalFreezeOverride: false }); - const registrationReadiness = await app.request("/v1/repos/entrius/allways-ui/registration-readiness", { headers: apiHeaders(env) }, env); expect(registrationReadiness.status).toBe(200); await expect(registrationReadiness.json()).resolves.toMatchObject({ diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts index 7253ff79d3..b27f6c315b 100644 --- a/test/unit/agent-action-executor.test.ts +++ b/test/unit/agent-action-executor.test.ts @@ -72,7 +72,7 @@ import { import type { PlannedAgentAction } from "../../src/settings/agent-actions"; import { STRUCTURED_CLOSE_REASONS_MAX_COUNT } from "../../src/settings/agent-execution"; import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules"; -import { clearProcessLocalGlobalAgentFrozenCacheForTest, getGlobalContributorBlacklist, isDbFrozenForRepo, isGlobalAgentFrozen, setGlobalAgentFrozen, upsertGlobalModerationConfig, upsertPullRequestFile, upsertPullRequestFromGitHub } from "../../src/db/repositories"; +import { clearProcessLocalGlobalAgentFrozenCacheForTest, getGlobalContributorBlacklist, isGlobalAgentFrozen, setGlobalAgentFrozen, upsertGlobalModerationConfig, upsertPullRequestFile, upsertPullRequestFromGitHub } from "../../src/db/repositories"; import * as repositoriesModule from "../../src/db/repositories"; import * as sentryModule from "../../src/selfhost/sentry"; import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics"; @@ -937,36 +937,23 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { expect(mergePullRequest).toHaveBeenCalled(); }); - it("REGRESSION (#4372): a repo's agentGlobalFreezeOverride lets IT execute while the global DB freeze stays on", async () => { + it("REGRESSION: the DB global freeze is now absolute -- no per-repo setting can bypass it, even one repo at a time", async () => { const env = createTestEnv({}); // env-var brake OFF await setGlobalAgentFrozen(env, true, "operator"); - const overridden = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false, agentGlobalFreezeOverride: true }), [merge]); - expect(overridden[0]?.outcome).toBe("completed"); - expect(mergePullRequest).toHaveBeenCalled(); - }); - - it("REGRESSION (#4372): a sibling repo WITHOUT the override stays denied while the global DB freeze is on, even though another repo opted out of it", async () => { - const env = createTestEnv({}); // env-var brake OFF - await setGlobalAgentFrozen(env, true, "operator"); - // The incident this override exists to prevent recurring: an operator meant only ONE repo to resume, so a - // sibling repo with no override (or an explicit false) must stay fully halted by the same global freeze. - const stillFrozen = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false, agentGlobalFreezeOverride: false }), [merge]); + // The per-repo agentGlobalFreezeOverride escape hatch (#4372) has been removed: it required either a raw + // DB write or an operator-only config field with no config-as-code parity with every other repo setting. + // Day-to-day per-repo enable/disable is settings.agentPaused instead (already global-default + + // per-repo-override via .loopover.yml); the fleet freeze itself now sits at the same absolute tier as the + // AGENT_ACTIONS_PAUSED env var -- every repo stays denied while it's on, with no exceptions. + const stillFrozen = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false }), [merge]); expect(stillFrozen[0]?.outcome).toBe("denied"); expect(mergePullRequest).not.toHaveBeenCalled(); }); - it("REGRESSION (#4372): the AGENT_ACTIONS_PAUSED env var stays absolute -- no per-repo override can bypass it", async () => { + it("REGRESSION: the AGENT_ACTIONS_PAUSED env var stays absolute even with the DB freeze off", async () => { const env = createTestEnv({ AGENT_ACTIONS_PAUSED: "true" }); await setGlobalAgentFrozen(env, false); // DB freeze OFF -- only the env var is on - const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false, agentGlobalFreezeOverride: true }), [merge]); - expect(outcomes[0]?.outcome).toBe("denied"); - expect(mergePullRequest).not.toHaveBeenCalled(); - }); - - it("REGRESSION (#4372): a repo's OWN agentPaused still wins over its agentGlobalFreezeOverride", async () => { - const env = createTestEnv({}); - await setGlobalAgentFrozen(env, false); - const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: true, agentGlobalFreezeOverride: true }), [merge]); + const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false }), [merge]); expect(outcomes[0]?.outcome).toBe("denied"); expect(mergePullRequest).not.toHaveBeenCalled(); }); @@ -1026,31 +1013,6 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { expect(await isGlobalAgentFrozen(broken)).toBe(false); }); - describe("isDbFrozenForRepo (#4372, incident follow-up)", () => { - it("agentGlobalFreezeOverride=true bypasses the DB freeze entirely, without even reading it", async () => { - const env = createTestEnv({}); - await setGlobalAgentFrozen(env, true, "operator"); - // Poison the DB so a read would throw/fail-closed if it were attempted -- the override must short-circuit. - const poisoned = { ...env, DB: null } as unknown as Env; - expect(await isDbFrozenForRepo(poisoned, true)).toBe(false); - }); - - it("agentGlobalFreezeOverride=false still reflects the DB freeze state", async () => { - const env = createTestEnv({}); - await setGlobalAgentFrozen(env, true, "operator"); - expect(await isDbFrozenForRepo(env, false)).toBe(true); - await setGlobalAgentFrozen(env, false, "operator"); - expect(await isDbFrozenForRepo(env, false)).toBe(false); - }); - - it("agentGlobalFreezeOverride=null/undefined (unset) still reflects the DB freeze state, same as false", async () => { - const env = createTestEnv({}); - await setGlobalAgentFrozen(env, true, "operator"); - expect(await isDbFrozenForRepo(env, null)).toBe(true); - expect(await isDbFrozenForRepo(env, undefined)).toBe(true); - }); - }); - 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/check-docs-drift-script.test.ts b/test/unit/check-docs-drift-script.test.ts index 54fe851c46..7200c34e70 100644 --- a/test/unit/check-docs-drift-script.test.ts +++ b/test/unit/check-docs-drift-script.test.ts @@ -91,20 +91,20 @@ describe("check-docs-drift script", () => { const fixture = ` export type RepositorySettings = { repoFullName: string; - agentGlobalFreezeOverride?: boolean | undefined; + agentPaused?: boolean | undefined; linkedIssueGateMode: GateRuleMode; }; `; // The OLD, narrow check: invisible to a plain boolean field with no "GateMode" in its name -- this is - // exactly the shape of gap #4617 was filed over (agentGlobalFreezeOverride was live in source code but + // exactly the shape of gap #4617 was filed over (a plain settable boolean was live in source code but // had zero automated documentation guarantee, because it isn't a *GateMode field). expect(extractGateModeFields(fixture)).toEqual(["linkedIssueGateMode"]); - expect(extractGateModeFields(fixture)).not.toContain("agentGlobalFreezeOverride"); + expect(extractGateModeFields(fixture)).not.toContain("agentPaused"); // The WIDENED check: sees every field on the type, regardless of shape. const fields = extractRepositorySettingsFields(fixture); - expect(fields).toEqual(["repoFullName", "agentGlobalFreezeOverride", "linkedIssueGateMode"]); + expect(fields).toEqual(["repoFullName", "agentPaused", "linkedIssueGateMode"]); }); it("is anchored on the RepositorySettings type's own brace boundary, not a bare name match elsewhere in the file", () => { @@ -485,15 +485,15 @@ describe("check-docs-drift script", () => { } }); - it("treats NOT_YML_CONFIGURABLE_SETTINGS_FIELDS members as excluded even with zero yml mention (repoFullName, createdAt, updatedAt, agentGlobalFreezeOverride)", () => { + it("treats NOT_YML_CONFIGURABLE_SETTINGS_FIELDS members as excluded even with zero yml mention (repoFullName, createdAt, updatedAt, skipAutomationBotAuthors)", () => { const files = baseFixtures(); files["src/types.ts"] = files["src/types.ts"]!.replace( "};", - " repoFullName: string;\n createdAt?: string | null | undefined;\n updatedAt?: string | null | undefined;\n agentGlobalFreezeOverride?: boolean | undefined;\n};", + " repoFullName: string;\n createdAt?: string | null | undefined;\n updatedAt?: string | null | undefined;\n skipAutomationBotAuthors?: \"inherit\" | \"off\" | \"enabled\" | undefined;\n};", ); const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) }); - for (const field of ["repoFullName", "createdAt", "updatedAt", "agentGlobalFreezeOverride"]) { + for (const field of ["repoFullName", "createdAt", "updatedAt", "skipAutomationBotAuthors"]) { expect(result.failures.find((failure) => failure.includes(field))).toBeUndefined(); } }); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 13df62f1eb..b2bb87148c 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -305,12 +305,6 @@ describe("data spine repositories", () => { await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentPaused: false }); expect((await getRepositorySettings(env, "owner/saferepo")).agentPaused).toBe(false); // update persists expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ agentPaused: false, agentDryRun: false }); // defaults - // #4372 per-repo global-freeze-override round-trip (insert + update) and default false. - await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentGlobalFreezeOverride: true }); - expect((await getRepositorySettings(env, "owner/saferepo")).agentGlobalFreezeOverride).toBe(true); - await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentGlobalFreezeOverride: false }); - expect((await getRepositorySettings(env, "owner/saferepo")).agentGlobalFreezeOverride).toBe(false); // update persists - expect((await getRepositorySettings(env, "owner/defaultpack")).agentGlobalFreezeOverride).toBe(false); // default // #2270 per-contributor open PR/issue caps: no row and no cap set both default to null (disabled). expect(await getRepositorySettings(env, "missing/repo")).toMatchObject({ contributorOpenPrCap: null, contributorOpenIssueCap: null }); expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ contributorOpenPrCap: null, contributorOpenIssueCap: null }); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index b3415a473a..aa11bd7b4c 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -295,14 +295,6 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { // silently vanishing from the exhaustiveness check. const SETTINGS_GATE_ALIASED_FIELDS = ["linkedIssueGateMode", "duplicatePrGateMode", "selfAuthoredLinkedIssueGateMode", "qualityGateMode", "qualityGateMinScore", "aiReviewMode", "aiReviewByok", "aiReviewProvider", "aiReviewModel", "aiReviewAllAuthors"] as const; - // Settings fields that are DELIBERATELY absent from `.loopover.yml.example` (unlike the gate-aliased fields - // above, these are never documented anywhere in the public template): agentGlobalFreezeOverride is an - // operator-only emergency lever, settable only from the operator's own private self-host config (source: - // "api_record" in parseSettingsOverride, focus-manifest.ts) -- never from a repo's own committed, maintainer- - // owned manifest (#4391's scope-leak fix). Documenting it in the PUBLIC example would misleadingly suggest a - // repo maintainer can set it themselves. - const SETTINGS_OPERATOR_ONLY_FIELDS = ["agentGlobalFreezeOverride"] as const; - const SETTINGS_FIELD_TOKENS = { commentMode: "commentMode:", publicAudienceMode: "publicAudienceMode:", @@ -369,7 +361,7 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { unlinkedIssueGuardrail: "unlinkedIssueGuardrail:", screenshotTableGate: "screenshotTableGate:", advisoryAiRouting: "advisoryAiRouting:", - } satisfies Record, string>; + } satisfies Record, string>; it.each(Object.entries(SETTINGS_FIELD_TOKENS))("documents settings.%s", (_field, token) => { expect(exampleContent).toContain(token); @@ -2030,7 +2022,6 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = includeMaintainerAuthors: true, requireLinkedIssue: true, backfillEnabled: false, - agentGlobalFreezeOverride: true, }, }); expect(m.present).toBe(true); @@ -2052,32 +2043,6 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = includeMaintainerAuthors: true, requireLinkedIssue: true, backfillEnabled: false, - agentGlobalFreezeOverride: true, - }); - // parseFocusManifest with no explicit `source` (and no `record.source` field, as here) defaults to - // "api_record" (normalizeSource, focus-manifest.ts) -- the operator-private-config trust level -- so - // agentGlobalFreezeOverride parses through and can overlay the DB value. See the dedicated - // "agentGlobalFreezeOverride: operator-only" describe block below for the source-gating itself (an - // explicit source: "repo_file" manifest, mirroring a real repo-owned `.loopover.yml`, drops it instead). - expect(resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as unknown as RepositorySettings, m).agentGlobalFreezeOverride).toBe(true); - }); - - describe("agentGlobalFreezeOverride: operator-only, never settable from a repo-owned manifest (#4391)", () => { - it("source: api_record (the operator's own private self-host config) — parses it and lets it overlay the DB value", () => { - const m = parseFocusManifest({ source: "api_record", settings: { agentGlobalFreezeOverride: true } }); - expect(m.settings.agentGlobalFreezeOverride).toBe(true); - expect(m.warnings).toEqual([]); - expect(resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as unknown as RepositorySettings, m).agentGlobalFreezeOverride).toBe(true); - }); - - it("source: repo_file (a real repo-owned .loopover.yml) — drops it with an operator-only warning; the DB value survives", () => { - const m = parseFocusManifest({ source: "repo_file", settings: { agentGlobalFreezeOverride: true } }); - expect(m.settings.agentGlobalFreezeOverride).toBeUndefined(); - expect(m.warnings).toContain("Ignored settings.agentGlobalFreezeOverride: operator-only, not settable from a repo-owned manifest."); - // A repo maintainer's own committed manifest must never be able to grant an exemption from the operator's - // fleet-wide freeze (the #4391 scope-leak this field's source-gating exists to prevent) -- the DB's `false` - // (fleet-wide frozen, no repo-level override) survives untouched. - expect(resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as unknown as RepositorySettings, m).agentGlobalFreezeOverride).toBe(false); }); }); diff --git a/test/unit/github-client.test.ts b/test/unit/github-client.test.ts index 3f79dd8b7b..4093477a7c 100644 --- a/test/unit/github-client.test.ts +++ b/test/unit/github-client.test.ts @@ -152,15 +152,11 @@ describe("resolveRepoActionMode", () => { await setGlobalAgentFrozen(env, true); expect(await resolveRepoActionMode(env, { agentPaused: false, agentDryRun: false })).toBe("paused"); // DB freeze wins - }); - it("REGRESSION (#4372): agentGlobalFreezeOverride lets a repo bypass the DB freeze but never the env brake, and its own agentPaused still wins", async () => { - const env = createTestEnv(); - await setGlobalAgentFrozen(env, true); - expect(await resolveRepoActionMode(env, { agentPaused: false, agentDryRun: false, agentGlobalFreezeOverride: true })).toBe("live"); - expect(await resolveRepoActionMode(env, { agentPaused: false, agentDryRun: false, agentGlobalFreezeOverride: false })).toBe("paused"); - expect(await resolveRepoActionMode({ ...env, AGENT_ACTIONS_PAUSED: "true" }, { agentPaused: false, agentDryRun: false, agentGlobalFreezeOverride: true })).toBe("paused"); // env brake still wins - expect(await resolveRepoActionMode(env, { agentPaused: true, agentDryRun: false, agentGlobalFreezeOverride: true })).toBe("paused"); // own pause still wins + // REGRESSION: the DB freeze is absolute -- no per-repo setting can bypass it anymore (the former + // agentGlobalFreezeOverride escape hatch was removed; day-to-day enable/disable is agentPaused instead). + await setGlobalAgentFrozen(env, false); + expect(await resolveRepoActionMode(env, { agentPaused: false, agentDryRun: false })).toBe("live"); }); }); diff --git a/test/unit/routes-focus-manifest.test.ts b/test/unit/routes-focus-manifest.test.ts index 3928d8e58b..50a38017c0 100644 --- a/test/unit/routes-focus-manifest.test.ts +++ b/test/unit/routes-focus-manifest.test.ts @@ -1,11 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createApp } from "../../src/api/routes"; import { createSessionForGitHubUser } from "../../src/auth/security"; -import { isDbFrozenForRepo, setGlobalAgentFrozen, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { getRepositoryCollaboratorPermission } from "../../src/github/app"; -import { resolveEffectiveSettings } from "../../src/signals/focus-manifest"; -import type { FocusManifest } from "../../src/signals/focus-manifest"; -import type { RepositorySettings } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; vi.mock("../../src/github/app", async (importOriginal) => ({ @@ -96,67 +93,6 @@ describe("focus-manifest route auth", () => { }); }); - it("strips operator-only freeze overrides from maintainer-writable focus-manifest updates", async () => { - const app = createApp(); - const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); - await seedRegisteredInstalledRepo(env, 201, "repo-owner", "owned-repo"); - await setGlobalAgentFrozen(env, true, "operator"); - mockedPermission.mockResolvedValue("write"); - const { token } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 201 }); - - const response = await app.request( - OWNED_REPO_PATH, - { - method: "PUT", - headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" }, - body: JSON.stringify({ wantedPaths: ["src/"], settings: { agentDryRun: true, agentGlobalFreezeOverride: true } }), - }, - env, - ); - - expect(response.status).toBe(200); - const body = await response.json() as { - manifest: { settings: { agentDryRun?: boolean; agentGlobalFreezeOverride?: boolean } }; - }; - expect(body.manifest.settings.agentDryRun).toBe(true); - expect(body.manifest.settings.agentGlobalFreezeOverride).toBeUndefined(); - const effective = resolveEffectiveSettings({ agentGlobalFreezeOverride: false } as RepositorySettings, body.manifest as FocusManifest); - expect(effective.agentGlobalFreezeOverride).toBe(false); - expect(await isDbFrozenForRepo(env, effective.agentGlobalFreezeOverride)).toBe(true); - }); - - // stripMaintainerFocusManifestSettings's guard is a compound OR chain (raw not-an-object / raw array / - // settings null / settings not-an-object / settings array / settings missing the override key) -- each of - // these leaves the body untouched (no strip), same as the pre-fix behavior, but for a different structural - // reason each time. Covering every arm here, not just the "settings HAS the key" happy path above. - it.each([ - ["a top-level non-object body", "just a string"], - ["a top-level array body", ["src/"]], - ["settings: null", { wantedPaths: ["src/"], settings: null }], - ["settings as a non-object", { wantedPaths: ["src/"], settings: "not-an-object" }], - ["settings as an array", { wantedPaths: ["src/"], settings: ["not", "a", "record"] }], - ["settings with no freeze-override key", { wantedPaths: ["src/"], settings: { agentDryRun: true } }], - ])("does not crash and passes the body through unstripped for %s", async (_label, body) => { - const app = createApp(); - const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); - await seedRegisteredInstalledRepo(env, 201, "repo-owner", "owned-repo"); - mockedPermission.mockResolvedValue("write"); - const { token } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 201 }); - - const response = await app.request( - OWNED_REPO_PATH, - { - method: "PUT", - headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" }, - body: JSON.stringify(body), - }, - env, - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ repoFullName: "repo-owner/owned-repo" }); - }); - it("rejects focus-manifest writes from sessions without live GitHub write permission", async () => { const app = createApp(); const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });