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
41 changes: 41 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# GITS Autonomy

GITS autonomy turns operator-reviewed improvement ideas into safely queued work while keeping the operator-visible history distinct from execution state.

## Language

**Proposal**:
A candidate piece of work presented for operator review. It is not executable work until accepted.
_Avoid_: Goal, task, notification

**Launch Configuration**:
The proposal-specific choices that define how accepted work should run, including its model and optional execution constraints.
_Avoid_: Global policy, settings form

**Acceptance**:
The operator decision that approves a Proposal with its Launch Configuration and requests exactly one Goal.
_Avoid_: Dispatch, start, run

**Goal**:
The durable execution record created from an accepted Proposal. A Goal may wait in the Queue before execution starts.
_Avoid_: Proposal, notification

**Queue**:
The ordered collection of Goals eligible for future execution. Entering the Queue does not mean execution has started.
_Avoid_: Inbox, scheduler

**Inbox Item**:
The durable operator-facing history of a Proposal and its resulting Goal. It is not execution truth.
_Avoid_: Goal, push notification

**Notification**:
An optional delivery signal that points to an Inbox Item. Delivery failure does not remove or alter the Inbox Item.
_Avoid_: Inbox Item, Proposal

**Pause**:
An operator state that prevents new proposals and future Goal starts without terminating work already running.
_Avoid_: Emergency Stop, kill

**Emergency Stop**:
An operator action that terminates running autonomous work and prevents new Goal starts until autonomy is explicitly enabled again.
_Avoid_: Pause, reject
47 changes: 47 additions & 0 deletions apps/server/src/gits/Layers/AutomodeSupervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,53 @@ describe("AutomodeSupervisorLive", () => {
}).pipe(Effect.provide(makeLayer())),
);

it.effect("enqueues at most one live goal for an episode", () =>
Effect.gen(function* () {
const supervisor = yield* AutomodeSupervisor;
yield* Effect.all(
[
supervisor.enqueueGoal({
episodeId: "episode-concurrent",
title: "First acceptance",
repo: "/tmp/source-repo",
prompt: "Run once.",
}),
supervisor.enqueueGoal({
episodeId: "episode-concurrent",
title: "Second acceptance",
repo: "/tmp/source-repo",
prompt: "Run once.",
}),
],
{ concurrency: "unbounded" },
);

const snapshot = yield* supervisor.getSnapshot();
assert.equal(
snapshot.goals.filter(
(goal) => goal.episodeId === "episode-concurrent" && goal.status !== "completed",
).length,
1,
);
}).pipe(Effect.provide(makeLayer())),
);

it.effect("rejects a zero-minute Goal runtime override", () =>
Effect.gen(function* () {
const supervisor = yield* AutomodeSupervisor;
const error = yield* supervisor
.enqueueGoal({
title: "Uncapped goal",
repo: "/tmp/source-repo",
prompt: "Run without a cap.",
maxRuntimeMinutes: 0,
})
.pipe(Effect.flip);

assert.include(error.message, "positive");
}).pipe(Effect.provide(makeLayer())),
);

