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
44 changes: 43 additions & 1 deletion apps/server/src/gits/HermesTelegramCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest";
import {
dispatchHermesTelegramCommand,
parseHermesTelegramCommand,
resolveGoalToken,
shortGoalCode,
} from "./HermesTelegramCommand.ts";
import { AutomodeSupervisor, type AutomodeSupervisorShape } from "./Services/AutomodeSupervisor.ts";
import { GitsSlotScheduler, type GitsSlotSchedulerShape } from "./Services/GitsSlotScheduler.ts";
Expand Down Expand Up @@ -42,8 +44,18 @@ describe("parseHermesTelegramCommand", () => {
});
});

// Live goals visible to short-code resolution; ids match the test commands.
const SNAPSHOT_GOALS = [
{ id: "goal-1", title: "Goal one", status: "waiting-approval" },
{ id: "goal-2", title: "Goal two", status: "blocked" },
{ id: "goal-abc123", title: "Goal abc one", status: "queued" },
{ id: "goal-abc999", title: "Goal abc two", status: "queued" },
{ id: "goal-dead00", title: "Done goal", status: "completed" },
];

function testLayer(calls: string[], stopAllResult = { stoppedPeers: 2, failures: 0 }) {
const supervisor = {
getSnapshot: () => Effect.succeed({ goals: SNAPSHOT_GOALS }),
approveGoal: ({ goalId }: { readonly goalId: string }) => {
calls.push(`approve:${goalId}`);
return Effect.succeed({});
Expand Down Expand Up @@ -136,9 +148,39 @@ describe("dispatchHermesTelegramCommand", () => {
const calls: string[] = [];

await expect(dispatch("RUN arbitrary rpc payload", calls)).resolves.toBe(
"Commands: APPROVE <goal-id>, REJECT <goal-id>, DEFER <goal-id>, ARM, SKIP <goal-id>, STOP.",
"Commands: APPROVE <code>, REJECT <code>, DEFER <code>, ARM, SKIP <code>, STOP. <code> is the short code from the goal message (e.g. 68a5) or a full goal id.",
);

expect(calls).toEqual([]);
});

it("resolves short codes (case-insensitive) to the full goal id", async () => {
const calls: string[] = [];
await expect(dispatch("APPROVE ABC1", calls)).resolves.toBe("Goal approved.");
expect(calls).toEqual(["approve:goal-abc123"]);
});

it("reports ambiguity instead of guessing", async () => {
const calls: string[] = [];
const reply = await dispatch("APPROVE abc", calls);
expect(reply).toContain("matches several goals");
expect(reply).toContain("abc1");
expect(reply).toContain("abc9");
expect(calls).toEqual([]);
});

it("rejects unknown codes and does not match terminal goals", async () => {
const calls: string[] = [];
await expect(dispatch("APPROVE zzz9", calls)).resolves.toBe("No live goal matches 'zzz9'.");
await expect(dispatch("APPROVE dead", calls)).resolves.toBe("No live goal matches 'dead'.");
expect(calls).toEqual([]);
});
});

describe("shortGoalCode / resolveGoalToken", () => {
it("derives 4-char codes and resolves exact full ids even when terminal", () => {
expect(shortGoalCode("goal-68a553c5-9252")).toBe("68a5");
const exact = resolveGoalToken(SNAPSHOT_GOALS as never, "goal-dead00");
expect(exact).toEqual({ kind: "ok", goalId: "goal-dead00" });
});
});
82 changes: 77 additions & 5 deletions apps/server/src/gits/HermesTelegramCommand.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
import * as Effect from "effect/Effect";

import type { AutomodeGoal } from "@t3tools/contracts";

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

// Short, phone-typable goal reference shown in Telegram messages: the first 4 hex
// chars of the goal's uuid. Codes derive from the id (never a list position), so a
// code can't silently start pointing at a different goal as the queue shifts.
export function shortGoalCode(goalId: string): string {
return goalId.replace(/^goal-/, "").slice(0, 4);
}

const LIVE_GOAL_STATUSES = new Set(["queued", "waiting-approval", "blocked", "running"]);

export type GoalTokenResolution =
| { readonly kind: "ok"; readonly goalId: string }
| { readonly kind: "ambiguous"; readonly matches: ReadonlyArray<AutomodeGoal> }
| { readonly kind: "not-found" };

// Resolve a Telegram goal token: exact id always wins; otherwise a case-insensitive
// prefix (with or without the "goal-" prefix, >= 3 chars) must match exactly one
// LIVE goal — ambiguity is reported, never guessed.
export function resolveGoalToken(
goals: ReadonlyArray<AutomodeGoal>,
token: string,
): GoalTokenResolution {
const exact = goals.find((goal) => goal.id === token);
if (exact !== undefined) return { kind: "ok", goalId: exact.id };
const needle = token.toLowerCase().replace(/^goal-/, "");
if (needle.length < 3) return { kind: "not-found" };
const matches = goals.filter(
(goal) =>
LIVE_GOAL_STATUSES.has(goal.status) &&
goal.id
.replace(/^goal-/, "")
.toLowerCase()
.startsWith(needle),
);
if (matches.length === 1) return { kind: "ok", goalId: matches[0]!.id };
if (matches.length > 1) return { kind: "ambiguous", matches };
return { kind: "not-found" };
}

export type HermesTelegramCommand =
| { readonly _tag: "approve"; readonly goalId: string }
| { readonly _tag: "reject"; readonly goalId: string }
Expand All @@ -13,7 +53,7 @@ export type HermesTelegramCommand =
| { readonly _tag: "invalid" };

const HELP =
"Commands: APPROVE <goal-id>, REJECT <goal-id>, DEFER <goal-id>, ARM, SKIP <goal-id>, STOP.";
"Commands: APPROVE <code>, REJECT <code>, DEFER <code>, ARM, SKIP <code>, STOP. <code> is the short code from the goal message (e.g. 68a5) or a full goal id.";

export function parseHermesTelegramCommand(text: string): HermesTelegramCommand {
const [verb, goalId, extra] = text.trim().split(/\s+/);
Expand All @@ -38,22 +78,51 @@ export function parseHermesTelegramCommand(text: string): HermesTelegramCommand
}
}

