diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 1e7ebe0d83..6c86cd3948 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -593,6 +593,16 @@ export { type ResultChangedFile, type ResultsPayload, } from "./results-payload.js"; +export { + buildProgressSnapshot, + progressChanged, + MAX_PROGRESS_ACTIVITY, + type LoopPhase, + type LoopProgressActivity, + type LoopProgressState, + type LoopRunStatus, + type ProgressSnapshot, +} from "./loop-progress.js"; export { buildMetadataRankInput, computeMetadataDupRisk, diff --git a/packages/loopover-engine/src/loop-progress.ts b/packages/loopover-engine/src/loop-progress.ts new file mode 100644 index 0000000000..6efe71816b --- /dev/null +++ b/packages/loopover-engine/src/loop-progress.ts @@ -0,0 +1,67 @@ +// Loop progress model (pure) — the near-real-time progress a customer watches while their rented loop runs +// (#4800, part of the Rent-a-Loop path #4778). This owns the DETERMINISTIC brain of the stream: it builds a +// progress snapshot from already-computed loop state, and decides when the snapshot has meaningfully changed +// so a customer-facing surface (#4807) can push ON CHANGE rather than poll on a fixed interval. No IO, no +// transport — a plain in/out transform, mirroring the intake bridge (#4798) and results composer (#4801). + +// Cap the streamed activity tail so a long run never floods the surface; the loop's full log lives elsewhere. +export const MAX_PROGRESS_ACTIVITY = 10; + +export type LoopPhase = "queued" | "claiming" | "coding" | "reviewing" | "submitting" | "done"; +export type LoopRunStatus = "running" | "converged" | "abandoned" | "error"; + +export type LoopProgressActivity = { + step: string; + detail?: string | undefined; + at?: string | undefined; +}; + +/** The already-computed state of one running loop, the input to a progress snapshot. */ +export type LoopProgressState = { + iteration: number; + maxIterations?: number | null | undefined; + phase: LoopPhase; + status: LoopRunStatus; + recentActivity?: LoopProgressActivity[] | undefined; +}; + +export type ProgressSnapshot = { + phase: LoopPhase; + status: LoopRunStatus; + iteration: number; + maxIterations: number | null; + /** Progress through the iteration budget (0-100), or null when the budget is unknown. */ + percentComplete: number | null; + /** The most recent activity, newest last, capped at {@link MAX_PROGRESS_ACTIVITY}. */ + recentActivity: LoopProgressActivity[]; + done: boolean; +}; + +/** Build a customer-facing progress snapshot from already-computed loop state (#4800). Pure. */ +export function buildProgressSnapshot(state: LoopProgressState): ProgressSnapshot { + const maxIterations = state.maxIterations ?? null; + const percentComplete = + maxIterations !== null && maxIterations > 0 ? Math.min(100, Math.round((state.iteration / maxIterations) * 100)) : null; + return { + phase: state.phase, + status: state.status, + iteration: state.iteration, + maxIterations, + percentComplete, + recentActivity: (state.recentActivity ?? []).slice(-MAX_PROGRESS_ACTIVITY), + done: state.status !== "running", + }; +} + +/** True when `next` differs from `prev` in a way worth pushing to the customer — so the surface streams + * ON CHANGE instead of polling on a fixed interval (#4800's acceptance). A null `prev` (the first snapshot) + * always pushes. Compares the displayed axes: phase, status, iteration, and the activity tail's length. */ +export function progressChanged(prev: ProgressSnapshot | null, next: ProgressSnapshot): boolean { + if (prev === null) return true; + return ( + prev.phase !== next.phase || + prev.status !== next.status || + prev.iteration !== next.iteration || + prev.recentActivity.length !== next.recentActivity.length + ); +} diff --git a/src/loop-progress.ts b/src/loop-progress.ts new file mode 100644 index 0000000000..5fa782f5fa --- /dev/null +++ b/src/loop-progress.ts @@ -0,0 +1,5 @@ +// Loop progress model (#4800) — thin re-export shim. The canonical implementation lives in +// `@loopover/engine` (packages/loopover-engine/src/loop-progress.ts), imported via the relative source +// path (matching src/results-payload.ts / src/idea-intake.ts) so the published loopover-mcp / loopover-miner +// CLIs share one model, and so this never depends on the engine's built dist/ during typecheck/test. +export * from "../packages/loopover-engine/src/loop-progress"; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6b0c69514b..92faccbb07 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -157,6 +157,7 @@ import { buildIssueSlopAssessment } from "../signals/issue-slop"; import { buildSlopAssessment } from "../signals/slop"; import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; import { buildResultsPayload } from "../results-payload"; +import { buildProgressSnapshot } from "../loop-progress"; import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; import { buildRepoDataQuality } from "../signals/data-quality"; @@ -976,6 +977,28 @@ const buildResultsPayloadOutputSchema = { totals: z.unknown().optional(), }; +// Loop progress-snapshot input (#4800): a running loop's already-computed state. +const buildProgressSnapshotShape = { + iteration: z.number().int(), + maxIterations: z.number().int().nullable().optional(), + phase: z.enum(["queued", "claiming", "coding", "reviewing", "submitting", "done"]), + status: z.enum(["running", "converged", "abandoned", "error"]), + recentActivity: z + .array(z.object({ step: z.string(), detail: z.string().optional(), at: z.string().optional() })) + .max(1000) + .optional(), +}; + +const buildProgressSnapshotOutputSchema = { + phase: z.string().optional(), + status: z.string().optional(), + iteration: z.number().optional(), + maxIterations: z.number().nullable().optional(), + percentComplete: z.number().nullable().optional(), + recentActivity: z.unknown().optional(), + done: z.boolean().optional(), +}; + // Deterministic structural-improvement counterpart to checkSlopRiskShape (#4746, sub-issue I of epic #4737): // the positive-axis mirror of checkSlopRisk, same pure local-metadata contract. changedFiles/tests/testFiles // are reused verbatim (same shape as checkSlopRiskShape) so the two signals never disagree about what counts @@ -1759,6 +1782,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.buildLoopResults(input)), ); + server.registerTool( + "loopover_build_progress_snapshot", + { + description: + "Build a near-real-time progress snapshot for a running rented loop (#4800): phase, status, iteration/percent-complete, and a bounded recent-activity tail, from already-computed loop state. Deterministic and source-free; a customer surface pushes it on change (via the engine's progressChanged) rather than polling on a fixed interval.", + inputSchema: buildProgressSnapshotShape, + outputSchema: buildProgressSnapshotOutputSchema, + }, + async (input) => this.toolResult(await this.buildLoopProgress(input)), + ); + server.registerTool( "loopover_check_slop_risk", { @@ -3072,6 +3106,15 @@ export class LoopoverMcp { }; } + private async buildLoopProgress(input: z.infer>): Promise { + await this.enforceToolRateLimit("loopover_build_progress_snapshot"); + const snapshot = buildProgressSnapshot(input); + return { + summary: `Loop progress: ${snapshot.phase} (${snapshot.status}), iteration ${snapshot.iteration}.`, + data: snapshot as unknown as Record, + }; + } + private async checkSlopRisk(input: z.infer>): Promise { await this.enforceToolRateLimit("loopover_check_slop_risk"); const assessment = buildSlopAssessment(input); diff --git a/test/unit/loop-progress.test.ts b/test/unit/loop-progress.test.ts new file mode 100644 index 0000000000..4b89cf0535 --- /dev/null +++ b/test/unit/loop-progress.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + buildProgressSnapshot, + progressChanged, + MAX_PROGRESS_ACTIVITY, + type LoopProgressState, +} from "../../packages/loopover-engine/src/loop-progress"; + +function running(overrides: Partial = {}): LoopProgressState { + return { iteration: 2, maxIterations: 5, phase: "coding", status: "running", ...overrides }; +} + +describe("buildProgressSnapshot (#4800)", () => { + it("builds a snapshot with percent-complete from the iteration budget", () => { + const s = buildProgressSnapshot(running({ recentActivity: [{ step: "claimed" }, { step: "coding" }] })); + expect(s).toMatchObject({ phase: "coding", status: "running", iteration: 2, maxIterations: 5, percentComplete: 40, done: false }); + expect(s.recentActivity).toHaveLength(2); + }); + + it("leaves percent-complete null when the iteration budget is unknown", () => { + expect(buildProgressSnapshot(running({ maxIterations: undefined })).percentComplete).toBeNull(); + expect(buildProgressSnapshot(running({ maxIterations: null })).maxIterations).toBeNull(); + expect(buildProgressSnapshot(running({ maxIterations: 0 })).percentComplete).toBeNull(); // 0 is not > 0 + }); + + it("caps percent-complete at 100 when iteration exceeds the budget", () => { + expect(buildProgressSnapshot(running({ iteration: 7, maxIterations: 5 })).percentComplete).toBe(100); + }); + + it("defaults recent activity to empty and caps the tail at MAX_PROGRESS_ACTIVITY", () => { + expect(buildProgressSnapshot(running()).recentActivity).toEqual([]); // omitted + const many = Array.from({ length: MAX_PROGRESS_ACTIVITY + 4 }, (_, i) => ({ step: `s${i}` })); + const s = buildProgressSnapshot(running({ recentActivity: many })); + expect(s.recentActivity).toHaveLength(MAX_PROGRESS_ACTIVITY); + expect(s.recentActivity.at(-1)?.step).toBe(`s${MAX_PROGRESS_ACTIVITY + 3}`); // newest kept + }); + + it("marks the loop done once its status is no longer running", () => { + expect(buildProgressSnapshot(running({ status: "converged" })).done).toBe(true); + expect(buildProgressSnapshot(running({ status: "running" })).done).toBe(false); + }); +}); + +describe("progressChanged — push on change, not on a fixed interval (#4800)", () => { + const base = buildProgressSnapshot(running({ recentActivity: [{ step: "a" }] })); + + it("always pushes the first snapshot (no prior)", () => { + expect(progressChanged(null, base)).toBe(true); + }); + + it("pushes when phase, status, iteration, or the activity tail changes", () => { + expect(progressChanged(base, buildProgressSnapshot(running({ phase: "reviewing", recentActivity: [{ step: "a" }] })))).toBe(true); + expect(progressChanged(base, buildProgressSnapshot(running({ status: "converged", recentActivity: [{ step: "a" }] })))).toBe(true); + expect(progressChanged(base, buildProgressSnapshot(running({ iteration: 3, recentActivity: [{ step: "a" }] })))).toBe(true); + expect(progressChanged(base, buildProgressSnapshot(running({ recentActivity: [{ step: "a" }, { step: "b" }] })))).toBe(true); + }); + + it("does not push when nothing displayed has changed", () => { + expect(progressChanged(base, buildProgressSnapshot(running({ recentActivity: [{ step: "a" }] })))).toBe(false); + }); +}); diff --git a/test/unit/mcp-loop-progress.test.ts b/test/unit/mcp-loop-progress.test.ts new file mode 100644 index 0000000000..161681ce67 --- /dev/null +++ b/test/unit/mcp-loop-progress.test.ts @@ -0,0 +1,42 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function connect() { + const server = new LoopoverMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-progress-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP loopover_build_progress_snapshot", () => { + it("builds a progress snapshot for a running loop", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "loopover_build_progress_snapshot", + arguments: { + iteration: 2, maxIterations: 5, phase: "coding", status: "running", + recentActivity: [{ step: "claimed issue-1" }, { step: "editing src/upload.ts" }], + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { phase: string; status: string; iteration: number; percentComplete: number; done: boolean; recentActivity: unknown[] }; + expect(data).toMatchObject({ phase: "coding", status: "running", iteration: 2, percentComplete: 40, done: false }); + expect(data.recentActivity).toHaveLength(2); + }); + + it("marks a finished loop done", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "loopover_build_progress_snapshot", + arguments: { iteration: 3, phase: "done", status: "converged" }, + }); + const data = result.structuredContent as { done: boolean; percentComplete: number | null }; + expect(data.done).toBe(true); + expect(data.percentComplete).toBeNull(); // no maxIterations given + }); +});