it.effect("blocks dispatch when the kill switch is enabled", () =>
Effect.gen(function* () {
const supervisor = yield* AutomodeSupervisor;
Expand Down
37 changes: 31 additions & 6 deletions apps/server/src/gits/Layers/AutomodeSupervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ interface AutomodeState {
}

const ACTIVE_PEER_STATUSES = new Set<PeerStatus>(["pending", "running", "blocked", "waiting"]);
const TERMINAL_GOAL_STATUSES = new Set<AutomodeGoalStatus>([
"completed",
"failed",
"blocked",
"rejected",
]);
const INTEGRATION_PATTERN = /\b(merge|admin-merge|integrate|pull request|pr)\b/i;
const DESTRUCTIVE_PATTERN = /\b(reset --hard|rm -rf|delete|destroy|drop|truncate|force push)\b/i;
const AUTOMODE_STATE_FILE_NAME = "automode-state.json";
Expand Down Expand Up @@ -112,6 +118,10 @@ function toAutomodeError(message: string, cause?: unknown) {
});
}

function hasValidGoalRuntime(runtime: number | null | undefined): boolean {
return runtime === null || runtime === undefined || runtime > 0;
}

function unavailableBudgetUsage(note: string): AutomodeBudgetUsage {
return {
source: "unavailable",
Expand Down Expand Up @@ -692,6 +702,9 @@ export const AutomodeSupervisorLive = Layer.effect(
}),
enqueueGoal: (input) =>
Effect.gen(function* () {
if (!hasValidGoalRuntime(input.maxRuntimeMinutes)) {
return yield* toAutomodeError("Goal runtime must be a positive number of minutes.");
}
const createdAt = yield* nowIso;
const goal: AutomodeGoal = {
id: `goal-${randomUUID()}`,
Expand All @@ -718,12 +731,21 @@ export const AutomodeSupervisorLive = Layer.effect(
planningNotes: null,
planningBoundary: null,
};
const nextState = yield* commitState((state) => ({
...state,
goals: [goal, ...state.goals],
lastEvent: `Queued ${input.title}.`,
updatedAt: createdAt,
}));
const nextState = yield* commitState((state) =>
input.episodeId !== undefined &&
state.goals.some(
(existing) =>
existing.episodeId === input.episodeId &&
!TERMINAL_GOAL_STATUSES.has(existing.status),
)
? state
: {
...state,
goals: [goal, ...state.goals],
lastEvent: `Queued ${input.title}.`,
updatedAt: createdAt,
},
);
return yield* snapshotFromState(nextState);
}),
deferGoal: (input) =>
Expand Down Expand Up @@ -754,6 +776,9 @@ export const AutomodeSupervisorLive = Layer.effect(
}),
updateQueuedGoal: (input) =>
Effect.gen(function* () {
if (!hasValidGoalRuntime(input.maxRuntimeMinutes)) {
return yield* toAutomodeError("Goal runtime must be a positive number of minutes.");
}
const updatedAt = yield* nowIso;
const nextState = yield* commitStateOrFail((state) => {
const current = findGoal(state, input.goalId);
Expand Down
184 changes: 184 additions & 0 deletions apps/server/src/gits/Layers/AutopilotControl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import { describe, expect, it } from "vitest";

import {
GitsSlotSchedulerError,
type AutomodeSnapshot,
type GitsSchedulerSnapshot,
} from "@t3tools/contracts";

import { configureAutopilot, emergencyStopAutopilot } from "./AutopilotControl.ts";

const automode = { policy: { mode: "manual" } } as AutomodeSnapshot;
const scheduler = { config: { enabled: false } } as GitsSchedulerSnapshot;

describe("AutopilotControl", () => {
it("enables the scheduler before switching to the safe autonomous policy", async () => {
const calls: Array<readonly [string, unknown]> = [];
const result = await Effect.runPromise(
configureAutopilot(
{
scheduler: {
setConfig: (input) =>
Effect.sync(() => {
calls.push(["scheduler.setConfig", input]);
return scheduler;
}),
disarm: () => Effect.succeed(scheduler),
},
supervisor: {
updatePolicy: (input) =>
Effect.sync(() => {
calls.push(["supervisor.updatePolicy", input]);
return automode;
}),
},
},
{ enabled: true, repositories: ["/srv/repo"] },
),
);

expect(calls).toEqual([
["scheduler.setConfig", { enabled: true }],
[
"supervisor.updatePolicy",
{
mode: "autonomous",
killSwitchEnabled: false,
maxActivePeers: 1,
allowedRepos: ["/srv/repo"],
proposalRepos: ["/srv/repo"],
nightlyProposalSweep: true,
sweepRequiresConfirmation: true,
autoEnqueueApprovedProposals: true,
gitsNotificationsEnabled: true,
},
],
]);
expect(result).toEqual({ automode, scheduler });
});

it("rejects enabling without a repository", async () => {
const exit = await Effect.runPromiseExit(
configureAutopilot(
{
scheduler: {
setConfig: () => Effect.succeed(scheduler),
disarm: () => Effect.succeed(scheduler),
},
supervisor: { updatePolicy: () => Effect.succeed(automode) },
},
{ enabled: true, repositories: [] },
),
);

expect(Exit.isFailure(exit)).toBe(true);
});

it("does not switch policy when scheduler enable fails", async () => {
let policyCalls = 0;
const exit = await Effect.runPromiseExit(
configureAutopilot(
{
scheduler: {
setConfig: () =>
Effect.fail(new GitsSlotSchedulerError({ message: "scheduler unavailable" })),
disarm: () => Effect.succeed(scheduler),
},
supervisor: {
updatePolicy: () =>
Effect.sync(() => {
policyCalls += 1;
return automode;
}),
},
},
{ enabled: true, repositories: ["/srv/repo"] },
),
);

expect(Exit.isFailure(exit)).toBe(true);
expect(policyCalls).toBe(0);
});

it("disarms before pausing and stops peers before disarming an emergency stop", async () => {
const pauseCalls: Array<readonly [string, unknown]> = [];
await Effect.runPromise(
configureAutopilot(
{
scheduler: {
setConfig: () => Effect.succeed(scheduler),
disarm: (input) =>
Effect.sync(() => {
pauseCalls.push(["scheduler.disarm", input]);
return scheduler;
}),
},
supervisor: {
updatePolicy: (input) =>
Effect.sync(() => {
pauseCalls.push(["supervisor.updatePolicy", input]);
return automode;
}),
},
},
{ enabled: false, repositories: ["/srv/repo"] },
),
);
expect(pauseCalls).toEqual([
["scheduler.disarm", { reason: "Autopilot paused." }],
[
"supervisor.updatePolicy",
{
mode: "manual",
allowedRepos: ["/srv/repo"],
proposalRepos: ["/srv/repo"],
nightlyProposalSweep: false,
},
],
]);

const stopCalls: Array<string> = [];
await Effect.runPromise(
emergencyStopAutopilot({
scheduler: {
disarm: () =>
Effect.sync(() => {
stopCalls.push("scheduler.disarm");
return scheduler;
}),
},
supervisor: {
stopAll: () =>
Effect.sync(() => {
stopCalls.push("supervisor.stopAll");
return { stoppedPeers: 1, failures: 0 };
}),
},
}),
);
expect(stopCalls).toEqual(["supervisor.stopAll", "scheduler.disarm"]);
});

it("still stops peers when emergency scheduler disarm fails", async () => {
let stopCalls = 0;
const exit = await Effect.runPromiseExit(
emergencyStopAutopilot({
scheduler: {
disarm: () => Effect.fail(new GitsSlotSchedulerError({ message: "cannot disarm" })),
},
supervisor: {
stopAll: () =>
Effect.sync(() => {
stopCalls += 1;
return { stoppedPeers: 1, failures: 0 };
}),
},
}),
);

expect(Exit.isFailure(exit)).toBe(true);
expect(stopCalls).toBe(1);
});
});
Loading
Loading