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
6 changes: 6 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8110,6 +8110,12 @@
"requireApprovals",
"mergeMethod"
]
},
"agentPaused": {
"type": "boolean"
},
"agentDryRun": {
"type": "boolean"
}
},
"required": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type MaintainerSettings = {
commandAuthorization: CommandAuthorization;
autonomy: Partial<Record<AgentActionClass, AutonomyLevel>>;
autoMaintain: { requireApprovals: number; mergeMethod: AutoMergeMethod };
agentPaused: boolean;
agentDryRun: boolean;
};

type AutonomyLevel = "observe" | "suggest" | "propose" | "auto_with_approval" | "auto";
Expand Down Expand Up @@ -108,6 +110,8 @@ const EDITABLE_KEYS: Array<keyof MaintainerSettings> = [
"commandAuthorization",
"autonomy",
"autoMaintain",
"agentPaused",
"agentDryRun",
];

type SelectFieldDef = {
Expand Down Expand Up @@ -307,6 +311,8 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p
? {
...result.data,
autonomy: result.data.autonomy ?? {},
agentPaused: result.data.agentPaused ?? false,
agentDryRun: result.data.agentDryRun ?? false,
autoMaintain: result.data.autoMaintain ?? {
requireApprovals: 1,
mergeMethod: "squash",
Expand Down Expand Up @@ -547,6 +553,20 @@ export function MaintainerSettings({ reviewability }: { reviewability: Array<{ p
</select>
</label>
</div>
<div className="mt-3 flex flex-wrap gap-6">
<ToggleControl
label="Pause all agent actions (kill-switch)"
hint="Take no action on this repo until re-enabled"
value={settings.agentPaused}
onChange={(v) => setField("agentPaused", v)}
/>
<ToggleControl
label="Dry-run / shadow mode"
hint="Record what the agent would do, without performing it"
value={settings.agentDryRun}
onChange={(v) => setField("agentDryRun", v)}
/>
</div>
</div>

<div className="flex flex-wrap items-center gap-3">
Expand Down
6 changes: 6 additions & 0 deletions migrations/0044_agent_kill_switch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Agent-layer safety controls (#776, Wave 2 Phase 0). Per-repo kill-switch + dry-run/shadow mode the action
-- layer (#778) consults via resolveAgentActionMode (alongside the GLOBAL env switch AGENT_ACTIONS_PAUSED).
-- `agent_paused` = take NO action on this repo; `agent_dry_run` = log what would happen without mutating.
-- Both default 0 (off) — additive, existing repos are unaffected.
ALTER TABLE repository_settings ADD COLUMN agent_paused INTEGER NOT NULL DEFAULT 0;
ALTER TABLE repository_settings ADD COLUMN agent_dry_run INTEGER NOT NULL DEFAULT 0;
2 changes: 2 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,8 @@ const maintainerSettingsSchema = z
includeMaintainerAuthors: z.boolean(),
requireLinkedIssue: z.boolean(),
badgeEnabled: z.boolean(),
agentPaused: z.boolean(),
agentDryRun: z.boolean(),
commandAuthorization: z.object({
default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(),
commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(),
Expand Down
10 changes: 10 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
backfillEnabled: true,
privateTrustEnabled: true,
badgeEnabled: false,
agentPaused: false,
agentDryRun: false,
commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy,
autonomy: {},
autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY },
Expand Down Expand Up @@ -461,6 +463,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
backfillEnabled: row.backfillEnabled,
privateTrustEnabled: row.privateTrustEnabled,
badgeEnabled: row.badgeEnabled,
agentPaused: row.agentPaused,
agentDryRun: row.agentDryRun,
commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson),
autonomy: parseAutonomyPolicy(row.autonomyJson),
autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson),
Expand Down Expand Up @@ -502,6 +506,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
backfillEnabled: settings.backfillEnabled ?? true,
privateTrustEnabled: settings.privateTrustEnabled ?? true,
badgeEnabled: settings.badgeEnabled ?? false,
agentPaused: settings.agentPaused ?? false,
agentDryRun: settings.agentDryRun ?? false,
commandAuthorization: normalizeCommandAuthorizationPolicy(settings.commandAuthorization).policy,
autonomy: normalizeAutonomyPolicy(settings.autonomy),
autoMaintain: normalizeAutoMaintainPolicy(settings.autoMaintain),
Expand Down Expand Up @@ -541,6 +547,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
backfillEnabled: resolved.backfillEnabled,
privateTrustEnabled: resolved.privateTrustEnabled,
badgeEnabled: resolved.badgeEnabled,
agentPaused: resolved.agentPaused,
agentDryRun: resolved.agentDryRun,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
autonomyJson: jsonString(resolved.autonomy),
autoMaintainJson: jsonString(resolved.autoMaintain),
Expand Down Expand Up @@ -581,6 +589,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
backfillEnabled: resolved.backfillEnabled,
privateTrustEnabled: resolved.privateTrustEnabled,
badgeEnabled: resolved.badgeEnabled,
agentPaused: resolved.agentPaused,
agentDryRun: resolved.agentDryRun,
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
autonomyJson: jsonString(resolved.autonomy),
autoMaintainJson: jsonString(resolved.autoMaintain),
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export const repositorySettings = sqliteTable("repository_settings", {
commandAuthorizationJson: text("command_authorization_json").notNull().default("{}"),
autonomyJson: text("autonomy_json").notNull().default("{}"),
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),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
2 changes: 2 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ declare global {
GITHUB_PUBLIC_TOKEN?: string;
/** #703: owner-gated global to apply upstream sigmoid time-decay in score previews. Default off. */
SCORING_TIME_DECAY_ENABLED?: string;
/** #776 agent-layer GLOBAL kill-switch — when truthy, halts ALL agent actions across every repo. */
AGENT_ACTIONS_PAUSED?: string;
GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string;
GITTENSORY_DRIFT_ISSUE_REPO?: string;
GITTENSORY_DRIFT_ISSUE_TOKEN?: string;
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,8 @@ export const RepositorySettingsSchema = z
.record(z.enum(["review", "request_changes", "approve", "merge", "close", "label"]), z.enum(["observe", "suggest", "propose", "auto_with_approval", "auto"]))
.optional(),
autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(),
agentPaused: z.boolean().optional(),
agentDryRun: z.boolean().optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
59 changes: 59 additions & 0 deletions src/settings/agent-execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { AgentActionClass, AuditEventRecord, AutonomyLevel } from "../types";

// Whether the agent actually executes an action, only logs what it WOULD do, or is halted entirely (#776).
export type AgentActionMode = "paused" | "dry_run" | "live";

/**
* The GLOBAL kill-switch — an operator emergency brake (env `AGENT_ACTIONS_PAUSED`) that halts ALL agent
* actions across every repo, regardless of per-repo config. Same truthy-string idiom as the other env flags.
*/
export function isGlobalAgentPause(env: { AGENT_ACTIONS_PAUSED?: string | undefined }): boolean {
return /^(1|true|yes|on)$/i.test(env.AGENT_ACTIONS_PAUSED ?? "");
}

/**
* THE single gate the action layer (#778) consults before executing any action, alongside resolveAutonomy.
* Precedence (safest wins): a global OR per-repo pause halts everything (`paused`); else a per-repo dry-run
* logs what would happen without executing (`dry_run`); else `live`. Deny-toward-safety. Pure.
*/
export function resolveAgentActionMode(input: { globalPaused: boolean; agentPaused?: boolean | null | undefined; agentDryRun?: boolean | null | undefined }): AgentActionMode {
if (input.globalPaused || input.agentPaused === true) return "paused";
if (input.agentDryRun === true) return "dry_run";
return "live";
}

/** True only for `live` — the only mode that performs a real GitHub mutation. `paused` does nothing;
* `dry_run` records a shadow action but never mutates. */
export function agentActionModeExecutes(mode: AgentActionMode): boolean {
return mode === "live";
}

/**
* Build the structured audit record for an agent action (who / what / why / outcome / mode). The action
* layer passes this to the existing recordAuditEvent so live actions AND dry-run shadows are both recorded
* on one consistent event shape (#776 "extend the existing audit-event infra"). Pure.
*/
export function buildAgentActionAudit(input: {
actionClass: AgentActionClass;
autonomyLevel: AutonomyLevel;
mode: AgentActionMode;
outcome: AuditEventRecord["outcome"];
repoFullName: string;
targetKey?: string | null | undefined;
actor?: string | null | undefined;
reason?: string | null | undefined;
}): AuditEventRecord {
return {
eventType: `agent.action.${input.actionClass}`,
actor: input.actor ?? null,
targetKey: input.targetKey ?? input.repoFullName,
outcome: input.outcome,
detail: input.reason ?? null,
metadata: {
repoFullName: input.repoFullName,
actionClass: input.actionClass,
autonomyLevel: input.autonomyLevel,
mode: input.mode,
},
};
}
4 changes: 3 additions & 1 deletion src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export type FocusManifestSettings = Partial<
| "privateTrustEnabled"
| "autonomy"
| "autoMaintain"
| "agentPaused"
| "agentDryRun"
>
>;

Expand Down Expand Up @@ -424,7 +426,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
if (gittensorLabel !== null) out.gittensorLabel = gittensorLabel;
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", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled"] as const) {
for (const key of ["aiReviewByok", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) {
const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings);
if (flag !== null) out[key] = flag;
}
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,12 @@ export type RepositorySettings = {
/** Auto-maintain policy (#774): merge method + approval count. Always populated by the DB layer with
* defaults (squash / 1 approval); optional so existing settings fixtures/callers need not be touched. */
autoMaintain?: AutoMaintainPolicy | undefined;
/** Per-repo agent kill-switch (#776): when true, the action layer takes NO action on this repo (the
* global env switch overrides this too). Default false. */
agentPaused?: boolean | undefined;
/** 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;
createdAt?: string | null | undefined;
updatedAt?: string | null | undefined;
};
Expand Down
6 changes: 4 additions & 2 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2215,8 +2215,8 @@ describe("api routes", () => {
{
method: "PUT",
headers: ownerHeaders,
// #773/#774: the agent-layer config is settable here; the DB layer drops an unknown action class.
body: JSON.stringify({ gateCheckMode: "enabled", slopGateMode: "block", slopGateMinScore: 55, autonomy: { merge: "auto_with_approval", deploy: "auto" }, autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" } }),
// #773/#774/#776: the agent-layer config is settable here; the DB layer drops an unknown action class.
body: JSON.stringify({ gateCheckMode: "enabled", slopGateMode: "block", slopGateMinScore: 55, autonomy: { merge: "auto_with_approval", deploy: "auto" }, autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" }, agentPaused: true, agentDryRun: true }),
},
ownerEnv,
);
Expand All @@ -2227,6 +2227,8 @@ describe("api routes", () => {
slopGateMinScore: 55,
autonomy: { merge: "auto_with_approval" }, // unknown action class dropped by the DB normalizer
autoMaintain: { requireApprovals: 2, mergeMethod: "rebase" },
agentPaused: true, // #776 kill-switch
agentDryRun: true,
});
// requireApprovals is bounded at the API boundary — an out-of-range value is rejected, not silently clamped.
const settingsBadApprovals = await app.request(
Expand Down
71 changes: 71 additions & 0 deletions test/unit/agent-execution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
agentActionModeExecutes,
buildAgentActionAudit,
isGlobalAgentPause,
resolveAgentActionMode,
} from "../../src/settings/agent-execution";

describe("resolveAgentActionMode (#776 safety gate)", () => {
it("a global OR per-repo pause halts everything (safest wins)", () => {
expect(resolveAgentActionMode({ globalPaused: true })).toBe("paused");
expect(resolveAgentActionMode({ globalPaused: true, agentDryRun: true })).toBe("paused"); // pause beats dry-run
expect(resolveAgentActionMode({ globalPaused: false, agentPaused: true })).toBe("paused");
expect(resolveAgentActionMode({ globalPaused: false, agentPaused: true, agentDryRun: true })).toBe("paused");
});

it("dry-run wins over live when not paused", () => {
expect(resolveAgentActionMode({ globalPaused: false, agentDryRun: true })).toBe("dry_run");
expect(resolveAgentActionMode({ globalPaused: false, agentPaused: false, agentDryRun: true })).toBe("dry_run");
});

it("defaults to live only when nothing is set", () => {
expect(resolveAgentActionMode({ globalPaused: false })).toBe("live");
expect(resolveAgentActionMode({ globalPaused: false, agentPaused: false, agentDryRun: false })).toBe("live");
expect(resolveAgentActionMode({ globalPaused: false, agentPaused: null, agentDryRun: null })).toBe("live");
});

it("only live actually executes", () => {
expect(agentActionModeExecutes("live")).toBe(true);
expect(agentActionModeExecutes("dry_run")).toBe(false);
expect(agentActionModeExecutes("paused")).toBe(false);
});
});

describe("isGlobalAgentPause", () => {
it("recognizes the truthy-string forms and treats everything else as not paused", () => {
for (const v of ["1", "true", "TRUE", "yes", "on"]) expect(isGlobalAgentPause({ AGENT_ACTIONS_PAUSED: v })).toBe(true);
for (const v of ["0", "false", "no", "off", "", "maybe"]) expect(isGlobalAgentPause({ AGENT_ACTIONS_PAUSED: v })).toBe(false);
expect(isGlobalAgentPause({})).toBe(false);
});
});

describe("buildAgentActionAudit", () => {
it("produces a structured who/what/why/outcome/mode audit record", () => {
const audit = buildAgentActionAudit({
actionClass: "merge",
autonomyLevel: "auto_with_approval",
mode: "dry_run",
outcome: "completed",
repoFullName: "owner/repo",
targetKey: "owner/repo#7",
actor: "gittensory",
reason: "merge-readiness met",
});
expect(audit).toMatchObject({
eventType: "agent.action.merge",
actor: "gittensory",
targetKey: "owner/repo#7",
outcome: "completed",
detail: "merge-readiness met",
metadata: { repoFullName: "owner/repo", actionClass: "merge", autonomyLevel: "auto_with_approval", mode: "dry_run" },
});
});

it("falls back to the repo as the target key and null actor/reason", () => {
const audit = buildAgentActionAudit({ actionClass: "label", autonomyLevel: "auto", mode: "live", outcome: "completed", repoFullName: "owner/repo" });
expect(audit.targetKey).toBe("owner/repo");
expect(audit.actor).toBeNull();
expect(audit.detail).toBeNull();
});
});
6 changes: 6 additions & 0 deletions test/unit/data-spine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ describe("data spine repositories", () => {
await upsertRepositorySettings(env, { repoFullName: "owner/automaintainrepo", autoMaintain: { requireApprovals: 0, mergeMethod: "merge" } });
expect((await getRepositorySettings(env, "owner/automaintainrepo")).autoMaintain).toEqual({ requireApprovals: 0, mergeMethod: "merge" }); // update persists
expect((await getRepositorySettings(env, "owner/defaultpack")).autoMaintain).toEqual({ requireApprovals: 1, mergeMethod: "squash" }); // defaults
// #776 kill-switch + dry-run round-trip (insert + update) and default false.
await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentPaused: true, agentDryRun: true });
expect(await getRepositorySettings(env, "owner/saferepo")).toMatchObject({ agentPaused: true, agentDryRun: true });
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
expect(updated.slopAiAdvisory).toBe(false);
expect(await getRepoSyncState(env, "missing/repo")).toBeNull();
expect(await getPullRequest(env, "owner/repo", 404)).toBeNull();
Expand Down
Loading