fix: readiness wave 2 — repo_health honesty, durable rate limits, dashboard cold start, email truth, dead code indexes #5608
6 changed files+226−47
Modifiedsrc/git/repository.ts+11−5View fileUnifiedSplit
@@ -1307,14 +1307,20 @@ export async function getTreeRecursive(
13071307 owner: string,
13081308 name: string,
13091309 ref: string,
1310 maxEntries = 50_000
1310 maxEntries = 50_000,
1311 subPath = ""
13111312): Promise<RecursiveTreeResult | null> {
13121313 const path = repoPath(owner, name);
13131314 // `-t` includes tree entries; `-l` adds size column for blobs.
1314 const { stdout, exitCode } = await exec(
1315 ["git", "ls-tree", "-r", "-t", "-l", "--full-tree", ref],
1316 { cwd: path }
1317 );
1315 //
1316 // `subPath` is a git pathspec, applied by git rather than by filtering the
1317 // output here: listing a subtree used to read the ENTIRE repository and
1318 // hand it all back, so asking for one directory of a 4,226-entry repo
1319 // returned 4,226 entries (~800KB) of which 9 were the ones requested.
1320 // Filtering after the fact would fix the answer and keep the cost.
1321 const cmd = ["git", "ls-tree", "-r", "-t", "-l", "--full-tree", ref];
1322 if (subPath) cmd.push(subPath.replace(/^\/+|\/+$/g, ""));
1323 const { stdout, exitCode } = await exec(cmd, { cwd: path });
13181324 if (exitCode !== 0) return null;
13191325
13201326 const lines = stdout.split("\n").filter(Boolean);
Modifiedsrc/lib/agent-multiplayer.ts+21−3View fileUnifiedSplit
@@ -373,10 +373,26 @@ export async function chargeAgent(
373373}
374374
375375/** Convenience getter — returns 0 spent / 0 cap when the session is missing. */
376/**
377 * Spend for an agent session.
378 *
379 * `status` distinguishes the three cases the numbers alone cannot. All three
380 * used to return `{spent: 0, cap: 0, remaining: 0}`: a session that exists
381 * and has spent nothing, a session id that does not exist (a typo), and a
382 * database fault. A budget guard whose "you have no budget" answer is
383 * identical to its "I could not look" answer cannot fail loud — and this one
384 * answered a garbage id like `"!!!!!not-a-uuid!!!!!"` with a well-formed
385 * spend report.
386 *
387 * The numeric fields keep their shape and meaning, so existing callers that
388 * only read `remaining` are unaffected; `status` is additive and lets a
389 * caller refuse to treat a lookup failure as a zero balance.
390 */
376391export async function getAgentUsage(agentSessionId: string): Promise<{
377392 spent: number;
378393 cap: number;
379394 remaining: number;
395 status: "ok" | "not_found" | "error";
380396}> {
381397 try {
382398 const [row] = await db
@@ -387,14 +403,16 @@ export async function getAgentUsage(agentSessionId: string): Promise<{
387403 .from(agentSessions)
388404 .where(eq(agentSessions.id, agentSessionId))
389405 .limit(1);
390 if (!row) return { spent: 0, cap: 0, remaining: 0 };
406 if (!row) return { spent: 0, cap: 0, remaining: 0, status: "not_found" };
391407 return {
392408 spent: row.spent,
393409 cap: row.cap,
394410 remaining: Math.max(0, row.cap - row.spent),
411 status: "ok",
395412 };
396 } catch {
397 return { spent: 0, cap: 0, remaining: 0 };
413 } catch (err) {
414 console.error("[agent-multiplayer] getAgentUsage:", err);
415 return { spent: 0, cap: 0, remaining: 0, status: "error" };
398416 }
399417}
400418
Addedsrc/lib/mcp-refs.ts+57−0View fileUnifiedSplit
@@ -0,0 +1,57 @@
1/**
2 * Ref resolution shared by both MCP tool surfaces.
3 *
4 * Lives in its own module because `mcp-tools.ts` imports `mcp-tools-expanded.ts`
5 * to merge the two tool sets, so the resolver cannot live in either without a
6 * cycle — and it MUST be shared. The defect this module exists to prevent is a
7 * ref that quietly resolves to something else, and two independent copies of
8 * that logic is exactly how one surface ends up lenient again.
9 */
10import { McpError, ERR_METHOD_NOT_FOUND } from "./mcp";
11import { getDefaultBranch, listBranches, resolveRef } from "../git/repository";
12
13/**
14 * Resolve the ref the caller actually meant, or throw.
15 *
16 * Two behaviours, and the difference between them is the whole point:
17 *
18 * - An empty ref means "the repository's default branch", read from HEAD.
19 * A repo's default is not always "main" — Vapron's is "Main" — so this is
20 * never hardcoded.
21 * - A ref that differs only in CASE from a real branch is corrected to the
22 * real one, because git refs are case-sensitive and "@main" on a "Main"
23 * repo is a typo, not a different request.
24 *
25 * Anything else THROWS. It previously returned the default branch, so asking
26 * for a branch, tag or commit that does not exist returned default-branch
27 * content under the caller's ref — byte-identical to a correct answer, with
28 * nothing in the payload to say a substitution had happened. An agent
29 * reviewing a PR branch, a tag or a pinned commit read the default branch
30 * instead and could not detect it. Wrong answers are worse than failures:
31 * a failure gets retried, a wrong answer gets acted on.
32 */
33export async function resolveReadRefOrThrow(
34 owner: string,
35 name: string,
36 requested: string
37): Promise<string> {
38 const def = (await getDefaultBranch(owner, name)) || "main";
39 if (!requested) return def;
40 if (await resolveRef(owner, name, requested)) return requested;
41
42 const branches = await listBranches(owner, name).catch(() => [] as string[]);
43 const ci = branches.find((b) => b.toLowerCase() === requested.toLowerCase());
44 if (ci) return ci;
45
46 const known =
47 branches.length > 0
48 ? `; known branches include ${branches
49 .slice(0, 10)
50 .map((b) => `'${b}'`)
51 .join(", ")}${branches.length > 10 ? `, +${branches.length - 10} more` : ""}`
52 : "";
53 throw new McpError(
54 ERR_METHOD_NOT_FOUND,
55 `ref not found: ${owner}/${name}@${requested} — the repository's default branch is '${def}'${known}`
56 );
57}
Modifiedsrc/lib/mcp-tools-expanded.ts+122−23View fileUnifiedSplit
@@ -70,7 +70,10 @@ import {
7070 writeBlob,
7171 createOrUpdateFileOnBranch,
7272 getBlobShaAtPath,
73 getRawBlob,
7374} from "../git/repository";
75import { resolveReadRefOrThrow } from "./mcp-refs";
76import { stateFilter, ISSUE_STATES, PR_STATES } from "./state-filters";
7477import { join } from "path";
7578import { mkdir, unlink, rm } from "fs/promises";
7679import { config } from "./config";
@@ -631,10 +634,16 @@ const searchIssues: McpToolHandler = {
631634 const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 25)));
632635 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
633636 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
634 const stateClause =
635 state === "all"
636 ? eq(issues.repositoryId, info.repoId)
637 : and(eq(issues.repositoryId, info.repoId), eq(issues.state, state));
637 // Routed through the shared resolver rather than re-inlined. This handler
638 // had its own copy, which accepted any string and turned an unrecognised
639 // one — including a merely mis-cased "Open" — into a query matching no
640 // rows. An empty result is indistinguishable from "there is nothing
641 // here", so the tool confidently answered a question it had not
642 // understood. state-filters.ts exists because this exact drift happened
643 // before; a second inlined copy is how it came back.
644 const sf = stateFilter(state, issues.state, ISSUE_STATES);
645 if (!sf.ok) throw new McpError(ERR_INVALID_PARAMS, sf.error);
646 const stateClause = and(eq(issues.repositoryId, info.repoId), sf.clause);
638647 const rows = await db
639648 .select({
640649 number: issues.number,
@@ -737,10 +746,10 @@ const searchPrs: McpToolHandler = {
737746 const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 25)));
738747 const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
739748 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
740 const stateClause =
741 state === "all"
742 ? eq(pullRequests.repositoryId, info.repoId)
743 : and(eq(pullRequests.repositoryId, info.repoId), eq(pullRequests.state, state));
749 // Same shared resolver as gluecron_search_issues — see the note there.
750 const sf = stateFilter(state, pullRequests.state, PR_STATES);
751 if (!sf.ok) throw new McpError(ERR_INVALID_PARAMS, sf.error);
752 const stateClause = and(eq(pullRequests.repositoryId, info.repoId), sf.clause);
744753 const rows = await db
745754 .select({
746755 number: pullRequests.number,
@@ -880,19 +889,64 @@ const readFile: McpToolHandler = {
880889 async run(args, ctx) {
881890 const owner = mcpArgString(args, "owner");
882891 const repo = mcpArgString(args, "repo");
883 const ref = mcpArgString(args, "ref", "HEAD");
892 const refArg = mcpArgString(args, "ref", "");
884893 const filePath = mcpArgString(args, "path");
885 await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
886 const blob = await getBlob(owner, repo, ref, filePath);
894
895 // `encoding` was declared in the schema and never read: every response
896 // came back "utf8" regardless, so a caller asking for base64 was told it
897 // had been honoured when it had not. Validated rather than ignored.
898 const encoding = mcpArgString(args, "encoding", "utf8").toLowerCase();
899 if (encoding !== "utf8" && encoding !== "base64") {
900 throw new McpError(
901 ERR_INVALID_PARAMS,
902 `encoding must be 'utf8' or 'base64' (got ${JSON.stringify(encoding)})`
903 );
904 }
905
906 const canon = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
907 const canonOwner = (canon as any)?.owner ?? owner;
908 const canonRepo = (canon as any)?.name ?? repo;
909
910 // Resolve the ref BEFORE reading, so a ref that does not exist is reported
911 // as a missing ref rather than a missing path. The default used to be the
912 // literal string "HEAD" and a bad ref surfaced as
913 // `path not found: owner/repo@no-such-branch:package.json`, which sends
914 // the caller hunting for a file that is right where they said it was.
915 const ref = await resolveReadRefOrThrow(canonOwner, canonRepo, refArg);
916
917 const blob = await getBlob(canonOwner, canonRepo, ref, filePath);
887918 if (!blob) {
888919 throw new McpError(ERR_METHOD_NOT_FOUND, `path not found: ${owner}/${repo}@${ref}:${filePath}`);
889920 }
921
922 // Binary content used to come back as `content: null, encoding: null` with
923 // a correct `size` — the bytes existed, were located, and were then thrown
924 // away, leaving no path at all to read an image, PDF or any other binary
925 // through this surface. A binary file is returned as base64 whether or not
926 // base64 was asked for, and `encoding` states what was actually returned,
927 // so the response is never a claim about a form the content is not in.
928 if (encoding === "base64" || blob.isBinary) {
929 const raw = await getRawBlob(canonOwner, canonRepo, ref, filePath);
930 if (!raw) {
931 throw new McpError(ERR_METHOD_NOT_FOUND, `path not found: ${owner}/${repo}@${ref}:${filePath}`);
932 }
933 return {
934 path: filePath,
935 ref,
936 size: blob.size,
937 isBinary: blob.isBinary,
938 content: Buffer.from(raw).toString("base64"),
939 encoding: "base64",
940 };
941 }
942
890943 return {
891944 path: filePath,
945 ref,
892946 size: blob.size,
893 isBinary: blob.isBinary,
894 content: blob.isBinary ? null : blob.content,
895 encoding: blob.isBinary ? null : "utf8",
947 isBinary: false,
948 content: blob.content,
949 encoding: "utf8",
896950 };
897951 },
898952};
@@ -1117,7 +1171,7 @@ const listTree: McpToolHandler = {
11171171 tool: {
11181172 name: "gluecron_list_tree",
11191173 description:
1120 "List directory contents at a ref. Optionally `recursive: true` returns the full file list. Mirrors GET /api/v2/repos/.../tree/:ref.",
1174 "List directory contents at a ref. `path` scopes the listing to a subtree and is honoured in BOTH modes; `recursive: true` walks the whole subtree instead of one level. Always returns the same shape: {path, ref, recursive, entries, truncated, totalCount}. Recursive entries carry full repo-relative paths; non-recursive entries carry names. A ref that does not exist is an error, never an empty listing.",
11211175 annotations: { title: "List tree", readOnlyHint: true, destructiveHint: false },
11221176 inputSchema: {
11231177 type: "object",
@@ -1134,17 +1188,48 @@ const listTree: McpToolHandler = {
11341188 async run(args, ctx) {
11351189 const owner = mcpArgString(args, "owner");
11361190 const repo = mcpArgString(args, "repo");
1137 const ref = mcpArgString(args, "ref");
1191 const refArg = mcpArgString(args, "ref");
11381192 const path = mcpArgString(args, "path", "");
11391193 const recursive = argBool(args, "recursive", false);
1140 await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
1194 const canon = await mcpResolveAccessibleRepo(owner, repo, ctx.userId);
1195 const canonOwner = (canon as any)?.owner ?? owner;
1196 const canonRepo = (canon as any)?.name ?? repo;
1197
1198 // A ref that does not exist is an error, not an empty directory. The
1199 // non-recursive path used to return `{entries: []}` for a nonexistent
1200 // ref, which reads as "this repository is empty" — the caller cannot
1201 // tell a typo'd branch from a genuinely empty tree.
1202 const ref = await resolveReadRefOrThrow(canonOwner, canonRepo, refArg);
1203
11411204 if (recursive) {
1142 const out = await getTreeRecursive(owner, repo, ref, 50_000);
1143 if (!out) throw new McpError(ERR_METHOD_NOT_FOUND, "ref not found");
1144 return out;
1205 // `path` used to be accepted and then dropped here, so the only way to
1206 // list a subtree was to receive the whole repository.
1207 const out = await getTreeRecursive(canonOwner, canonRepo, ref, 50_000, path);
1208 if (!out) throw new McpError(ERR_METHOD_NOT_FOUND, `ref not found: ${owner}/${repo}@${ref}`);
1209 return {
1210 path,
1211 ref,
1212 recursive: true,
1213 entries: out.tree,
1214 truncated: out.truncated,
1215 totalCount: out.totalCount,
1216 };
11451217 }
1146 const tree = await getTree(owner, repo, ref, path);
1147 return { path, ref, entries: tree };
1218
1219 const tree = await getTree(canonOwner, canonRepo, ref, path);
1220 // Same key (`entries`), same envelope, in both modes. The two modes used
1221 // to return different shapes — `{entries}` with relative names when
1222 // non-recursive, `{tree, truncated, totalCount}` with full paths when
1223 // recursive — so a boolean silently switched the response contract and a
1224 // caller had to branch on the flag it had just passed.
1225 return {
1226 path,
1227 ref,
1228 recursive: false,
1229 entries: tree,
1230 truncated: false,
1231 totalCount: tree.length,
1232 };
11481233 },
11491234};
11501235
@@ -2502,7 +2587,21 @@ const getAgentBudget: McpToolHandler = {
25022587 mcpRequireAuthedCtx(ctx, "gluecron_get_agent_budget");
25032588 const sessionId = mcpArgString(args, "agent_session_id");
25042589 const { getAgentUsage } = await import("./agent-multiplayer");
2505 return await getAgentUsage(sessionId);
2590 const usage = await getAgentUsage(sessionId);
2591 // A session id that does not resolve is an error, not a zero balance.
2592 if (usage.status === "not_found") {
2593 throw new McpError(
2594 ERR_METHOD_NOT_FOUND,
2595 `agent session not found: ${sessionId}`
2596 );
2597 }
2598 if (usage.status === "error") {
2599 throw new McpError(
2600 ERR_INTERNAL,
2601 "the agent session budget could not be read — this is a platform fault, not a zero balance"
2602 );
2603 }
2604 return usage;
25062605 },
25072606};
25082607
Modifiedsrc/lib/mcp-tools.ts+9−15View fileUnifiedSplit
@@ -39,6 +39,7 @@ import { triggerPrTriage } from "./pr-triage";
3939import { performGatedMerge } from "./pr-merge-gated";
4040import { expandedTools } from "./mcp-tools-expanded";
4141import { stateFilter, PR_STATES } from "./state-filters";
42import { resolveReadRefOrThrow } from "./mcp-refs";
4243
4344/**
4445 * MCP spec ToolAnnotations — behavioural hints surfaced to clients via
@@ -176,27 +177,20 @@ async function resolveReadableRepo(
176177}
177178
178179/**
179 * Pick the ref to read from. Git refs are case-sensitive and a repo's default
180 * branch is not always "main" (e.g. Vapron's is "Main"). So:
181 * - no ref requested -> the repo's real default branch (git HEAD)
182 * - requested ref resolves as-is -> use it
183 * - otherwise -> a case-insensitive branch match (main -> Main), else the
184 * default branch, else the original ref (so getBlob reports the honest 404)
180 * Pick the ref to read from — see `resolveReadRefOrThrow` for the contract.
181 *
182 * Delegates rather than reimplements. This function used to fall back to the
183 * default branch when a requested ref did not resolve, which returned
184 * default-branch content under the caller's ref with nothing to signal the
185 * substitution. The shared resolver throws instead, and lives in its own
186 * module so the other tool surface cannot drift back to being lenient.
185187 */
186188async function resolveReadRef(
187189 owner: string,
188190 name: string,
189191 requested: string
190192): Promise<string> {
191 const def = (await getDefaultBranch(owner, name)) || "main";
192 if (!requested) return def;
193 if (await resolveRef(owner, name, requested)) return requested;
194 const branches = await listBranches(owner, name).catch(() => [] as string[]);
195 const ci = branches.find(
196 (b) => b.toLowerCase() === requested.toLowerCase()
197 );
198 if (ci) return ci;
199 return def;
193 return resolveReadRefOrThrow(owner, name, requested);
200194}
201195
202196// ---------------------------------------------------------------------------
Modifiedsrc/lib/state-filters.ts+6−1View fileUnifiedSplit
@@ -78,7 +78,12 @@ export function stateFilter(
7878 column: AnyPgColumn,
7979 allowed: readonly string[]
8080): StateFilterResult {
81 const state = raw || "open";
81 // Case- and whitespace-insensitive. `state=Open` used to fall through to
82 // the unknown-value branch on the surfaces that validated, and to a silent
83 // zero-row answer on the ones that did not — a capital letter reading as
84 // "nothing found". The stored values are lowercase, so folding here is the
85 // one place that fixes every surface at once.
86 const state = (raw || "open").trim().toLowerCase();
8287 if (state === "all") return { ok: true, clause: undefined };
8388 if (!allowed.includes(state)) {
8489 return { ok: false, error: stateError(state, allowed) };
8590
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts