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
50 changes: 18 additions & 32 deletions apps/server/src/gits/HermesTelegramCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
parseHermesTelegramCommand,
} from "./HermesTelegramCommand.ts";
import { AutomodeSupervisor, type AutomodeSupervisorShape } from "./Services/AutomodeSupervisor.ts";
import { DelamainAdapter, type DelamainAdapterShape } from "./Services/DelamainAdapter.ts";
import { GitsSlotScheduler, type GitsSlotSchedulerShape } from "./Services/GitsSlotScheduler.ts";

describe("parseHermesTelegramCommand", () => {
Expand Down Expand Up @@ -43,15 +42,7 @@ describe("parseHermesTelegramCommand", () => {
});
});

function testLayer(calls: string[], killFailures = new Set<string>()) {
const snapshot = {
goals: [
{ peerId: "peer-running", status: "running" },
{ peerId: "peer-pending", status: "pending" },
{ peerId: "peer-completed", status: "completed" },
{ peerId: null, status: "running" },
],
};
function testLayer(calls: string[], stopAllResult = { stoppedPeers: 2, failures: 0 }) {
const supervisor = {
approveGoal: ({ goalId }: { readonly goalId: string }) => {
calls.push(`approve:${goalId}`);
Expand All @@ -61,13 +52,9 @@ function testLayer(calls: string[], killFailures = new Set<string>()) {
calls.push(`reject:${goalId}:${reason ?? ""}`);
return Effect.succeed({});
},
updatePolicy: ({ killSwitchEnabled }: { readonly killSwitchEnabled?: boolean }) => {
calls.push(`policy:${String(killSwitchEnabled)}`);
return Effect.succeed(snapshot);
},
getSnapshot: () => {
calls.push("snapshot");
return Effect.succeed(snapshot);
stopAll: () => {
calls.push("stopAll");
return Effect.succeed(stopAllResult);
},
} as unknown as AutomodeSupervisorShape;
const scheduler = {
Expand All @@ -76,24 +63,21 @@ function testLayer(calls: string[], killFailures = new Set<string>()) {
return Effect.succeed({});
},
} as unknown as GitsSlotSchedulerShape;
const delamain = {
killPeer: ({ peerId }: { readonly peerId: string }) => {
calls.push(`kill:${peerId}`);
return killFailures.has(peerId) ? Effect.fail({ _tag: "TestFailure" }) : Effect.succeed({});
},
} as unknown as DelamainAdapterShape;

return Layer.mergeAll(
Layer.succeed(AutomodeSupervisor, supervisor),
Layer.succeed(GitsSlotScheduler, scheduler),
Layer.succeed(DelamainAdapter, delamain),
);
}

async function dispatch(text: string, calls: string[], killFailures?: Set<string>) {
async function dispatch(
text: string,
calls: string[],
stopAllResult?: { stoppedPeers: number; failures: number },
) {
return Effect.runPromise(
dispatchHermesTelegramCommand(parseHermesTelegramCommand(text)).pipe(
Effect.provide(testLayer(calls, killFailures)),
Effect.provide(testLayer(calls, stopAllResult)),
),
);
}
Expand Down Expand Up @@ -128,22 +112,24 @@ describe("dispatchHermesTelegramCommand", () => {
expect(calls).toEqual(["arm"]);
});

it("enables the kill switch before reading and terminating active peers", async () => {
it("delegates STOP to supervisor.stopAll and formats its counts", async () => {
const calls: string[] = [];

await expect(dispatch("STOP", calls)).resolves.toBe("Stop requested for 2 peer(s).");
await expect(dispatch("STOP", calls, { stoppedPeers: 2, failures: 0 })).resolves.toBe(
"Stop requested for 2 peer(s).",
);

expect(calls).toEqual(["policy:true", "snapshot", "kill:peer-running", "kill:peer-pending"]);
expect(calls).toEqual(["stopAll"]);
});

it("keeps the kill switch enabled and reports bounded stop failures", async () => {
it("reports bounded stop failures from supervisor.stopAll", async () => {
const calls: string[] = [];

await expect(dispatch("STOP", calls, new Set(["peer-pending"]))).resolves.toBe(
await expect(dispatch("STOP", calls, { stoppedPeers: 1, failures: 1 })).resolves.toBe(
"Stop requested for 1 peer(s); 1 failed.",
);

expect(calls[0]).toBe("policy:true");
expect(calls).toEqual(["stopAll"]);
});

it("returns help without calling a service for invalid commands", async () => {
Expand Down
32 changes: 4 additions & 28 deletions apps/server/src/gits/HermesTelegramCommand.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as Effect from "effect/Effect";

import { AutomodeSupervisor } from "./Services/AutomodeSupervisor.ts";
import { DelamainAdapter } from "./Services/DelamainAdapter.ts";
import { GitsSlotScheduler } from "./Services/GitsSlotScheduler.ts";

export type HermesTelegramCommand =
Expand Down Expand Up @@ -69,33 +68,10 @@ export function dispatchHermesTelegramCommand(command: HermesTelegramCommand) {
}
case "stop": {
const supervisor = yield* AutomodeSupervisor;
const delamain = yield* DelamainAdapter;
yield* supervisor.updatePolicy({ killSwitchEnabled: true });
const snapshot = yield* supervisor.getSnapshot();
let stopped = 0;
let failed = 0;

for (const goal of snapshot.goals) {
const status: string = goal.status;
if (goal.peerId === null || (status !== "running" && status !== "pending")) continue;
// Workflow-dispatched goals track the run id as peerId — kill the whole run
// (runner + live leaves), not the run record as a lone peer. Both branches are
// mapped to a boolean so the differing success types don't form an Effect union.
const ok = yield* (
goal.workflowId
? delamain.workflowKill({ workflowId: goal.workflowId }).pipe(Effect.as(true))
: delamain.killPeer({ peerId: goal.peerId }).pipe(Effect.as(true))
).pipe(Effect.orElseSucceed(() => false));
if (ok) {
stopped += 1;
} else {
failed += 1;
}
}

return failed === 0
? `Stop requested for ${stopped} peer(s).`
: `Stop requested for ${stopped} peer(s); ${failed} failed.`;
const { stoppedPeers, failures } = yield* supervisor.stopAll();
return failures === 0
? `Stop requested for ${stoppedPeers} peer(s).`
: `Stop requested for ${stoppedPeers} peer(s); ${failures} failed.`;
}
case "invalid":
return HELP;
Expand Down
150 changes: 146 additions & 4 deletions apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ import * as TestClock from "effect/testing/TestClock";

import type {
AutomodeBudgetUsage,
AutomodeEpisode,
DelamainPeerListResult,
HermesExecutionDraft,
HermesProposalCard,
} from "@t3tools/contracts";
import { HermesAdapterError } from "@t3tools/contracts";

import { ServerConfig } from "../../config.ts";
import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts";
import { AutomodeLanding } from "../Services/AutomodeLanding.ts";
import { AutomodeProposalSweep } from "../Services/AutomodeProposalSweep.ts";
import { AutomodeSupervisor } from "../Services/AutomodeSupervisor.ts";
import { AutomodeUsageMeter } from "../Services/AutomodeUsageMeter.ts";
import { DelamainAdapter } from "../Services/DelamainAdapter.ts";
import { HermesAdapter } from "../Services/HermesAdapter.ts";
import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts";
import { AutomodeProposalSweepLive } from "./AutomodeProposalSweep.ts";
import { AutomodeSupervisorLive } from "./AutomodeSupervisor.ts";

Expand Down Expand Up @@ -97,7 +100,9 @@ function makeDraft(overrides: Partial<HermesExecutionDraft>): HermesExecutionDra
interface HermesOptions {
readonly inspect?: (projectDir: string) => Effect.Effect<HermesProposalCard, HermesAdapterError>;
readonly draft?: HermesExecutionDraft;
readonly onInspect?: (projectDir: string) => void;
readonly onInspect?: (input: { readonly projectDir: string; readonly prompt?: string }) => void;
readonly episodes?: ReadonlyArray<AutomodeEpisode>;
readonly telegramSent?: string[];
}

function makeLayer(hermesOptions: HermesOptions = {}) {
Expand Down Expand Up @@ -125,8 +130,8 @@ function makeLayer(hermesOptions: HermesOptions = {}) {
// cross-night dedup guard sees distinct episodes.
let proposalSeq = 0;
const hermes = Layer.mock(HermesAdapter)({
inspectGitsAndPropose: ({ projectDir }) => {
hermesOptions.onInspect?.(projectDir);
inspectGitsAndPropose: ({ projectDir, prompt }) => {
hermesOptions.onInspect?.(prompt === undefined ? { projectDir } : { projectDir, prompt });
proposalSeq += 1;
return hermesOptions.inspect === undefined
? Effect.succeed(
Expand All @@ -141,27 +146,43 @@ function makeLayer(hermesOptions: HermesOptions = {}) {
decideProposal: () => Effect.succeed(makeCard({ status: "approved" })),
draftFromProposal: () => Effect.succeed(hermesOptions.draft ?? makeDraft({})),
});
const ledger = Layer.mock(AutomodeEpisodeLedger)({
list_episodes: () => Effect.succeed(hermesOptions.episodes ?? []),
});
const notifier = Layer.mock(HermesTelegramNotifier)({
notify: ({ subject, text }) =>
Effect.sync(() => {
hermesOptions.telegramSent?.push(`${subject}\n${text}`);
}),
});
return AutomodeProposalSweepLive.pipe(
Layer.provideMerge(supervisor),
Layer.provide(hermes),
Layer.provide(ledger),
Layer.provide(notifier),
Layer.provideMerge(TestClock.layer()),
);
}

function arm(options?: {
readonly nightlyProposalSweep?: boolean;
readonly proposalRepos?: string[];
readonly sweepRequiresConfirmation?: boolean;
readonly requireApprovalForPeerSpawn?: boolean;
}) {
return Effect.gen(function* () {
const supervisor = yield* AutomodeSupervisor;
yield* supervisor.updatePolicy({
mode: "autonomous",
killSwitchEnabled: false,
requireApprovalForPeerSpawn: false,
requireApprovalForPeerSpawn: options?.requireApprovalForPeerSpawn ?? false,
maxBudgetUsd: 25,
allowedRepos: [REPO, OTHER_REPO],
nightlyProposalSweep: options?.nightlyProposalSweep ?? true,
proposalRepos: options?.proposalRepos ?? [REPO],
// Legacy self-approved-queued path by default — matches every pre-existing test's
// expectations; only the new confirmation-loop tests below opt into the gate.
sweepRequiresConfirmation: options?.sweepRequiresConfirmation ?? false,
});
return supervisor;
});
Expand Down Expand Up @@ -304,4 +325,125 @@ describe("AutomodeProposalSweep", () => {
assert.equal((yield* supervisor.getSnapshot()).goals.length, 0);
}).pipe(Effect.provide(makeLayer({ onInspect: () => (inspects += 1) })));
});

it.effect(
"parks the sweep-drafted goal at waiting-approval and Telegram-announces it with its id when confirmation is required",
() => {
const telegramSent: string[] = [];
return Effect.gen(function* () {
const supervisor = yield* arm({
sweepRequiresConfirmation: true,
requireApprovalForPeerSpawn: true,
});
const sweep = yield* AutomodeProposalSweep;
yield* TestClock.setTime(EVENING);
yield* sweep.tick();

const snapshot = yield* supervisor.getSnapshot();
assert.equal(snapshot.goals.length, 1);
const goal = snapshot.goals[0]!;
// The confirmation gate parks the goal — it must NOT be self-approved.
assert.equal(goal.status, "waiting-approval");
assert.isNull(goal.approvedAt);

assert.equal(telegramSent.length, 1);
const message = telegramSent[0] ?? "";
assert.include(message, `[${goal.id}]`);
assert.match(message, /Improve sweep repo — sweep-repo/);
assert.include(message, "Reply: APPROVE <id> · REJECT <id>");
}).pipe(Effect.provide(makeLayer({ telegramSent })));
},
);

it.effect(
"parks the sweep-drafted goal at waiting-approval even when requireApprovalForPeerSpawn is off",
() => {
return Effect.gen(function* () {
// sweepRequiresConfirmation must gate independent of requireApprovalForPeerSpawn —
// an operator who disables per-peer-spawn approval must not thereby also waive the
// owner's sweep confirmation contract.
const supervisor = yield* arm({
sweepRequiresConfirmation: true,
requireApprovalForPeerSpawn: false,
});
const sweep = yield* AutomodeProposalSweep;
yield* TestClock.setTime(EVENING);
yield* sweep.tick();

const snapshot = yield* supervisor.getSnapshot();
assert.equal(snapshot.goals.length, 1);
const goal = snapshot.goals[0]!;
assert.equal(goal.status, "waiting-approval");
assert.isNull(goal.approvedAt);
}).pipe(Effect.provide(makeLayer()));
},
);

it.effect(
"keeps the legacy self-queued path (still Telegram-announced) when sweepRequiresConfirmation is off",
() => {
const telegramSent: string[] = [];
return Effect.gen(function* () {
const supervisor = yield* arm({ sweepRequiresConfirmation: false });
const sweep = yield* AutomodeProposalSweep;
yield* TestClock.setTime(EVENING);
yield* sweep.tick();

const snapshot = yield* supervisor.getSnapshot();
assert.equal(snapshot.goals.length, 1);
// Byte-identical to the pre-confirmation-loop behavior: queued, not approved,
// no dispatch-gate probe.
assert.equal(snapshot.goals[0]!.status, "queued");
assert.isNull(snapshot.goals[0]!.approvedAt);

assert.equal(telegramSent.length, 1);
assert.include(telegramSent[0] ?? "", `[${snapshot.goals[0]!.id}]`);
// With confirmation off the goals are already queued for autonomous dispatch —
// the message must not advertise an APPROVE/REJECT gate that does nothing.
assert.notInclude(telegramSent[0] ?? "", "APPROVE");
assert.include(telegramSent[0] ?? "", "no confirmation required");
assert.include(telegramSent[0] ?? "", "STOP");
}).pipe(Effect.provide(makeLayer({ telegramSent })));
},
);

it.effect("folds recent ledger outcomes for the repo into the Hermes proposal prompt", () => {
const capturedPrompts: Array<string | undefined> = [];
return Effect.gen(function* () {
yield* arm();
const sweep = yield* AutomodeProposalSweep;
yield* TestClock.setTime(EVENING);
yield* sweep.tick();

assert.equal(capturedPrompts.length, 1);
const prompt = capturedPrompts[0] ?? "";
assert.match(prompt, /Recent automode outcomes for this repo:/);
assert.match(prompt, /Fix flaky auth test: pass/);
assert.match(prompt, /Refactor logger: fail \(flagged\)/);
}).pipe(
Effect.provide(
makeLayer({
episodes: [
{ goalTitle: "Fix flaky auth test", verdict: "pass", flagged: false },
{ goalTitle: "Refactor logger", verdict: "fail", flagged: true },
] as never,
onInspect: (input) => capturedPrompts.push(input.prompt),
}),
),
);
});

it.effect("omits the outcomes section when the ledger has no rows for the repo", () => {
const capturedPrompts: Array<string | undefined> = [];
return Effect.gen(function* () {
yield* arm();
const sweep = yield* AutomodeProposalSweep;
yield* TestClock.setTime(EVENING);
yield* sweep.tick();
assert.equal(capturedPrompts.length, 1);
assert.isUndefined(capturedPrompts[0]);
}).pipe(
Effect.provide(makeLayer({ onInspect: (input) => capturedPrompts.push(input.prompt) })),
);
});
});
Loading
Loading