From 2683dd07bfb3b0057546b7926cebf841391ba09d Mon Sep 17 00:00:00 2001 From: Joshua Date: Sun, 9 Aug 2026 17:30:41 +0100 Subject: [PATCH 01/17] feat(gits): add cockpit proposal notifications --- .../src/gits/Layers/AutomodeDriver.test.ts | 33 ++- apps/server/src/gits/Layers/AutomodeDriver.ts | 130 +++++++----- .../gits/Layers/AutomodeNotifications.test.ts | 62 ++++++ .../src/gits/Layers/AutomodeNotifications.ts | 108 ++++++++++ .../gits/Layers/AutomodeProposalSweep.test.ts | 129 +++++++----- .../src/gits/Layers/AutomodeProposalSweep.ts | 166 +++++++++++----- .../src/gits/Layers/AutomodeSupervisor.ts | 25 ++- .../gits/Layers/HermesAutomodeBridge.test.ts | 45 ++++- .../src/gits/Layers/HermesAutomodeBridge.ts | 123 +++++++----- .../src/gits/Layers/HermesCliAdapter.test.ts | 46 +++++ .../src/gits/Layers/HermesCliAdapter.ts | 42 ++++ apps/server/src/server.test.ts | 16 ++ apps/server/src/server.ts | 14 +- apps/server/src/ws.ts | 71 ++++++- apps/web/src/components/gits/GitsCockpit.tsx | 23 ++- .../gits/cockpit/AutopilotPanel.tsx | 35 ++++ .../components/gits/cockpit/MotokoPanel.tsx | 188 +++++++++++++++++- .../cockpit/autopilot/autopilot.logic.test.ts | 2 + .../gits/cockpit/motoko/motoko.logic.test.ts | 18 +- .../gits/cockpit/motoko/motoko.logic.ts | 12 ++ .../src/automode-policy-fields.test.ts | 6 + packages/contracts/src/gits.test.ts | 28 +++ packages/contracts/src/gits.ts | 45 +++++ 23 files changed, 1134 insertions(+), 233 deletions(-) create mode 100644 apps/server/src/gits/Layers/AutomodeNotifications.test.ts create mode 100644 apps/server/src/gits/Layers/AutomodeNotifications.ts diff --git a/apps/server/src/gits/Layers/AutomodeDriver.test.ts b/apps/server/src/gits/Layers/AutomodeDriver.test.ts index 56bafd0baa2d..2c2d3e331be6 100644 --- a/apps/server/src/gits/Layers/AutomodeDriver.test.ts +++ b/apps/server/src/gits/Layers/AutomodeDriver.test.ts @@ -30,6 +30,7 @@ import { HermesTelegramNotifierError, type HermesTelegramNotifierShape, } from "../Services/HermesTelegramNotifier.ts"; +import { AutomodeNotifications } from "./AutomodeNotifications.ts"; import { GitsReviewPipeline } from "../Services/GitsReviewPipeline.ts"; import { GitsSlotScheduler, @@ -298,7 +299,14 @@ function makeLayer( notify: (input) => Effect.suspend(() => { options?.onNotify?.(input); - return options?.notifyError === undefined ? Effect.void : Effect.fail(options.notifyError); + return options?.notifyError === undefined ? Effect.void : Effect.die(options.notifyError); + }), + }); + const notifications = Layer.mock(AutomodeNotifications)({ + notify: (input) => + Effect.suspend(() => { + options?.onNotify?.(input); + return options?.notifyError === undefined ? Effect.void : Effect.die(options.notifyError); }), }); const config = ServerConfig.layerTest( @@ -324,6 +332,7 @@ function makeLayer( Layer.provide(digest), Layer.provide(proposalSweep), Layer.provide(notifier), + Layer.provide(notifications), ); } @@ -395,6 +404,28 @@ describe("AutomodeDriver", () => { }).pipe(Effect.provide(makeLayer(peerStatus))); }); + it.effect("keeps a proposal queued until its not-before time", () => { + const peerStatus = { current: "absent" as PeerStatus | "absent" }; + return Effect.gen(function* () { + const supervisor = yield* AutomodeSupervisor; + const driver = yield* AutomodeDriver; + yield* armAutonomous(supervisor); + yield* supervisor.enqueueGoal({ + title: "Scheduled", + repo: "/tmp/source-repo", + prompt: "do it later", + notBefore: "2099-01-01T00:00:00.000Z", + }); + + yield* driver.tickOnce(); + + assert.equal( + (yield* supervisor.getSnapshot()).goals.find((goal) => goal.title === "Scheduled")?.status, + "queued", + ); + }).pipe(Effect.provide(makeLayer(peerStatus))); + }); + it.effect("does not dispatch a second goal while one is running (sequential)", () => { const peerStatus = { current: "absent" as PeerStatus | "absent" }; return Effect.gen(function* () { diff --git a/apps/server/src/gits/Layers/AutomodeDriver.ts b/apps/server/src/gits/Layers/AutomodeDriver.ts index cd86cc5128a6..c39f2777121f 100644 --- a/apps/server/src/gits/Layers/AutomodeDriver.ts +++ b/apps/server/src/gits/Layers/AutomodeDriver.ts @@ -23,9 +23,9 @@ import { GitsReviewPipeline } from "../Services/GitsReviewPipeline.ts"; import { GitsSlotScheduler } from "../Services/GitsSlotScheduler.ts"; import { AUTOMODE_BASE_REF, AutomodeLanding } from "../Services/AutomodeLanding.ts"; import { AutomodeHeldPr } from "../Services/AutomodeHeldPr.ts"; -import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts"; import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts"; import { decide_automode_gate } from "./AutomodeReviewGate.ts"; +import { AutomodeNotifications } from "./AutomodeNotifications.ts"; export const HALT_ALERT_COOLDOWN_MS = 30 * 60 * 1000; @@ -92,7 +92,7 @@ export const AutomodeDriverLive = Layer.effect( const scheduler = yield* GitsSlotScheduler; const telegramDigest = yield* AutomodeTelegramDigest; const proposalSweep = yield* AutomodeProposalSweep; - const telegramNotifier = yield* HermesTelegramNotifier; + const notifications = yield* AutomodeNotifications; const toDriverError = (message: string) => (cause: unknown) => new AutomodeSupervisorError({ message, cause }); @@ -104,23 +104,42 @@ export const AutomodeDriverLive = Layer.effect( readonly reason: string; readonly prUrl?: string | null; }) => - telegramNotifier - .notify({ - subject: input.subject, - text: [ - `Title: ${input.title}`, - `Goal ID: ${input.goalId ?? "none"}`, - `Reason: ${input.reason}`, - ...(input.prUrl === undefined || input.prUrl === null - ? [] - : [`PR URL: ${input.prUrl}`]), - ].join("\n"), - }) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning("gits.automode.telegram.alert-failed", { cause }), - ), - ); + Effect.gen(function* () { + const policy = yield* supervisor.getPolicy(); + yield* notifications + .notify({ + key: `automode:${input.goalId ?? "driver"}:${input.subject}`, + subject: input.subject, + text: [ + `Title: ${input.title}`, + `Goal ID: ${input.goalId ?? "none"}`, + `Reason: ${input.reason}`, + ...(input.prUrl === undefined || input.prUrl === null + ? [] + : [`PR URL: ${input.prUrl}`]), + ].join("\n"), + url: `/gits?panel=autopilot${input.goalId === null ? "" : `&goal=${encodeURIComponent(input.goalId)}`}`, + gitsEnabled: policy.gitsNotificationsEnabled, + telegramEnabled: policy.telegramNotificationsEnabled, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("gits.automode.notification-failed", { cause }), + ), + ); + }); + + const failGoal = (goal: AutomodeGoal, reason: string) => + supervisor.failGoal({ goalId: goal.id, reason }).pipe( + Effect.tap(() => + notify({ + subject: "GITS automode goal failed", + title: goal.title, + goalId: goal.id, + reason, + }), + ), + ); // 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 @@ -240,24 +259,27 @@ export const AutomodeDriverLive = Layer.effect( } if (peer.integrationStatus === "failed" || TERMINAL_FAIL_STATUSES.has(peer.status)) { - yield* supervisor.failGoal({ - goalId: running.id, - reason: `Peer ${peer.id} ended as ${peer.status}.`, - }); + yield* failGoal(running, `Peer ${peer.id} ended as ${peer.status}.`); yield* halt(running, `Halted: ${running.title} failed (${peer.status}).`); return; } if (peer.integrationStatus === "skipped") { // delamain skips the push when the peer branch has zero commits ahead of the // sync base — nothing ever reaches origin, so landing would fail every tick. - yield* supervisor.failGoal({ - goalId: running.id, - reason: `Peer ${peer.id} finished with no changes ahead of the integration branch (nothing pushed).`, - }); + yield* failGoal( + running, + `Peer ${peer.id} finished with no changes ahead of the integration branch (nothing pushed).`, + ); yield* halt(running, `Halted: ${running.title} produced no changes to land.`); return; } if (peer.status === "waiting") { + yield* notify({ + subject: "GITS automode goal waiting", + title: running.title, + goalId: running.id, + reason: `Peer ${peer.id} is waiting on input.`, + }); yield* halt( running, `Halted: peer ${peer.id} is waiting on input for ${running.title}.`, @@ -281,7 +303,11 @@ export const AutomodeDriverLive = Layer.effect( // Floor ∪ policy (policy wins by label). The fail-closed empty check guards // the MERGED set — unreachable while the floor const is non-empty, kept as // defense against a future emptied floor. - const verificationCommands = merge_verify_commands(policy.verificationCommands); + const verificationCommands = merge_verify_commands( + running.verificationCommands.length > 0 + ? running.verificationCommands + : policy.verificationCommands, + ); if (verificationCommands.length === 0) { yield* halt( running, @@ -300,10 +326,10 @@ export const AutomodeDriverLive = Layer.effect( .workflowStatus({ workflowId: running.workflowId }) .pipe(Effect.result); if (Result.isFailure(statusResult)) { - yield* supervisor.failGoal({ - goalId: running.id, - reason: `Could not read workflow ${running.workflowId} status to resolve the landed slice.`, - }); + yield* failGoal( + running, + `Could not read workflow ${running.workflowId} status to resolve the landed slice.`, + ); yield* halt( running, `Halted: workflow ${running.workflowId} status read failed for ${running.title}.`, @@ -312,10 +338,10 @@ export const AutomodeDriverLive = Layer.effect( } const leafIds = statusResult.success.peerIds; if (leafIds.length === 0) { - yield* supervisor.failGoal({ - goalId: running.id, - reason: `Workflow ${running.workflowId} finished with no leaf peer to land.`, - }); + yield* failGoal( + running, + `Workflow ${running.workflowId} finished with no leaf peer to land.`, + ); yield* halt( running, `Halted: workflow ${running.workflowId} produced no leaf peer for ${running.title}.`, @@ -333,10 +359,10 @@ export const AutomodeDriverLive = Layer.effect( .getPeerStatus({ peerId: leafIds[0]! }) .pipe(Effect.result); if (Result.isFailure(leafResult)) { - yield* supervisor.failGoal({ - goalId: running.id, - reason: `Could not read leaf peer ${leafIds[0]} of workflow ${running.workflowId}.`, - }); + yield* failGoal( + running, + `Could not read leaf peer ${leafIds[0]} of workflow ${running.workflowId}.`, + ); yield* halt( running, `Halted: leaf peer ${leafIds[0]} status read failed for ${running.title}.`, @@ -365,10 +391,7 @@ export const AutomodeDriverLive = Layer.effect( }) .pipe(Effect.result); if (Result.isFailure(reviewResult)) { - yield* supervisor.failGoal({ - goalId: running.id, - reason: `Verifier errored for ${running.title}.`, - }); + yield* failGoal(running, `Verifier errored for ${running.title}.`); yield* halt( running, `Halted: verifier errored for ${running.title} — manual check needed.`, @@ -379,7 +402,7 @@ export const AutomodeDriverLive = Layer.effect( const review = reviewResult.success; const decision = decide_automode_gate(review); if (decision.action === "fail") { - yield* supervisor.failGoal({ goalId: running.id, reason: decision.reason }); + yield* failGoal(running, decision.reason); yield* halt(running, `Halted: ${running.title} failed review — ${decision.reason}`); return; } @@ -490,11 +513,18 @@ export const AutomodeDriverLive = Layer.effect( yield* maintainHeldPr(snapshot); return; } + if ( + next.notBefore !== null && + Date.parse(next.notBefore) > (yield* Clock.currentTimeMillis) + ) { + yield* maintainHeldPr(snapshot); + return; + } // Scheduler start gate (decisions 6/7/11/20): a deny leaves the goal queued for a // later tick — quiet, NOT a halt. Manual RPC dispatch stays ungated (human-driven). const gate = yield* scheduler .checkStartAllowed({ - expectedRuntimeMinutes: snapshot.policy.maxRuntimeMinutes, + expectedRuntimeMinutes: next.maxRuntimeMinutes ?? snapshot.policy.maxRuntimeMinutes, maxActivePeers: snapshot.policy.maxActivePeers, }) .pipe(Effect.mapError(toDriverError("Scheduler start gate check failed."))); @@ -535,6 +565,14 @@ export const AutomodeDriverLive = Layer.effect( } const result = dispatched.success; if (result.peer === null) { + yield* notify({ + subject: result.approvalRequired + ? "GITS automode goal waiting" + : "GITS automode goal blocked", + title: next.title, + goalId: next.id, + reason: result.blockedReason ?? "Automode dispatch did not start.", + }); yield* halt( next, result.blockedReason ?? `Dispatch of ${next.title} did not spawn a peer.`, diff --git a/apps/server/src/gits/Layers/AutomodeNotifications.test.ts b/apps/server/src/gits/Layers/AutomodeNotifications.test.ts new file mode 100644 index 000000000000..1abf6452b33c --- /dev/null +++ b/apps/server/src/gits/Layers/AutomodeNotifications.test.ts @@ -0,0 +1,62 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { deliverAutomodeNotification } from "./AutomodeNotifications.ts"; + +describe("deliverAutomodeNotification", () => { + it.effect("routes independently and deduplicates stable event keys", () => + Effect.gen(function* () { + const delivered = new Set(); + const push: string[] = []; + const telegram: string[] = []; + const input = { + key: "proposal:p1:created", + subject: "New Motoko proposal", + text: "Improve retries", + url: "/gits?panel=autopilot&proposal=p1", + gitsEnabled: true, + telegramEnabled: false, + }; + const dependencies = { + delivered, + push: (url: string) => Effect.sync(() => push.push(url)).pipe(Effect.asVoid), + telegram: (text: string) => Effect.sync(() => telegram.push(text)).pipe(Effect.asVoid), + }; + + yield* deliverAutomodeNotification(input, dependencies); + yield* deliverAutomodeNotification(input, dependencies); + + assert.deepEqual(push, ["/gits?panel=autopilot&proposal=p1"]); + assert.deepEqual(telegram, []); + + yield* deliverAutomodeNotification( + { ...input, key: "proposal:p2:created", gitsEnabled: false, telegramEnabled: true }, + dependencies, + ); + assert.deepEqual(telegram, ["Improve retries"]); + }), + ); + + it.effect("does not fail state transitions when delivery fails", () => + Effect.gen(function* () { + const delivered = new Set(); + yield* deliverAutomodeNotification( + { + key: "goal:g1:failed", + subject: "Automode attention needed", + text: "Goal failed", + url: "/gits?panel=autopilot§ion=goals", + gitsEnabled: true, + telegramEnabled: true, + }, + { + delivered, + push: () => Effect.fail("push failed"), + telegram: () => Effect.fail("telegram failed"), + }, + ); + assert.isFalse(delivered.has("gits:goal:g1:failed")); + assert.isFalse(delivered.has("telegram:goal:g1:failed")); + }), + ); +}); diff --git a/apps/server/src/gits/Layers/AutomodeNotifications.ts b/apps/server/src/gits/Layers/AutomodeNotifications.ts new file mode 100644 index 000000000000..8a5925c1a9a2 --- /dev/null +++ b/apps/server/src/gits/Layers/AutomodeNotifications.ts @@ -0,0 +1,108 @@ +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { writeFileStringAtomically } from "../../atomicWrite.ts"; +import { ServerConfig } from "../../config.ts"; +import { PushNotificationService } from "../../push/Services/PushNotificationService.ts"; +import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts"; + +export interface AutomodeNotificationInput { + readonly key: string; + readonly subject: string; + readonly text: string; + readonly url: string; + readonly gitsEnabled: boolean; + readonly telegramEnabled: boolean; +} + +export function deliverAutomodeNotification( + input: AutomodeNotificationInput, + dependencies: { + readonly delivered: Set; + readonly push: (url: string) => Effect.Effect; + readonly telegram: (text: string) => Effect.Effect; + }, +): Effect.Effect { + const deliver = ( + channel: "gits" | "telegram", + enabled: boolean, + effect: Effect.Effect, + ) => { + const key = `${channel}:${input.key}`; + if (!enabled || dependencies.delivered.has(key)) return Effect.void; + return effect.pipe( + Effect.tap(() => Effect.sync(() => dependencies.delivered.add(key))), + Effect.ignoreCause({ log: true }), + ); + }; + + return Effect.all([ + deliver("gits", input.gitsEnabled, dependencies.push(input.url)), + deliver("telegram", input.telegramEnabled, dependencies.telegram(input.text)), + ]).pipe(Effect.asVoid); +} + +export interface AutomodeNotificationsShape { + readonly notify: (input: AutomodeNotificationInput) => Effect.Effect; +} + +const decodeDeliveredKeys = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(Schema.String)), +); + +export class AutomodeNotifications extends Context.Service< + AutomodeNotifications, + AutomodeNotificationsShape +>()("t3/gits/Layers/AutomodeNotifications") {} + +export const AutomodeNotificationsLive = Layer.effect( + AutomodeNotifications, + Effect.gen(function* () { + const config = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const push = yield* PushNotificationService; + const telegram = yield* HermesTelegramNotifier; + const statePath = path.join(config.stateDir, "gits", "automode-notifications-state.json"); + const delivered = new Set( + yield* fs.readFileString(statePath).pipe( + Effect.flatMap(decodeDeliveredKeys), + Effect.catch(() => Effect.succeed([])), + ), + ); + const semaphore = yield* Semaphore.make(1); + + return { + notify: (input) => + semaphore.withPermits(1)( + deliverAutomodeNotification(input, { + delivered, + push: (url) => + push.sendToAll({ + title: input.subject, + body: input.text, + tag: `gits:${input.key}`, + url, + }), + telegram: (text) => telegram.notify({ subject: input.subject, text }), + }).pipe( + Effect.andThen( + writeFileStringAtomically({ + filePath: statePath, + contents: `${JSON.stringify([...delivered].slice(-1_000), null, 2)}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.ignoreCause({ log: true }), + ), + ), + ), + ), + }; + }), +); diff --git a/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts b/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts index 3e4264dead2e..edc082a1ace2 100644 --- a/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts +++ b/apps/server/src/gits/Layers/AutomodeProposalSweep.test.ts @@ -11,10 +11,11 @@ import type { HermesExecutionDraft, HermesProposalCard, } from "@t3tools/contracts"; -import { HermesAdapterError } from "@t3tools/contracts"; +import { HermesAdapterError, ThreadId } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { AutomodeEpisodeLedger } from "../../persistence/Services/AutomodeEpisodeLedger.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { AutomodeLanding } from "../Services/AutomodeLanding.ts"; import { AutomodeProposalSweep } from "../Services/AutomodeProposalSweep.ts"; import { AutomodeSupervisor } from "../Services/AutomodeSupervisor.ts"; @@ -22,7 +23,8 @@ 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 { AutomodeProposalSweepLive, selectContinuationsByRepo } from "./AutomodeProposalSweep.ts"; +import { AutomodeNotifications } from "./AutomodeNotifications.ts"; import { AutomodeSupervisorLive } from "./AutomodeSupervisor.ts"; import { AutomodeSupervisorTestRoutingLayer } from "./AutomodeSupervisor.testHelpers.ts"; @@ -70,6 +72,12 @@ function makeCard(overrides: Partial): HermesProposalCard { blockedReason: null, source: "test", projectDir: REPO, + model: null, + notBefore: null, + maxRuntimeMinutes: null, + verificationCommands: [], + integrationBranch: null, + sourceThreadId: null, decisionReason: null, decidedAt: null, createdAt: "2026-01-01T00:00:00.000Z", @@ -104,6 +112,7 @@ interface HermesOptions { readonly onInspect?: (input: { readonly projectDir: string; readonly prompt?: string }) => void; readonly episodes?: ReadonlyArray; readonly telegramSent?: string[]; + readonly notificationsSent?: string[]; } function makeLayer(hermesOptions: HermesOptions = {}) { @@ -157,11 +166,28 @@ function makeLayer(hermesOptions: HermesOptions = {}) { hermesOptions.telegramSent?.push(`${subject}\n${text}`); }), }); + const notifications = Layer.mock(AutomodeNotifications)({ + notify: (input) => + Effect.sync(() => { + hermesOptions.notificationsSent?.push(input.url); + }), + }); + const projection = Layer.mock(ProjectionSnapshotQuery)({ + getSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }); return AutomodeProposalSweepLive.pipe( Layer.provideMerge(supervisor), Layer.provide(hermes), Layer.provide(ledger), Layer.provide(notifier), + Layer.provide(notifications), + Layer.provide(projection), Layer.provideMerge(TestClock.layer()), ); } @@ -191,6 +217,25 @@ function arm(options?: { } describe("AutomodeProposalSweep", () => { + it("selects the newest unfinished thread per repository", () => { + const selected = selectContinuationsByRepo({ + projects: [{ id: "project-1", workspaceRoot: REPO }], + threads: [ + { + id: ThreadId.make("thread-unfinished"), + projectId: "project-1", + title: "Finish notifications", + updatedAt: "2026-01-02T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + latestTurn: { state: "interrupted" }, + proposedPlans: [], + }, + ], + }); + assert.equal(selected.get(REPO)?.id, "thread-unfinished"); + }); + it.effect("enqueues one waiting-approval goal per opted-in repo, once per night", () => { let inspects = 0; return Effect.gen(function* () { @@ -328,58 +373,40 @@ describe("AutomodeProposalSweep", () => { }).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); + it.effect("leaves a review-required sweep as a proposal and deep-links its notification", () => { + const notificationsSent: string[] = []; + return Effect.gen(function* () { + const supervisor = yield* arm({ + sweepRequiresConfirmation: true, + requireApprovalForPeerSpawn: true, + }); + const sweep = yield* AutomodeProposalSweep; + yield* TestClock.setTime(EVENING); + yield* sweep.tick(); - assert.equal(telegramSent.length, 1); - const message = telegramSent[0] ?? ""; - assert.include(message, `[${goal.id.replace(/^goal-/, "").slice(0, 4)}]`); - assert.match(message, /Improve sweep repo — sweep-repo/); - assert.include(message, "Reply: APPROVE · REJECT "); - }).pipe(Effect.provide(makeLayer({ telegramSent }))); - }, - ); + const snapshot = yield* supervisor.getSnapshot(); + assert.equal(snapshot.goals.length, 0); + assert.deepEqual(notificationsSent, ["/gits?panel=autopilot&proposal=proposal-1"]); + }).pipe(Effect.provide(makeLayer({ notificationsSent }))); + }); - 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(); + it.effect("does not enqueue a review-required sweep when peer approval 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())); - }, - ); + const snapshot = yield* supervisor.getSnapshot(); + assert.equal(snapshot.goals.length, 0); + }).pipe(Effect.provide(makeLayer())); + }); it.effect( "keeps the legacy self-queued path (still Telegram-announced) when sweepRequiresConfirmation is off", diff --git a/apps/server/src/gits/Layers/AutomodeProposalSweep.ts b/apps/server/src/gits/Layers/AutomodeProposalSweep.ts index d3bcc4b6b238..0375de3537c8 100644 --- a/apps/server/src/gits/Layers/AutomodeProposalSweep.ts +++ b/apps/server/src/gits/Layers/AutomodeProposalSweep.ts @@ -8,7 +8,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { AutomodeGoal } from "@t3tools/contracts"; +import type { AutomodeGoal, ThreadId } from "@t3tools/contracts"; import { writeFileStringAtomically } from "../../atomicWrite.ts"; import { shortGoalCode } from "../HermesTelegramCommand.ts"; @@ -18,16 +18,69 @@ import { HermesAdapter } from "../Services/HermesAdapter.ts"; import { AutomodeProposalSweep } from "../Services/AutomodeProposalSweep.ts"; import { AutomodeSupervisor } from "../Services/AutomodeSupervisor.ts"; import { HermesTelegramNotifier } from "../Services/HermesTelegramNotifier.ts"; +import { AutomodeNotifications } from "./AutomodeNotifications.ts"; import { hasLiveGoalForRepo } from "./HermesAutomodeBridge.ts"; import { london_instant } from "./GitsSlotScheduler.ts"; import { repoAllowed } from "./AutomodeSupervisor.ts"; - -const REPLY_HINT = "Reply: APPROVE · REJECT "; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const STATE_FILE_NAME = "automode-proposal-sweep-state.json"; const DEFAULT_START_MINUTES = 20 * 60; // 20:00 London dinner window. const START_TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; +export function selectContinuationsByRepo(readModel: { + readonly projects: ReadonlyArray<{ readonly id: string; readonly workspaceRoot: string }>; + readonly threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly projectId: string; + readonly title: string; + readonly updatedAt: string; + readonly archivedAt: string | null; + readonly deletedAt: string | null; + readonly latestTurn: { readonly state: string } | null; + readonly proposedPlans: ReadonlyArray<{ + readonly implementedAt: string | null; + readonly implementationThreadId: string | null; + }>; + }>; +}) { + const projects = new Map( + readModel.projects.map((project) => [project.id, project.workspaceRoot]), + ); + const candidates = new Map< + string, + { readonly id: ThreadId; readonly title: string; readonly reason: string } + >(); + for (const thread of readModel.threads.toSorted((a, b) => + b.updatedAt.localeCompare(a.updatedAt), + )) { + const repo = projects.get(thread.projectId); + if ( + repo === undefined || + candidates.has(repo) || + thread.archivedAt !== null || + thread.deletedAt !== null + ) { + continue; + } + const pendingPlan = thread.proposedPlans.some( + (plan) => plan.implementedAt === null && plan.implementationThreadId === null, + ); + const interrupted = + thread.latestTurn?.state === "interrupted" || thread.latestTurn?.state === "error"; + if (pendingPlan || interrupted) { + candidates.set(repo, { + id: thread.id, + title: thread.title, + reason: pendingPlan + ? "an accepted plan remains unimplemented" + : "the last turn did not complete", + }); + } + } + return candidates; +} + /** GITS_PROPOSAL_SWEEP_START_HHMM override; invalid input warns and falls back to 20:00. */ function parseStartMinutes(raw: string | undefined): { readonly minutes: number; @@ -104,6 +157,8 @@ export const AutomodeProposalSweepLive = Layer.effect( const hermes = yield* HermesAdapter; const ledger = yield* AutomodeEpisodeLedger; const notifier = yield* HermesTelegramNotifier; + const notifications = yield* AutomodeNotifications; + const projection = yield* ProjectionSnapshotQuery; const parsedStart = parseStartMinutes(process.env.GITS_PROPOSAL_SWEEP_START_HHMM); if (parsedStart.error !== null) { yield* Effect.logWarning("gits.sweep.start-invalid", { detail: parsedStart.error }); @@ -113,12 +168,10 @@ export const AutomodeProposalSweepLive = Layer.effect( const stateRef = yield* Ref.make(yield* loadState(statePath)); const semaphore = yield* Semaphore.make(1); - // Propose to ONE repo: skip it if a goal is already live for it (dedup BEFORE the codex - // spend), then spawn Hermes, approve the card, draft it, and enqueue the drafted - // delamain-peer as a queued goal carrying the card's episodeId. A failure here is - // logged and swallowed so the sweep continues to the next repo. Returns the enqueued - // goal (null on any skip/failure) so the caller can gate approval and notify. - const sweepRepo = (repo: string): Effect.Effect => + const sweepRepo = ( + repo: string, + continuation?: { readonly id: ThreadId; readonly title: string; readonly reason: string }, + ): Effect.Effect<{ readonly proposed: boolean; readonly goal: AutomodeGoal | null }> => Effect.gen(function* () { // Dedup by repo, ahead of hermes: a fresh inspect always mints a new episodeId, so // episode dedup could never fire here. A repo whose prior-night sweep or cockpit @@ -126,27 +179,44 @@ export const AutomodeProposalSweepLive = Layer.effect( const snapshot = yield* supervisor.getSnapshot(); if (hasLiveGoalForRepo(snapshot.goals, repo)) { yield* Effect.logInfo("gits.sweep.dedup-skipped", { repo }); - return null; + return { proposed: false, goal: null }; } // Feedback loop: fold the repo's recent ledger outcomes into the proposal prompt so // Hermes doesn't blindly repeat an idea that already ran, landed, or got flagged. const episodes = yield* ledger.list_episodes({ repo, limit: 5 }); - const outcomesSection = - episodes.length === 0 - ? undefined + const promptSections = [ + ...(episodes.length === 0 + ? [] : [ - "Recent automode outcomes for this repo:", - ...episodes.map( - (episode) => - `- ${episode.goalTitle}: ${episode.verdict}${episode.flagged ? " (flagged)" : ""}`, - ), - ].join("\n"); + [ + "Recent automode outcomes for this repo:", + ...episodes.map( + (episode) => + `- ${episode.goalTitle}: ${episode.verdict}${episode.flagged ? " (flagged)" : ""}`, + ), + ].join("\n"), + ]), + ...(continuation === undefined + ? [] + : [ + `Unfinished past thread ${continuation.id} (${continuation.title}): ${continuation.reason}. Propose the smallest safe continuation if it is still relevant.`, + ]), + ]; // "worktree-spawn" makes the card actionable: a read-only inspect card can only ever // draft as "verification", never "delamain-peer" (draftKindFor keys on actionKind). const card = yield* hermes.inspectGitsAndPropose({ projectDir: repo, actionKind: "worktree-spawn", - ...(outcomesSection === undefined ? {} : { prompt: outcomesSection }), + ...(promptSections.length === 0 ? {} : { prompt: promptSections.join("\n\n") }), + ...(continuation === undefined ? {} : { sourceThreadId: continuation.id }), + }); + yield* notifications.notify({ + key: `proposal:${card.id}:created`, + subject: "New Motoko proposal", + text: `${card.title} — ${path.basename(repo)}`, + url: `/gits?panel=autopilot&proposal=${encodeURIComponent(card.id)}`, + gitsEnabled: snapshot.policy.gitsNotificationsEnabled, + telegramEnabled: snapshot.policy.telegramNotificationsEnabled, }); if (card.status === "blocked" || card.projectDir === null) { yield* Effect.logInfo("gits.sweep.card-skipped", { @@ -154,7 +224,12 @@ export const AutomodeProposalSweepLive = Layer.effect( status: card.status, blockedReason: card.blockedReason, }); - return null; + return { proposed: false, goal: null }; + } + // A review-required sweep stops at the proposal. The cockpit approval bridge owns + // decide -> draft -> enqueue, so edits are applied before any runnable goal exists. + if (snapshot.policy.sweepRequiresConfirmation) { + return { proposed: true, goal: null }; } // Approve before drafting — mirrors the approve bridge's decide→draft order. // draftFromProposal blocks any card whose status !== "approved". @@ -166,7 +241,7 @@ export const AutomodeProposalSweepLive = Layer.effect( kind: draft.kind, status: draft.status, }); - return null; + return { proposed: false, goal: null }; } const enqueued = yield* supervisor.enqueueGoal({ title: draft.title, @@ -176,11 +251,11 @@ export const AutomodeProposalSweepLive = Layer.effect( origin: "sweep", }); // enqueueGoal prepends the new goal, so it's always the freshest entry. - return enqueued.goals[0] ?? null; + return { proposed: true, goal: enqueued.goals[0] ?? null }; }).pipe( Effect.catchCause((cause) => Effect.logWarning("gits.sweep.repo-failed", { repo, cause: Cause.pretty(cause) }).pipe( - Effect.as(null), + Effect.as({ proposed: false, goal: null }), ), ), ); @@ -213,32 +288,29 @@ export const AutomodeProposalSweepLive = Layer.effect( yield* Ref.set(stateRef, next); // Sequential: hermes invocations share codex capacity — never parallelise. const newGoals: AutomodeGoal[] = []; + let proposalCount = 0; let attemptedRepos = 0; + const readModel = yield* projection.getSnapshot().pipe( + Effect.catchCause((cause) => + Effect.logWarning("gits.sweep.continuations-unavailable", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), + ), + ); + const continuationByRepo = + readModel === null ? new Map() : selectContinuationsByRepo(readModel); for (const repo of policy.proposalRepos) { if (!repoAllowed(policy, repo)) { yield* Effect.logWarning("gits.sweep.repo-not-allowed", { repo }); continue; } attemptedRepos += 1; - const goal = yield* sweepRepo(repo); + const { proposed, goal } = yield* sweepRepo(repo, continuationByRepo.get(repo)); + if (proposed) proposalCount += 1; if (goal === null) continue; newGoals.push(goal); - if (policy.sweepRequiresConfirmation) { - // Parks the goal at waiting-approval via the shared dispatch-time approval gate: - // the goal carries origin "sweep" (enqueued above), and goalNeedsApproval in - // AutomodeSupervisor.ts treats sweep + sweepRequiresConfirmation as needing - // approval independent of requireApprovalForPeerSpawn. - yield* supervisor.dispatchGoal({ goalId: goal.id }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("gits.sweep.confirm-gate-failed", { - goalId: goal.id, - cause: Cause.pretty(cause), - }), - ), - ); - } } - if (attemptedRepos > 0 && newGoals.length === 0) { + if (attemptedRepos > 0 && proposalCount === 0) { // A sweep night is consumed up front, so a silent zero-goal run means the owner // waits a whole day without knowing anything went wrong. Always say so. // ponytail: generic message; per-repo reasons live in the gits.sweep.* logs. @@ -260,14 +332,12 @@ export const AutomodeProposalSweepLive = Layer.effect( // 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 // the only real control. - const text = policy.sweepRequiresConfirmation - ? ["New goals from tonight's sweep:", ...goalLines, "", REPLY_HINT].join("\n") - : [ - "New goals from tonight's sweep (queued for autonomous run — no confirmation required):", - ...goalLines, - "", - "Reply: STOP to halt automode.", - ].join("\n"); + const text = [ + "New goals from tonight's sweep (queued for autonomous run — no confirmation required):", + ...goalLines, + "", + "Reply: STOP to halt automode.", + ].join("\n"); yield* notifier .notify({ subject: "GITS nightly sweep", text }) .pipe( diff --git a/apps/server/src/gits/Layers/AutomodeSupervisor.ts b/apps/server/src/gits/Layers/AutomodeSupervisor.ts index 7c6af9a8fbfb..7005fe60af13 100644 --- a/apps/server/src/gits/Layers/AutomodeSupervisor.ts +++ b/apps/server/src/gits/Layers/AutomodeSupervisor.ts @@ -236,6 +236,8 @@ function defaultPolicy(updatedAt: string): AutomodePolicy { integrationBranch: null, motokoAuthority: "observe", telegramDigestEnabled: true, + gitsNotificationsEnabled: true, + telegramNotificationsEnabled: false, sweepRequiresConfirmation: true, updatedAt, }; @@ -350,10 +352,8 @@ function sortGoals(goals: ReadonlyArray): AutomodeGoal[] { return [...goals].sort((left, right) => right.createdAt.localeCompare(left.createdAt)); } -function shouldScheduleRuntimeLimit(policy: AutomodePolicy): policy is AutomodePolicy & { - readonly maxRuntimeMinutes: number; -} { - return policy.maxRuntimeMinutes !== null && policy.maxRuntimeMinutes > 0; +function shouldScheduleRuntimeLimit(minutes: number | null): minutes is number { + return minutes !== null && minutes > 0; } function budgetBlockedReason( @@ -425,6 +425,9 @@ function applyPolicyUpdate( input.integrationBranch === undefined ? policy.integrationBranch : input.integrationBranch, motokoAuthority: input.motokoAuthority ?? policy.motokoAuthority, telegramDigestEnabled: input.telegramDigestEnabled ?? policy.telegramDigestEnabled, + gitsNotificationsEnabled: input.gitsNotificationsEnabled ?? policy.gitsNotificationsEnabled, + telegramNotificationsEnabled: + input.telegramNotificationsEnabled ?? policy.telegramNotificationsEnabled, sweepRequiresConfirmation: input.sweepRequiresConfirmation ?? policy.sweepRequiresConfirmation, updatedAt, }; @@ -661,7 +664,7 @@ export const AutomodeSupervisorLive = Layer.effect( if ( nextPolicy.mode === "autonomous" && nextPolicy.maxBudgetUsd === null && - !shouldScheduleRuntimeLimit(nextPolicy) + !shouldScheduleRuntimeLimit(nextPolicy.maxRuntimeMinutes) ) { return yield* toAutomodeError( "Autonomous mode requires a resource cap — set a max budget (maxBudgetUsd) or a runtime cap (maxRuntimeMinutes).", @@ -703,6 +706,10 @@ export const AutomodeSupervisorLive = Layer.effect( rejectedAt: null, workflowId: null, branch: null, + notBefore: input.notBefore ?? null, + maxRuntimeMinutes: input.maxRuntimeMinutes ?? null, + verificationCommands: input.verificationCommands ?? [], + integrationBranch: input.integrationBranch ?? null, }; const nextState = yield* commitState((state) => ({ ...state, @@ -877,7 +884,8 @@ export const AutomodeSupervisorLive = Layer.effect( // into one branch, and deterministic, so a re-approved goal resumes its branch. // Never spawn unpinned: delamain's default would merge peer work straight into // the origin default branch. - const goalBranch = state.policy.integrationBranch ?? `automode/${goal.id}`; + const goalBranch = + goal.integrationBranch ?? state.policy.integrationBranch ?? `automode/${goal.id}`; yield* landing.ensure_integration_branch({ repo: goal.repo, integrationBranch: goalBranch, @@ -910,8 +918,9 @@ export const AutomodeSupervisorLive = Layer.effect( ), ); const workflowId: string | null = null; - const deadlineEpochMs = shouldScheduleRuntimeLimit(state.policy) - ? DateTime.toEpochMillis(yield* DateTime.now) + state.policy.maxRuntimeMinutes * 60_000 + const runtimeMinutes = goal.maxRuntimeMinutes ?? state.policy.maxRuntimeMinutes; + const deadlineEpochMs = shouldScheduleRuntimeLimit(runtimeMinutes) + ? DateTime.toEpochMillis(yield* DateTime.now) + runtimeMinutes * 60_000 : null; const updatedAt = yield* nowIso; diff --git a/apps/server/src/gits/Layers/HermesAutomodeBridge.test.ts b/apps/server/src/gits/Layers/HermesAutomodeBridge.test.ts index e41338d2093d..2591441d3c82 100644 --- a/apps/server/src/gits/Layers/HermesAutomodeBridge.test.ts +++ b/apps/server/src/gits/Layers/HermesAutomodeBridge.test.ts @@ -114,6 +114,12 @@ function makeProposal(status: HermesProposalStatus): HermesProposalCard { blockedReason: null, source: "test", projectDir: "/tmp/source-repo", + model: "gpt-5.6-sol", + notBefore: "2026-01-02T00:00:00.000Z", + maxRuntimeMinutes: 45, + verificationCommands: [{ label: "typecheck", cmd: ["bun", "typecheck"] }], + integrationBranch: "auto/proposal-1", + sourceThreadId: "thread-1", decisionReason: null, decidedAt: null, createdAt: "2026-01-01T00:00:00.000Z", @@ -202,6 +208,11 @@ describe("decideProposalWithAutomodeBridge", () => { assert.equal(snapshot.goals[0]!.status, "queued"); // Episode thread (decision 23): the goal carries the proposal's episodeId. assert.equal(snapshot.goals[0]!.episodeId, "epi-proposal-1"); + assert.equal(snapshot.goals[0]!.model, "gpt-5.6-sol"); + assert.equal(snapshot.goals[0]!.notBefore, "2026-01-02T00:00:00.000Z"); + assert.equal(snapshot.goals[0]!.maxRuntimeMinutes, 45); + assert.equal(snapshot.goals[0]!.verificationCommands.length, 1); + assert.equal(snapshot.goals[0]!.integrationBranch, "auto/proposal-1"); const dispatched = yield* supervisor.dispatchGoal({ goalId: snapshot.goals[0]!.id }); assert.equal(dispatched.goal.status, "running"); @@ -217,7 +228,7 @@ describe("decideProposalWithAutomodeBridge", () => { ); }); - it.effect("approve with flag off stays handoff-only", () => + it.effect("approve queues even when the legacy auto-enqueue flag is off", () => Effect.gen(function* () { const supervisor = yield* AutomodeSupervisor; yield* supervisor.updatePolicy({ @@ -232,15 +243,18 @@ describe("decideProposalWithAutomodeBridge", () => { decision: "approve", }); - assert.equal((yield* supervisor.getSnapshot()).goals.length, 0); - assert.equal(draftCallCount(), 0); + assert.equal((yield* supervisor.getSnapshot()).goals.length, 1); + assert.equal(draftCallCount(), 1); }).pipe(Effect.provide(makeSupervisorLayer())), ); - it.effect("approve with flag on but manual mode stays handoff-only", () => + it.effect("approve queues in manual mode without dispatching", () => Effect.gen(function* () { const supervisor = yield* AutomodeSupervisor; - yield* supervisor.updatePolicy({ autoEnqueueApprovedProposals: true }); + yield* supervisor.updatePolicy({ + autoEnqueueApprovedProposals: true, + allowedRepos: ["/tmp/source-repo"], + }); const { hermes, draftCallCount } = makeFakeHermes(); yield* decideProposalWithAutomodeBridge(hermes, supervisor, { @@ -248,8 +262,8 @@ describe("decideProposalWithAutomodeBridge", () => { decision: "approve", }); - assert.equal((yield* supervisor.getSnapshot()).goals.length, 0); - assert.equal(draftCallCount(), 0); + assert.equal((yield* supervisor.getSnapshot()).goals.length, 1); + assert.equal(draftCallCount(), 1); }).pipe(Effect.provide(makeSupervisorLayer())), ); @@ -290,7 +304,7 @@ describe("decideProposalWithAutomodeBridge", () => { }); assert.equal((yield* supervisor.getSnapshot()).goals.length, 1); - assert.equal(draftCallCount(), 1); + assert.equal(draftCallCount(), 0); }).pipe(Effect.provide(makeSupervisorLayer())), ); @@ -309,6 +323,21 @@ describe("decideProposalWithAutomodeBridge", () => { }).pipe(Effect.provide(makeSupervisorLayer())), ); + it.effect("rejects edited repositories outside Automode policy before persisting approval", () => + Effect.gen(function* () { + const supervisor = yield* armAutonomous; + const { hermes } = makeFakeHermes(); + const error = yield* decideProposalWithAutomodeBridge(hermes, supervisor, { + proposalId: "proposal-1", + decision: "approve", + projectDir: "/tmp/not-allowed", + }).pipe(Effect.flip); + + assert.include(error.message, "not allowed"); + assert.equal((yield* supervisor.getSnapshot()).goals.length, 0); + }).pipe(Effect.provide(makeSupervisorLayer())), + ); + it.effect("open-gsd, verification, and blocked drafts are never enqueued", () => Effect.gen(function* () { const supervisor = yield* armAutonomous; diff --git a/apps/server/src/gits/Layers/HermesAutomodeBridge.ts b/apps/server/src/gits/Layers/HermesAutomodeBridge.ts index 62b34bb3f2b3..aeb317b40ce6 100644 --- a/apps/server/src/gits/Layers/HermesAutomodeBridge.ts +++ b/apps/server/src/gits/Layers/HermesAutomodeBridge.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; -import type { HermesProposalDecisionInput } from "@t3tools/contracts"; +import { HermesAdapterError, type HermesProposalDecisionInput } from "@t3tools/contracts"; import type { AutomodeSupervisorShape } from "../Services/AutomodeSupervisor.ts"; import type { HermesAdapterShape } from "../Services/HermesAdapter.ts"; @@ -33,69 +33,90 @@ export const hasLiveGoalForRepo = ( goals.some((goal) => goal.repo === repo && !TERMINAL_GOAL_STATUSES.includes(goal.status)); /** - * Motoko→Automode bridge (audit 2026-07-07 §B4). Decides the proposal, then — only when - * the operator has opted in via `autoEnqueueApprovedProposals` AND armed autonomous mode — - * converts a freshly approved proposal into a queued automode goal via the existing - * draft rails. Everything else (flag off, manual/supervised mode, re-approve, open-gsd / - * verification / blocked drafts) stays handoff-only, exactly as before. - * - * Bridge failures are logged, not surfaced: the decision itself already persisted, and - * failing the RPC after that would leave the operator unable to retry (re-approve is a - * guarded no-op). The missing goal is visible in the automode panel either way. + * Motoko→Automode bridge. Approval always queues an actionable Delamain draft; the + * Automode policy and scheduler still control when it can dispatch. Re-approval retries + * a prior enqueue failure and remains idempotent once a live episode goal exists. */ export const decideProposalWithAutomodeBridge = ( hermes: Pick, automode: Pick, input: HermesProposalDecisionInput, -): ReturnType => +) => Effect.gen(function* () { - // Capture the snapshot once: its policy arms the bridge and its goals feed the - // episode-thread dedup below (a card the sweep already enqueued must not double-enqueue). const snapshot = input.decision === "approve" - ? yield* automode.getSnapshot().pipe(Effect.catch(() => Effect.succeed(null))) + ? yield* automode + .getSnapshot() + .pipe( + Effect.mapError( + (cause) => + new HermesAdapterError({ message: "Failed to validate Automode policy.", cause }), + ), + ) : null; - const bridgeArmed = - snapshot !== null && - snapshot.policy.autoEnqueueApprovedProposals && - snapshot.policy.mode === "autonomous"; - // Prior status must be read before deciding: approve→approve must not re-enqueue. - // Capture the whole card — its episodeId threads proposal → goal (decision 23). - const priorProposal = bridgeArmed + const priorProposal = snapshot ? ((yield* hermes.listProposals()).proposals.find( (proposal) => proposal.id === input.proposalId, ) ?? null) : null; + if ( + snapshot !== null && + priorProposal !== null && + priorProposal.actionKind !== "read-only" && + priorProposal.recommendedExecutor !== "open-gsd" + ) { + const repo = input.projectDir ?? priorProposal.projectDir; + const model = input.model === undefined ? priorProposal.model : input.model; + const effectiveModel = model ?? snapshot.policy.defaultModel; + if (repo === null || !snapshot.policy.allowedRepos.includes(repo)) { + return yield* new HermesAdapterError({ + message: "Proposal repository is not allowed by Automode policy.", + }); + } + if ( + effectiveModel !== null && + snapshot.policy.allowedModels.length > 0 && + !snapshot.policy.allowedModels.includes(effectiveModel) + ) { + return yield* new HermesAdapterError({ + message: "Proposal model is not allowed by Automode policy.", + }); + } + } const decided = yield* hermes.decideProposal(input); - if (bridgeArmed && priorProposal?.status !== "approved") { - yield* hermes.draftFromProposal({ proposalId: input.proposalId }).pipe( - Effect.flatMap((draft) => - draft.kind === "delamain-peer" && - draft.status === "draft" && - draft.repo !== null && - !( - priorProposal !== null && - snapshot !== null && - hasLiveGoalForEpisode(snapshot.goals, priorProposal.episodeId) - ) - ? automode - .enqueueGoal({ - title: draft.title, - prompt: draft.prompt, - repo: draft.repo, - origin: "proposal", - ...(priorProposal === null ? {} : { episodeId: priorProposal.episodeId }), - }) - .pipe(Effect.asVoid) - : Effect.void, - ), - Effect.catch((cause) => - Effect.logWarning("hermes→automode auto-enqueue failed after approval", { - proposalId: input.proposalId, - detail: cause.message, - }), - ), - ); + if ( + snapshot !== null && + priorProposal !== null && + !hasLiveGoalForEpisode(snapshot.goals, priorProposal.episodeId) + ) { + const draft = yield* hermes.draftFromProposal({ proposalId: input.proposalId }); + if (draft.kind === "delamain-peer" && draft.status === "draft" && draft.repo !== null) { + yield* automode + .enqueueGoal({ + title: draft.title, + prompt: draft.prompt, + repo: draft.repo, + ...(decided.model === null ? {} : { model: decided.model }), + notBefore: decided.notBefore, + maxRuntimeMinutes: decided.maxRuntimeMinutes, + verificationCommands: decided.verificationCommands, + integrationBranch: decided.integrationBranch, + origin: "proposal", + episodeId: priorProposal.episodeId, + }) + .pipe( + Effect.mapError( + (cause) => + new HermesAdapterError({ message: "Failed to queue approved proposal.", cause }), + ), + ); + } } return decided; - }); + }).pipe( + Effect.mapError((cause) => + cause._tag === "HermesAdapterError" + ? cause + : new HermesAdapterError({ message: "Failed to approve proposal.", cause }), + ), + ); diff --git a/apps/server/src/gits/Layers/HermesCliAdapter.test.ts b/apps/server/src/gits/Layers/HermesCliAdapter.test.ts index fd6f5abc419b..073e9c76be9f 100644 --- a/apps/server/src/gits/Layers/HermesCliAdapter.test.ts +++ b/apps/server/src/gits/Layers/HermesCliAdapter.test.ts @@ -244,6 +244,12 @@ describe("HermesCliAdapter cockpit chat", () => { blockedReason: "Hermes cockpit chat did not complete.", source: "hermes cockpit chat", projectDir: "/tmp/gits", + model: null, + notBefore: null, + maxRuntimeMinutes: null, + verificationCommands: [], + integrationBranch: null, + sourceThreadId: null, decisionReason: null, decidedAt: null, createdAt: "2026-06-03T00:00:00.000Z", @@ -272,6 +278,12 @@ describe("HermesCliAdapter cockpit chat", () => { blockedReason: null, source: "hermes cockpit chat", projectDir: "/tmp/gits", + model: null, + notBefore: null, + maxRuntimeMinutes: null, + verificationCommands: [], + integrationBranch: null, + sourceThreadId: null, decisionReason: null, decidedAt: null, createdAt: "2026-06-03T00:00:00.000Z", @@ -363,6 +375,32 @@ describe("HermesCliAdapter sweep draft derivation", () => { expect(draft.repo).toBe(repo); }); + it("atomically saves proposal edits before approving", async () => { + const { card } = await seed("worktree-spawn"); + const updated = await Effect.runPromise( + decideProposal({ + proposalId: card.id, + decision: "approve", + title: "Edited title", + prompt: "Edited execution prompt", + model: "gpt-5.6-sol", + notBefore: "2026-01-02T00:00:00.000Z", + maxRuntimeMinutes: 45, + verificationCommands: [{ label: "typecheck", cmd: ["bun", "typecheck"] }], + integrationBranch: "auto/edited", + }), + ); + expect(updated).toMatchObject({ + title: "Edited title", + nextCommandOrPrompt: "Edited execution prompt", + model: "gpt-5.6-sol", + notBefore: "2026-01-02T00:00:00.000Z", + maxRuntimeMinutes: 45, + integrationBranch: "auto/edited", + }); + expect(updated.verificationCommands).toHaveLength(1); + }); + it("drafts an approved read-only card as verification (why the sweep must not use read-only)", async () => { const { card } = await seed("read-only"); await Effect.runPromise(decideProposal({ proposalId: card.id, decision: "approve" })); @@ -744,6 +782,14 @@ describe("HermesCliAdapter proposal helpers", () => { it("normalizeProposal backfills a legacy card without episodeId", () => { const normalized = normalizeProposal({ id: "hermes-old", title: "Old card" }); expect(normalized?.episodeId).toBe("epi-legacy-hermes-old"); + expect(normalized).toMatchObject({ + model: null, + notBefore: null, + maxRuntimeMinutes: null, + verificationCommands: [], + integrationBranch: null, + sourceThreadId: null, + }); }); it("normalizeProposal keeps a stored episodeId", () => { diff --git a/apps/server/src/gits/Layers/HermesCliAdapter.ts b/apps/server/src/gits/Layers/HermesCliAdapter.ts index a4a375ab22ad..8287f3ecb32a 100644 --- a/apps/server/src/gits/Layers/HermesCliAdapter.ts +++ b/apps/server/src/gits/Layers/HermesCliAdapter.ts @@ -1107,6 +1107,25 @@ export function normalizeProposal(value: unknown): HermesProposalCard | null { blockedReason: nullableStringFromUnknown(record.blockedReason), source: nullableStringFromUnknown(record.source) ?? "hermes proposal store", projectDir: nullableStringFromUnknown(record.projectDir), + model: nullableStringFromUnknown(record.model), + notBefore: nullableStringFromUnknown(record.notBefore), + maxRuntimeMinutes: + typeof record.maxRuntimeMinutes === "number" && + Number.isInteger(record.maxRuntimeMinutes) && + record.maxRuntimeMinutes >= 0 + ? record.maxRuntimeMinutes + : null, + verificationCommands: Array.isArray(record.verificationCommands) + ? record.verificationCommands.flatMap((command) => { + if (typeof command !== "object" || command === null) return []; + const item = command as Record; + const label = nullableStringFromUnknown(item.label); + const cmd = arrayFromUnknown(item.cmd, []); + return label === null || cmd.length === 0 ? [] : [{ label, cmd }]; + }) + : [], + integrationBranch: nullableStringFromUnknown(record.integrationBranch), + sourceThreadId: nullableStringFromUnknown(record.sourceThreadId), decisionReason: nullableStringFromUnknown(record.decisionReason), decidedAt: nullableStringFromUnknown(record.decidedAt), createdAt: nullableStringFromUnknown(record.createdAt) ?? LEGACY_PROPOSAL_TIMESTAMP, @@ -1240,6 +1259,7 @@ export function makeProposal(input: { readonly recommendedExecutor?: HermesProposalExecutor; readonly verificationPlan?: ReadonlyArray; readonly nextCommandOrPrompt?: string | null; + readonly sourceThreadId?: string; }): HermesProposalCard { const risk = input.blockedReason !== null ? "blocked" : actionRisk(input.actionKind); return { @@ -1267,6 +1287,12 @@ export function makeProposal(input: { blockedReason: input.blockedReason, source: input.source, projectDir: input.projectDir, + model: null, + notBefore: null, + maxRuntimeMinutes: null, + verificationCommands: [], + integrationBranch: null, + sourceThreadId: input.sourceThreadId ?? null, decisionReason: null, decidedAt: null, createdAt: input.now, @@ -1885,6 +1911,7 @@ const makeInspectGitsAndPropose = : `Hermes Codex OAuth chain requires re-login. Run \`${preflight.command}\`.`, source: "hermes chat -q", projectDir: input.projectDir, + ...(input.sourceThreadId === undefined ? {} : { sourceThreadId: input.sourceThreadId }), now, evidence: ["GITS preflight blocked the Hermes spawn before execution.", preflight.reason], }); @@ -1936,6 +1963,7 @@ const makeInspectGitsAndPropose = blockedReason: exec.exitCode === 0 ? null : "Hermes proposal command did not complete.", source: "hermes chat -q", projectDir: input.projectDir, + ...(input.sourceThreadId === undefined ? {} : { sourceThreadId: input.sourceThreadId }), now, evidence: [ "Hermes was invoked in read-only inspection mode.", @@ -2131,6 +2159,20 @@ export const decideProposal: HermesAdapterShape["decideProposal"] = (input) => : (input.reason ?? proposal.blockedReason); const updated: HermesProposalCard = { ...proposal, + title: input.title ?? proposal.title, + nextCommandOrPrompt: input.prompt ?? proposal.nextCommandOrPrompt, + projectDir: input.projectDir ?? proposal.projectDir, + model: input.model === undefined ? proposal.model : input.model, + notBefore: input.notBefore === undefined ? proposal.notBefore : input.notBefore, + maxRuntimeMinutes: + input.maxRuntimeMinutes === undefined + ? proposal.maxRuntimeMinutes + : input.maxRuntimeMinutes, + verificationCommands: input.verificationCommands ?? proposal.verificationCommands, + integrationBranch: + input.integrationBranch === undefined + ? proposal.integrationBranch + : input.integrationBranch, status: nextStatus, blockedReason, decisionReason: input.reason ?? null, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ad575e1ef7be..47bece4bbcbc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -140,6 +140,7 @@ import { type GitsSlotSchedulerShape, } from "./gits/Services/GitsSlotScheduler.ts"; import { HermesAdapter, type HermesAdapterShape } from "./gits/Services/HermesAdapter.ts"; +import { AutomodeNotifications } from "./gits/Layers/AutomodeNotifications.ts"; import { setVisualPlanState } from "./gits/mcp/VisualPlanMcpRegistry.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; @@ -248,6 +249,10 @@ const defaultAutomodeGoal: AutomodeGoal = { prompt: "Spawn a safe test peer.", repo: "/tmp/source-repo", model: "gpt-5.5", + notBefore: null, + maxRuntimeMinutes: null, + verificationCommands: [], + integrationBranch: null, status: "queued", peerId: null, blockedReason: null, @@ -279,6 +284,8 @@ const defaultAutomodeSnapshot: AutomodeSnapshot = { integrationBranch: null, motokoAuthority: "observe", telegramDigestEnabled: true, + gitsNotificationsEnabled: true, + telegramNotificationsEnabled: false, sweepRequiresConfirmation: true, updatedAt: "2026-01-01T00:00:00.000Z", }, @@ -492,6 +499,12 @@ const defaultHermesProposal: HermesProposalCard = { blockedReason: null, source: "test", projectDir: null, + model: null, + notBefore: null, + maxRuntimeMinutes: null, + verificationCommands: [], + integrationBranch: null, + sourceThreadId: null, decisionReason: null, decidedAt: null, createdAt: "1970-01-01T00:00:00.000Z", @@ -1216,6 +1229,7 @@ const buildAppUnderTest = (options?: { }), ...options?.layers?.hermesAdapter, }), + Layer.mock(AutomodeNotifications)({ notify: () => Effect.void }), ); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { @@ -5519,7 +5533,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "devCommandsInit:/tmp/default-project", "inspect:/tmp/default-project", "chat:Plan next action", + "proposals", "decide:proposal-test:approve", + "draft:proposal-test", "context:/tmp/default-project", "draft:proposal-test", "schedule:daily-briefing:/tmp/default-project", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0af853756ca9..a910b3754002 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -81,6 +81,7 @@ import { AutomodeSupervisorLive } from "./gits/Layers/AutomodeSupervisor.ts"; import { AutomodeUsageMeterLive } from "./gits/Layers/AutomodeUsageMeter.ts"; import { AutomodeDriverLive } from "./gits/Layers/AutomodeDriver.ts"; import { AutomodeProposalSweepLive } from "./gits/Layers/AutomodeProposalSweep.ts"; +import { AutomodeNotificationsLive } from "./gits/Layers/AutomodeNotifications.ts"; import { AutomodeTelegramDigestLive } from "./gits/Layers/AutomodeTelegramDigest.ts"; import { GitsSlotSchedulerLive } from "./gits/Layers/GitsSlotScheduler.ts"; import { HermesTelegramNotifierLive } from "./gits/Layers/HermesTelegramNotifier.ts"; @@ -118,6 +119,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -344,6 +346,13 @@ const GitsSlotSchedulerLayerLive = GitsSlotSchedulerLive.pipe( ), ); +const AutomodeNotificationsLayerLive = AutomodeNotificationsLive.pipe( + Layer.provide(HermesTelegramNotifierLive), + Layer.provide( + PushNotificationLayerLive.pipe(Layer.provide(WebPushSubscriptionRepositoryLayerLive)), + ), +); + const AutomodeEpisodeLedgerLayerLive = AutomodeEpisodeLedgerLive.pipe( Layer.provide(PersistenceLayerLive), ); @@ -366,6 +375,8 @@ const HermesAdapterLayerLive = HermesCliAdapterLive.pipe( const AutomodeProposalSweepLayerLive = AutomodeProposalSweepLive.pipe( Layer.provide(AutomodeSupervisorLayerLive), Layer.provide(HermesAdapterLayerLive), + Layer.provide(AutomodeNotificationsLayerLive), + Layer.provide(OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provide(PersistenceLayerLive))), ); const AutomodeDriverLayerLive = AutomodeDriverLive.pipe( @@ -378,6 +389,7 @@ const AutomodeDriverLayerLive = AutomodeDriverLive.pipe( Layer.provide(AutomodeTelegramDigestLayerLive), Layer.provide(AutomodeProposalSweepLayerLive), Layer.provide(HermesTelegramNotifierLive), + Layer.provide(AutomodeNotificationsLayerLive), Layer.provide( GitsReviewPipelineLive.pipe( Layer.provide(GitsCodexVerifierAdapterLive), @@ -413,7 +425,7 @@ const GitsLayerLive = Layer.empty.pipe( Layer.provideMerge(HermesAdapterLayerLive), Layer.provideMerge(AutomodeSupervisorLayerLive), Layer.provideMerge(GitsSlotSchedulerLayerLive), - Layer.provideMerge(AutomodeDriverLayerLive), + Layer.provideMerge(Layer.merge(AutomodeDriverLayerLive, AutomodeNotificationsLayerLive)), // Exposed directly (not just as AutomodeDriverLayerLive's internal dependency) so the ws.ts // RPC layer can `yield* AutomodeEpisodeLedger` for gits.automode.episodes.list. Layer.provideMerge(AutomodeEpisodeLedgerLayerLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ab04f1026381..e537bd52cbd0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -105,7 +105,11 @@ import { HermesAdapter } from "./gits/Services/HermesAdapter.ts"; import { OpenGsdAdapter } from "./gits/Services/OpenGsdAdapter.ts"; import { AutomodeSupervisor } from "./gits/Services/AutomodeSupervisor.ts"; import { AutomodeEpisodeLedger } from "./persistence/Services/AutomodeEpisodeLedger.ts"; -import { decideProposalWithAutomodeBridge } from "./gits/Layers/HermesAutomodeBridge.ts"; +import { + decideProposalWithAutomodeBridge, + hasLiveGoalForEpisode, +} from "./gits/Layers/HermesAutomodeBridge.ts"; +import { AutomodeNotifications } from "./gits/Layers/AutomodeNotifications.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { denyThreadAccess, @@ -425,6 +429,23 @@ const makeWsRpcLayer = ( const openGsdAdapter = yield* OpenGsdAdapter; const automodeSupervisor = yield* AutomodeSupervisor; const automodeEpisodeLedger = yield* AutomodeEpisodeLedger; + const automodeNotifications = yield* AutomodeNotifications; + const notifyProposal = (proposal: { readonly id: string; readonly title: string }) => + Effect.gen(function* () { + const policy = yield* automodeSupervisor.getPolicy(); + yield* automodeNotifications.notify({ + key: `proposal:${proposal.id}:created`, + subject: "New Motoko proposal", + text: proposal.title, + url: `/gits?panel=autopilot&proposal=${encodeURIComponent(proposal.id)}`, + gitsEnabled: policy.gitsNotificationsEnabled, + telegramEnabled: policy.telegramNotificationsEnabled, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("gits.proposal.notification-failed", { cause }), + ), + ); // R#1 (kill-switch-only manual gate): manual peer actions stay human-driven, but the // global automode kill switch also freezes them. ponytail: reuse DelamainAdapterError // (these RPC channels already carry it) so no rpc.ts/client contract change is needed. @@ -2324,17 +2345,45 @@ const makeWsRpcLayer = ( [WS_METHODS.gitsHermesInspectGits]: (input) => observeRpcEffect( WS_METHODS.gitsHermesInspectGits, - hermesAdapter.inspectGitsAndPropose(input), + hermesAdapter.inspectGitsAndPropose(input).pipe(Effect.tap(notifyProposal)), { "rpc.aggregate": "gits" }, ), [WS_METHODS.gitsHermesChat]: (input) => - observeRpcEffect(WS_METHODS.gitsHermesChat, hermesAdapter.chat(input), { - "rpc.aggregate": "gits", - }), + observeRpcEffect( + WS_METHODS.gitsHermesChat, + hermesAdapter + .chat(input) + .pipe( + Effect.tap((result) => + result.proposal === null ? Effect.void : notifyProposal(result.proposal), + ), + ), + { "rpc.aggregate": "gits" }, + ), [WS_METHODS.gitsHermesDecideProposal]: (input) => observeRpcEffect( WS_METHODS.gitsHermesDecideProposal, - decideProposalWithAutomodeBridge(hermesAdapter, automodeSupervisor, input), + decideProposalWithAutomodeBridge(hermesAdapter, automodeSupervisor, input).pipe( + Effect.tap((proposal) => + input.decision !== "approve" + ? Effect.void + : automodeSupervisor.getSnapshot().pipe( + Effect.flatMap((snapshot) => + hasLiveGoalForEpisode(snapshot.goals, proposal.episodeId) + ? automodeNotifications.notify({ + key: `proposal:${proposal.id}:queued`, + subject: "Motoko proposal queued", + text: proposal.title, + url: `/gits?panel=autopilot&proposal=${encodeURIComponent(proposal.id)}`, + gitsEnabled: snapshot.policy.gitsNotificationsEnabled, + telegramEnabled: snapshot.policy.telegramNotificationsEnabled, + }) + : Effect.void, + ), + Effect.catchCause(() => Effect.void), + ), + ), + ), { "rpc.aggregate": "gits" }, ), [WS_METHODS.gitsHermesWriteProjectContext]: (input) => @@ -2350,9 +2399,13 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "gits" }, ), [WS_METHODS.gitsHermesRunSchedule]: (input) => - observeRpcEffect(WS_METHODS.gitsHermesRunSchedule, hermesAdapter.runSchedule(input), { - "rpc.aggregate": "gits", - }), + observeRpcEffect( + WS_METHODS.gitsHermesRunSchedule, + hermesAdapter + .runSchedule(input) + .pipe(Effect.tap((result) => Effect.forEach(result.proposals, notifyProposal))), + { "rpc.aggregate": "gits" }, + ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/gits/GitsCockpit.tsx b/apps/web/src/components/gits/GitsCockpit.tsx index ac9f5e89b656..ca8d8701a5b3 100644 --- a/apps/web/src/components/gits/GitsCockpit.tsx +++ b/apps/web/src/components/gits/GitsCockpit.tsx @@ -62,15 +62,19 @@ import { saveMotokoTranscripts, type MotokoInteractionMode, type MotokoProposalDecision, + type MotokoProposalEdits, type MotokoTranscriptEntry, type MotokoTranscriptState, } from "./cockpit/MotokoPanel"; import { handleDevOpenPreview } from "./cockpit/DevPanel"; import { useGitsCockpitQueries } from "./cockpit/useGitsCockpitQueries"; import { useDevCommandSessions } from "./cockpit/useDevCommandSessions"; +import { readCockpitDeepLink } from "./cockpit/motoko/motoko.logic"; export function GitsCockpit() { - const [activeTab, setActiveTab] = useState("overview"); + const deepLink = readCockpitDeepLink(typeof window === "undefined" ? "" : window.location.search); + const [activeTab, setActiveTab] = useState(deepLink.panel); + const [focusedProposalId] = useState(deepLink.proposalId); const [selectedPeerId, setSelectedPeerId] = useState(null); const [spawnRepo, setSpawnRepo] = useState(""); const [spawnName, setSpawnName] = useState(""); @@ -400,11 +404,13 @@ export function GitsCockpit() { decision: MotokoProposalDecision; routeKey: string; title: string; + edits: MotokoProposalEdits; }) => { const client = readGitsClient(); const decided = await client.hermes.decideProposal({ proposalId: input.proposalId, decision: input.decision, + ...input.edits, }); if (input.decision !== "approve" || decided.status !== "approved") { return { decided, draft: null }; @@ -924,12 +930,14 @@ export function GitsCockpit() { setMotokoChatInput(""); hermesChatMutation.reset(); }} - onDecision={(proposal, decision) => + focusedProposalId={focusedProposalId} + onDecision={(proposal, decision, edits) => void hermesDecisionMutation.mutate({ proposalId: proposal.id, decision, routeKey: motokoRoute, title: proposal.title, + edits, }) } onWriteContext={() => void hermesContextMutation.mutate()} @@ -951,6 +959,17 @@ export function GitsCockpit() { goalModel={automodeGoalModel} goalPrompt={automodeGoalPrompt} projects={query.data?.projects} + proposals={hermesProposalsQuery.data} + focusedProposalId={focusedProposalId} + onProposalDecision={(proposal, decision, edits) => + void hermesDecisionMutation.mutate({ + proposalId: proposal.id, + decision, + routeKey: motokoRoute, + title: proposal.title, + edits, + }) + } onRefresh={() => void automodeQuery.refetch()} onKillSwitchChange={(next) => void automodeKillSwitchMutation.mutate(next)} onGoalTitleChange={setAutomodeGoalTitle} diff --git a/apps/web/src/components/gits/cockpit/AutopilotPanel.tsx b/apps/web/src/components/gits/cockpit/AutopilotPanel.tsx index 6c83aae4b577..bb98fb2e1d42 100644 --- a/apps/web/src/components/gits/cockpit/AutopilotPanel.tsx +++ b/apps/web/src/components/gits/cockpit/AutopilotPanel.tsx @@ -8,6 +8,8 @@ import type { CodexModelTier, GitsCockpitProject, GitsSchedulerSnapshot, + HermesProposalCard, + HermesProposalListResult, MotokoAuthority, } from "@t3tools/contracts"; import { CODEX_MODEL_TIER_LABELS, CODEX_MODEL_TIERS } from "@t3tools/contracts"; @@ -50,6 +52,11 @@ import { ComboboxPopup, } from "~/components/ui/combobox"; import { Input } from "~/components/ui/input"; +import { + MotokoProposalReview, + type MotokoProposalDecision, + type MotokoProposalEdits, +} from "./MotokoPanel"; import { Select, SelectGroup, @@ -125,6 +132,8 @@ const SWITCHBOARD_FIELDS: ReadonlyArray<{ | "sweepRequiresConfirmation" | "autoEnqueueApprovedProposals" | "telegramDigestEnabled" + | "gitsNotificationsEnabled" + | "telegramNotificationsEnabled" >; label: string; description: string; @@ -145,6 +154,16 @@ const SWITCHBOARD_FIELDS: ReadonlyArray<{ description: "Approved Motoko proposals join the goal queue automatically (autonomous mode only).", }, + { + field: "gitsNotificationsEnabled", + label: "GITS/PWA notifications", + description: "Notify this device about proposals and Automode attention states.", + }, + { + field: "telegramNotificationsEnabled", + label: "Telegram notifications", + description: "Send proposal and Automode attention notifications through Telegram.", + }, { field: "telegramDigestEnabled", label: "Telegram digest", @@ -276,6 +295,9 @@ export function AutopilotPanel({ goalModel, goalPrompt, projects, + proposals, + focusedProposalId, + onProposalDecision, onRefresh, onKillSwitchChange, onGoalTitleChange, @@ -305,6 +327,13 @@ export function AutopilotPanel({ /** Not sent by the shell today (only Motoko/GSD panels get it) — falls back to a * textarea for repo entry until `GitsCockpit.tsx` is updated to pass it. */ projects?: ReadonlyArray; + proposals: HermesProposalListResult | undefined; + focusedProposalId: string | null; + onProposalDecision: ( + proposal: HermesProposalCard, + decision: MotokoProposalDecision, + edits: MotokoProposalEdits, + ) => void; onRefresh: () => void; onKillSwitchChange: (value: boolean) => void; onGoalTitleChange: (value: string) => void; @@ -466,6 +495,12 @@ export function AutopilotPanel({ return (
+
diff --git a/apps/web/src/components/gits/cockpit/MotokoPanel.tsx b/apps/web/src/components/gits/cockpit/MotokoPanel.tsx index 77d9481aa1e6..4ed08aee4983 100644 --- a/apps/web/src/components/gits/cockpit/MotokoPanel.tsx +++ b/apps/web/src/components/gits/cockpit/MotokoPanel.tsx @@ -1,12 +1,14 @@ import type { GitsCapacitySnapshot, GitsCockpitProject, + GitsVerifyCommand, HermesChatResult, HermesCommandResult, HermesExecutionDraft, HermesLogTailResult, HermesProposalCard, HermesProposalListResult, + HermesProposalDecisionInput, HermesScheduleKind, HermesScheduleRunResult, HermesSessionListResult, @@ -38,6 +40,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; import { Separator } from "~/components/ui/separator"; import { Select, @@ -57,6 +60,7 @@ import { SheetTitle, } from "~/components/ui/sheet"; import { Spinner } from "~/components/ui/spinner"; +import { Textarea } from "~/components/ui/textarea"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { ComposerVoiceButton } from "~/components/chat/ComposerVoiceButton"; import { useVoiceTranscription } from "~/hooks/useVoiceTranscription"; @@ -158,6 +162,32 @@ export function motokoSelectedRouteLabel(selectedProjectRoot: string): string { export const EMPTY_MOTOKO_TRANSCRIPT: ReadonlyArray = []; export type MotokoProposalDecision = "approve" | "reject" | "defer"; +export type MotokoProposalEdits = Omit< + HermesProposalDecisionInput, + "proposalId" | "decision" | "reason" +>; + +function parseVerificationCommands(value: string): ReadonlyArray | null { + try { + const parsed = JSON.parse(value) as unknown; + if ( + !Array.isArray(parsed) || + !parsed.every( + (item) => + isRecord(item) && + typeof item.label === "string" && + Array.isArray(item.cmd) && + item.cmd.every((part) => typeof part === "string") && + (item.timeoutSeconds === undefined || typeof item.timeoutSeconds === "number"), + ) + ) { + return null; + } + return parsed as GitsVerifyCommand[]; + } catch { + return null; + } +} const MOTOKO_TRANSCRIPTS_STORAGE_KEY = "gits:motoko:transcripts:v1"; const MOTOKO_TRANSCRIPT_PERSIST_LIMIT = 200; @@ -289,10 +319,37 @@ function MotokoProposalCard({ }: { proposal: HermesProposalCard; actionPending: boolean; - onDecision: (proposal: HermesProposalCard, decision: MotokoProposalDecision) => void; + onDecision: ( + proposal: HermesProposalCard, + decision: MotokoProposalDecision, + edits: MotokoProposalEdits, + ) => void; onDraft: (proposalId: string) => void; compact?: boolean; }) { + const [title, setTitle] = useState(proposal.title); + const [prompt, setPrompt] = useState(proposal.nextCommandOrPrompt ?? ""); + const [repo, setRepo] = useState(proposal.projectDir ?? ""); + const [model, setModel] = useState(proposal.model ?? ""); + const [notBefore, setNotBefore] = useState(proposal.notBefore?.slice(0, 16) ?? ""); + const [runtime, setRuntime] = useState( + proposal.maxRuntimeMinutes === null ? "" : String(proposal.maxRuntimeMinutes), + ); + const [integrationBranch, setIntegrationBranch] = useState(proposal.integrationBranch ?? ""); + const [verificationCommandsJson, setVerificationCommandsJson] = useState( + JSON.stringify(proposal.verificationCommands, null, 2), + ); + const verificationCommands = parseVerificationCommands(verificationCommandsJson); + const edits = (): MotokoProposalEdits => ({ + title: title.trim(), + prompt, + projectDir: repo.trim(), + model: model.trim() || null, + notBefore: notBefore ? new Date(notBefore).toISOString() : null, + maxRuntimeMinutes: runtime ? Number(runtime) : null, + verificationCommands: verificationCommands ?? proposal.verificationCommands, + integrationBranch: integrationBranch.trim() || null, + }); const draftKind = motokoProposalDraftKind(proposal); const canApprove = proposal.status === "proposed" || @@ -309,6 +366,7 @@ function MotokoProposalCard({ proposal.scope.length > 0); return (
{proposal.summary}

+ {!compact && canApprove ? ( +
+ setTitle(event.target.value)} + aria-label="Title" + /> +