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
10 changes: 10 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions packages/loopover-engine/src/loop-progress.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
5 changes: 5 additions & 0 deletions src/loop-progress.ts
Original file line number Diff line number Diff line change
@@ -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";
43 changes: 43 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -3072,6 +3106,15 @@ export class LoopoverMcp {
};
}

private async buildLoopProgress(input: z.infer<z.ZodObject<typeof buildProgressSnapshotShape>>): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async checkSlopRisk(input: z.infer<z.ZodObject<typeof checkSlopRiskShape>>): Promise<ToolPayload> {
await this.enforceToolRateLimit("loopover_check_slop_risk");
const assessment = buildSlopAssessment(input);
Expand Down
61 changes: 61 additions & 0 deletions test/unit/loop-progress.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
42 changes: 42 additions & 0 deletions test/unit/mcp-loop-progress.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});