function describeResolutionFailure(resolution: GoalTokenResolution, token: string): string {
if (resolution.kind === "ambiguous") {
const options = resolution.matches
.map((goal) => `${shortGoalCode(goal.id)} (${goal.title.slice(0, 40)})`)
.join(", ");
return `'${token}' matches several goals: ${options}. Add more characters.`;
}
return `No live goal matches '${token}'.`;
}

export function dispatchHermesTelegramCommand(command: HermesTelegramCommand) {
return Effect.gen(function* () {
// Resolve short codes once for every goal-targeting verb.
let resolvedGoalId: string | null = null;
if (
command._tag === "approve" ||
command._tag === "reject" ||
command._tag === "defer" ||
command._tag === "skip"
) {
const supervisor = yield* AutomodeSupervisor;
const snapshot = yield* supervisor.getSnapshot();
const resolution = resolveGoalToken(snapshot.goals, command.goalId);
if (resolution.kind !== "ok") {
return describeResolutionFailure(resolution, command.goalId);
}
resolvedGoalId = resolution.goalId;
}
switch (command._tag) {
case "approve": {
const supervisor = yield* AutomodeSupervisor;
yield* supervisor.approveGoal({ goalId: command.goalId });
yield* supervisor.approveGoal({ goalId: resolvedGoalId ?? command.goalId });
return "Goal approved.";
}
case "reject": {
const supervisor = yield* AutomodeSupervisor;
yield* supervisor.rejectGoal({ goalId: command.goalId });
yield* supervisor.rejectGoal({ goalId: resolvedGoalId ?? command.goalId });
return "Goal rejected.";
}
case "defer": {
const supervisor = yield* AutomodeSupervisor;
yield* supervisor.rejectGoal({ goalId: command.goalId, reason: "Deferred by operator." });
yield* supervisor.rejectGoal({
goalId: resolvedGoalId ?? command.goalId,
reason: "Deferred by operator.",
});
return "Goal deferred.";
}
case "arm": {
Expand All @@ -63,7 +132,10 @@ export function dispatchHermesTelegramCommand(command: HermesTelegramCommand) {
}
case "skip": {
const supervisor = yield* AutomodeSupervisor;
yield* supervisor.rejectGoal({ goalId: command.goalId, reason: "Skipped by operator." });
yield* supervisor.rejectGoal({
goalId: resolvedGoalId ?? command.goalId,
reason: "Skipped by operator.",
});
return "Goal skipped.";
}
case "stop": {
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/gits/Layers/AutomodeDriver.halt-alert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";

import { HALT_ALERT_COOLDOWN_MS, shouldSendHaltAlert } from "./AutomodeDriver.ts";

describe("shouldSendHaltAlert", () => {
it("sends the first alert and suppresses same-reason repeats inside the cooldown", () => {
expect(shouldSendHaltAlert(null, "peer failed", 1000)).toBe(true);
const last = { reason: "peer failed", at: 1000 };
expect(shouldSendHaltAlert(last, "peer failed", 1000 + 5000)).toBe(false);
expect(shouldSendHaltAlert(last, "peer failed", 1000 + HALT_ALERT_COOLDOWN_MS)).toBe(true);
});

it("always sends when the reason changes", () => {
const last = { reason: "peer failed", at: 1000 };
expect(shouldSendHaltAlert(last, "landing non-fast-forward", 1001)).toBe(true);
});
});
36 changes: 31 additions & 5 deletions apps/server/src/gits/Layers/AutomodeDriver.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as Clock from "effect/Clock";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import * as Result from "effect/Result";

import {
Expand All @@ -25,6 +27,17 @@ import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts";
import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts";
import { decide_automode_gate } from "./AutomodeReviewGate.ts";

export const HALT_ALERT_COOLDOWN_MS = 30 * 60 * 1000;

/** Pure gate for halt Telegram alerts: send unless the same reason fired within the cooldown. */
export function shouldSendHaltAlert(
last: { readonly reason: string; readonly at: number } | null,
reason: string,
now: number,
): boolean {
return last === null || last.reason !== reason || now - last.at >= HALT_ALERT_COOLDOWN_MS;
}

const TICK_INTERVAL_MS = (() => {
const raw = process.env.GITS_AUTOMODE_DRIVER_TICK_MS?.trim();
const parsed = raw === undefined || raw === "" ? Number.NaN : Number.parseInt(raw, 10);
Expand Down Expand Up @@ -109,14 +122,27 @@ export const AutomodeDriverLive = Layer.effect(
),
);

// Same-reason halt alerts are suppressed for a cooldown window: a driver that
// re-halts into the same failure (e.g. operator keeps resuming, or a gate keeps
// denying) must not flood Telegram with identical messages.
// ponytail: in-memory, resets on restart — acceptable, restarts re-arm the kill switch.
const lastHaltAlertRef = yield* Ref.make<{ reason: string; at: number } | null>(null);
const halt = (goal: AutomodeGoal | null, reason: string) =>
supervisor.haltDriver({ reason }).pipe(
Effect.tap(() =>
notify({
subject: "GITS automode halted",
title: goal?.title ?? "Automode driver",
goalId: goal?.id ?? null,
reason,
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
const last = yield* Ref.get(lastHaltAlertRef);
if (!shouldSendHaltAlert(last, reason, now)) {
return yield* Effect.logInfo("gits.automode.halt-alert-suppressed", { reason });
}
yield* Ref.set(lastHaltAlertRef, { reason, at: now });
yield* notify({
subject: "GITS automode halted",
title: goal?.title ?? "Automode driver",
goalId: goal?.id ?? null,
reason,
});
}),
),
);
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,9 @@ describe("AutomodeProposalSweep", () => {

assert.equal(telegramSent.length, 1);
const message = telegramSent[0] ?? "";
assert.include(message, `[${goal.id}]`);
assert.include(message, `[${goal.id.replace(/^goal-/, "").slice(0, 4)}]`);
assert.match(message, /Improve sweep repo — sweep-repo/);
assert.include(message, "Reply: APPROVE <id> · REJECT <id>");
assert.include(message, "Reply: APPROVE <code> · REJECT <code>");
}).pipe(Effect.provide(makeLayer({ telegramSent })));
},
);
Expand Down Expand Up @@ -397,7 +397,10 @@ describe("AutomodeProposalSweep", () => {
assert.isNull(snapshot.goals[0]!.approvedAt);

assert.equal(telegramSent.length, 1);
assert.include(telegramSent[0] ?? "", `[${snapshot.goals[0]!.id}]`);
assert.include(
telegramSent[0] ?? "",
`[${snapshot.goals[0]!.id.replace(/^goal-/, "").slice(0, 4)}]`,
);
// 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");
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/gits/Layers/AutomodeProposalSweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Semaphore from "effect/Semaphore";
import type { AutomodeGoal } from "@t3tools/contracts";

import { writeFileStringAtomically } from "../../atomicWrite.ts";
import { shortGoalCode } from "../HermesTelegramCommand.ts";
import { ServerConfig } from "../../config.ts";
import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts";
import { HermesAdapter } from "../Services/HermesAdapter.ts";
Expand All @@ -21,7 +22,7 @@ import { hasLiveGoalForRepo } from "./HermesAutomodeBridge.ts";
import { london_instant } from "./GitsSlotScheduler.ts";
import { repoAllowed } from "./AutomodeSupervisor.ts";

const REPLY_HINT = "Reply: APPROVE <id> · REJECT <id>";
const REPLY_HINT = "Reply: APPROVE <code> · REJECT <code>";

const STATE_FILE_NAME = "automode-proposal-sweep-state.json";
const DEFAULT_START_MINUTES = 20 * 60; // 20:00 London dinner window.
Expand Down Expand Up @@ -254,7 +255,7 @@ export const AutomodeProposalSweepLive = Layer.effect(
}
if (newGoals.length > 0) {
const goalLines = newGoals.map(
(goal) => `${goal.title} — ${path.basename(goal.repo)} [${goal.id}]`,
(goal) => `${goal.title} — ${path.basename(goal.repo)} [${shortGoalCode(goal.id)}]`,
);
// The reply hint must reflect reality: with confirmation off, goals are already
// queued for autonomous dispatch — APPROVE/REJECT would be a false gate; STOP is
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/gits/Layers/AutomodeTelegramDigest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,10 @@ describe("AutomodeTelegramDigest", () => {
assert.match(sent[0] ?? "", /finished/);
assert.match(sent[0] ?? "", /failed/);
assert.match(sent[0] ?? "", /1/);
assert.match(sent[1] ?? "", /one \[goal-one\]/);
assert.match(sent[1] ?? "", /five \[goal-five\]/);
assert.match(sent[1] ?? "", /one \[one\]/);
assert.match(sent[1] ?? "", /five \[five\]/);
assert.notMatch(sent[1] ?? "", /six/);
assert.match(sent[1] ?? "", /Reply: APPROVE <id> · REJECT <id>/);
assert.match(sent[1] ?? "", /Reply: APPROVE <code> · REJECT <code>/);
}).pipe(Effect.provide(makeLayer({ sent })));
});

Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/gits/Layers/AutomodeTelegramDigest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";

import { writeFileStringAtomically } from "../../atomicWrite.ts";
import { shortGoalCode } from "../HermesTelegramCommand.ts";
import { ServerConfig } from "../../config.ts";
import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts";
import { AutomodeSupervisor } from "../Services/AutomodeSupervisor.ts";
Expand All @@ -17,7 +18,7 @@ import { GitsSlotScheduler } from "../Services/GitsSlotScheduler.ts";
import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts";
import { london_instant } from "./GitsSlotScheduler.ts";

const REPLY_HINT = "Reply: APPROVE <id> · REJECT <id>";
const REPLY_HINT = "Reply: APPROVE <code> · REJECT <code>";

const STATE_FILE_NAME = "automode-telegram-digest-state.json";
const PersistedState = Schema.Struct({
Expand Down Expand Up @@ -145,9 +146,16 @@ export const AutomodeTelegramDigestLive = Layer.effect(
[
"Queued/proposed goals:",
...snapshot.goals
.filter((goal) => goal.status === "queued" || goal.status === "waiting-approval")
// blocked included: goals parked by the kill switch / gates are exactly
// the ones the operator needs to see and can APPROVE.
.filter(
(goal) =>
goal.status === "queued" ||
goal.status === "waiting-approval" ||
goal.status === "blocked",
)
.slice(0, 5)
.map((goal) => `- ${goal.title} [${goal.id}]`),
.map((goal) => `- ${goal.title} [${shortGoalCode(goal.id)}]`),
"",
REPLY_HINT,
].join("\n"),
Expand Down
14 changes: 8 additions & 6 deletions profiles/motoko-gits/SOUL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,17 @@ You reason, remember, brief, propose, and route. You do not directly execute rep

## Telegram GITS control relay

Recognize only these exact uppercase command forms as GITS controls:
Recognize these command forms (case-insensitive verbs) as GITS controls:

- `APPROVE <goal-id>`
- `REJECT <goal-id>`
- `DEFER <goal-id>`
- `APPROVE <code>`
- `REJECT <code>`
- `DEFER <code>`
- `ARM`
- `SKIP <goal-id>`
- `SKIP <code>`
- `STOP`

`<code>` is the short goal code shown in sweep/digest messages (e.g. `68a5`) or a full goal id; the server resolves it.

For a control, POST the original command as `{ "command": "..." }` to `http://127.0.0.1:<server-port>/api/gits/hermes-telegram/command` with `Authorization: Bearer $GITS_HERMES_TELEGRAM_RELAY_TOKEN`. Return the route's `text` response verbatim, with no added explanation. Malformed or unsupported control input is relayed unchanged so the route returns its help message. Hermes remains the sole Telegram long-poll owner; GITS never polls Telegram.

Nightly sweep proposals now arrive as `waiting-approval` goals, announced by their goal ID (in the sweep Telegram message and the evening digest). `APPROVE <goal-id>` confirms one of these for that night's run — same control as any other pending goal, no new command.
Nightly sweep proposals now arrive as `waiting-approval` goals, announced with a short code (in the sweep Telegram message and the evening digest). `APPROVE <code>` confirms one of these for that night's run — same control as any other pending goal, no new command.
Loading