fix: restore CI (2g ceiling was OOM-killing tsc), plus the two Vapron display fixes #5563
6 changed files+257−9
Modified.env.example+5−2View fileUnifiedSplit
@@ -93,13 +93,16 @@ HEARTBEAT_REPORT_TOKEN=
9393# recording.
9494REPORTING_EPOCH=
9595ANTHROPIC_API_KEY=
96# Container memory ceiling (docker-compose.standalone.yml). Default 2g.
96# Container memory ceiling (docker-compose.standalone.yml). Default 4g.
9797# INCIDENT 2026-08-27: the container ran unbounded and leaked, taking a
9898# shared host to 96% full via five 10-13 GB core dumps. Raise on a
9999# dedicated box; do not remove — unbounded means one tenant can take the
100100# machine. Core dumps are disabled alongside it (ulimits.core=0), which is
101101# what stops a crash becoming a disk-space incident for everyone else.
102GLUECRON_MEM_LIMIT=2g
102# Must clear the BUILD, not just the app: the CI runner executes inside this
103# container, and 2g killed `tsc --noEmit` with exit 137 (OOM) on the first run
104# after the limit was introduced.
105GLUECRON_MEM_LIMIT=4g
103106# ── Model provider ──────────────────────────────────────────────────────
104107# The platform does not need AI to run: git, CI and merges never call a
105108# model. These exist so the AI features are not tied to one vendor.
Modifieddocker-compose.standalone.yml+16−5View fileUnifiedSplit
@@ -99,11 +99,22 @@ services:
9999 # 13 GB on disk — without it a crash is a restart; with it, a crash is
100100 # a disk-space incident for everyone else on the machine.
101101 #
102 # GLUECRON_MEM_LIMIT is overridable because the right ceiling depends on
103 # the host: 2g suits a small shared box, and a dedicated one can afford
104 # more. Set it in .env rather than editing this file.
105 mem_limit: ${GLUECRON_MEM_LIMIT:-2g}
106 memswap_limit: ${GLUECRON_MEM_LIMIT:-2g}
102 # THE CEILING MUST CLEAR THE BUILD, NOT JUST THE APP. First attempt set
103 # this to 2g and broke CI on the very next run: `bunx tsc --noEmit` died
104 # with exit 137 (SIGKILL, i.e. OOM) in 6.7s. The workflow runner executes
105 # INSIDE this container, so the app's memory bound also bounds every
106 # typecheck and test run — and a full typecheck of ~400 files peaks well
107 # above the app's steady state.
108 #
109 # 4g clears the build with headroom while still bounding the leak this
110 # was added for (the container was at 526 MB and climbing, having written
111 # five 10-13 GB core dumps). Raise on a bigger box; do not drop below
112 # what tsc needs unless CI moves out of this container.
113 #
114 # Symptom to recognise if it recurs: CI red, "exit 137", no test failures
115 # reported — because the process was killed before it could report any.
116 mem_limit: ${GLUECRON_MEM_LIMIT:-4g}
117 memswap_limit: ${GLUECRON_MEM_LIMIT:-4g}
107118 ulimits:
108119 # No core dumps. A bun core here is 10-13 GB and has never once been
109120 # the thing that diagnosed a problem — the logs were.
Addedsrc/__tests__/repo-freshness.test.ts+63−0View fileUnifiedSplit
@@ -0,0 +1,63 @@
1/**
2 * "Updated N ago" must not be able to contradict the repository.
3 *
4 * repositories.pushed_at only advances on pushes that go THROUGH the app.
5 * A ref written straight into the bare repo — system sshd, a push run on the
6 * box, an admin fixing something by hand — changes the repository and never
7 * tells the database. On this deployment that is not an edge case: the in-app
8 * SSH server is off (SSH_PORT=0 since the 2026-08-22 incident), so it is the
9 * only SSH path there is.
10 *
11 * The owner lost a day to it: the page read "Updated 6d ago" while ~30
12 * branches merged in. These pin the precedence rule that stops it recurring.
13 */
14
15import { describe, it, expect } from "bun:test";
16import { reconcileFreshness } from "../lib/repo-freshness";
17
18const d = (iso: string) => new Date(iso);
19
20describe("reconcileFreshness", () => {
21 it("prefers git when git is newer, and reports that it healed", () => {
22 // The exact case that misled the owner.
23 const r = reconcileFreshness(d("2026-08-21T00:00:00Z"), d("2026-08-27T21:00:00Z"));
24 expect(r.value?.toISOString()).toBe("2026-08-27T21:00:00.000Z");
25 expect(r.healed).toBe(true);
26 });
27
28 it("keeps the stored value when it is newer, and does not heal", () => {
29 // A repo can hold commits older than its last push — a force-push back to
30 // an earlier commit, or an import of old history. The stored push time is
31 // then the truer answer to "when did something last happen here", and
32 // overwriting it would move the date BACKWARDS on a live repo.
33 const r = reconcileFreshness(d("2026-08-27T21:00:00Z"), d("2026-01-01T00:00:00Z"));
34 expect(r.value?.toISOString()).toBe("2026-08-27T21:00:00.000Z");
35 expect(r.healed).toBe(false);
36 });
37
38 it("adopts git when nothing is stored", () => {
39 const r = reconcileFreshness(null, d("2026-08-27T21:00:00Z"));
40 expect(r.value?.toISOString()).toBe("2026-08-27T21:00:00.000Z");
41 expect(r.healed).toBe(true);
42 });
43
44 it("keeps the stored value when git has nothing to say", () => {
45 // An empty repo, or a git call that failed. A freshness read must never
46 // blank a date the database legitimately holds.
47 const stored = d("2026-08-20T00:00:00Z");
48 const r = reconcileFreshness(stored, null);
49 expect(r.value).toBe(stored);
50 expect(r.healed).toBe(false);
51 });
52
53 it("returns null only when neither source knows", () => {
54 expect(reconcileFreshness(null, null)).toEqual({ value: null, healed: false });
55 });
56
57 it("does not heal on an exact tie", () => {
58 // Equal timestamps mean the column is already correct; writing again
59 // would be a database round trip per page view for no change.
60 const t = d("2026-08-27T21:00:00Z");
61 expect(reconcileFreshness(t, new Date(t.getTime())).healed).toBe(false);
62 });
63});
Modifiedsrc/lib/import-helper.ts+33−1View fileUnifiedSplit
@@ -271,6 +271,25 @@ export async function importOneRepo(
271271 // the violation reach the catch below reported "failed" and left that
272272 // directory behind — invisible, and enough of it to matter on a bulk
273273 // import.
274 // Read the branch the clone actually landed on. Falls back to what the
275 // caller supplied, then "main" — but only when git itself cannot say.
276 let resolvedDefaultBranch = defaultBranch || "main";
277 try {
278 const proc = Bun.spawn(
279 ["git", "-C", destPath, "symbolic-ref", "--short", "HEAD"],
280 {
281 stdout: "pipe",
282 stderr: "pipe",
283 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
284 }
285 );
286 const head = (await new Response(proc.stdout).text()).trim();
287 if ((await proc.exited) === 0 && head) resolvedDefaultBranch = head;
288 } catch {
289 // Keep the caller's value; a failed read must not fail an import whose
290 // objects are already on disk.
291 }
292
274293 const [created] = await db
275294 .insert(repositories)
276295 .values({
@@ -278,7 +297,20 @@ export async function importOneRepo(
278297 ownerId,
279298 description,
280299 isPrivate,
281 defaultBranch: defaultBranch || "main",
300 // The CLONE's own HEAD wins over anything the caller told us.
301 //
302 // This field defaulted to the literal "main" and was otherwise taken
303 // on trust from the caller. A repo whose default branch is "Main"
304 // therefore landed with a row pointing at a branch that DOES NOT
305 // EXIST — `git rev-parse main` fails, so every read resolved nothing
306 // and the repo rendered as empty or frozen. No error anywhere; the
307 // symptom is a repo that looks dead. It cost the owner a day of
308 // believing a live repository had stopped receiving pushes.
309 //
310 // Two sources of truth for one fact, and the wrong one was stored.
311 // After a clone the bare repo's symbolic HEAD is authoritative and
312 // free to read, so ask it rather than believe a parameter.
313 defaultBranch: resolvedDefaultBranch,
282314 diskPath: destPath,
283315 starCount: 0,
284316 })
Addedsrc/lib/repo-freshness.ts+128−0View fileUnifiedSplit
@@ -0,0 +1,128 @@
1/**
2 * "Updated N ago" must come from the repository, not from a column.
3 *
4 * THE BUG. `repositories.pushed_at` is written by the post-receive hook, which
5 * runs on the paths that go THROUGH the app: HTTP Smart HTTP, and the in-app
6 * SSH server. Anything that writes refs directly to the bare repo on disk —
7 * system sshd, a `git push` run on the box, an admin fixing something by hand,
8 * a migration script — changes the repository and never tells the database.
9 *
10 * That is not hypothetical and it is not cheap. The owner spent a day
11 * believing Vapron had stopped receiving pushes: the page said "Updated 6d
12 * ago" while roughly thirty branches merged into it, because every one of
13 * those merges arrived over a path the hook does not see. "Last pushed 3 days
14 * ago" is precisely the signal someone uses to decide whether a system is
15 * alive, and it was lying.
16 *
17 * Note the platform's own SSH server is currently OFF (SSH_PORT=0, disabled
18 * after the 2026-08-22 inbound-TCP incident), so on this deployment the
19 * direct-to-disk path is not an edge case — it is the only SSH path there is.
20 *
21 * THE FIX, and why it is not another hook. A hook can only cover the paths
22 * that reach the app, and the path that caused this does not. Two sources of
23 * truth for one fact — the git repo and the DB row both claim to know when
24 * the last push was — and the renderer trusted the one that can silently go
25 * stale. So: ask git, take whichever is newer, and heal the column on the way
26 * past. Whatever writes the refs, the page can no longer report a date the
27 * repository itself contradicts.
28 */
29
30import { db } from "../db";
31import { repositories } from "../db/schema";
32import { eq } from "drizzle-orm";
33import { cached, gitCache } from "./cache";
34import { getRepoPath } from "../git/repository";
35
36/** Newest commit time across ALL refs, or null if the repo has none. */
37export async function newestCommitDate(
38 owner: string,
39 name: string
40): Promise<Date | null> {
41 // Cached under the repo's own prefix, so `invalidateRepoCache` on push
42 // clears it and an app-path push is reflected immediately rather than
43 // waiting out a TTL.
44 const iso = await cached(
45 gitCache as unknown as import("./cache").LRUCache<string>,
46 `${owner}/${name}:newest-commit-date`,
47 async () => {
48 try {
49 const proc = Bun.spawn(
50 [
51 "git",
52 "-C",
53 getRepoPath(owner, name),
54 "for-each-ref",
55 "--sort=-committerdate",
56 "--count=1",
57 "--format=%(committerdate:iso-strict)",
58 "refs/heads",
59 ],
60 {
61 stdout: "pipe",
62 stderr: "pipe",
63 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
64 }
65 );
66 const out = (await new Response(proc.stdout).text()).trim();
67 return (await proc.exited) === 0 ? out : "";
68 } catch {
69 return "";
70 }
71 }
72 );
73 if (!iso) return null;
74 const d = new Date(iso);
75 return Number.isNaN(d.getTime()) ? null : d;
76}
77
78/**
79 * Pure half, so the precedence rule is testable without a repo or a database.
80 *
81 * Git wins only when it is NEWER. A repo can legitimately hold commits older
82 * than its last push (a force-push to an earlier commit, an import of old
83 * history), and in those cases the stored push time is the more truthful
84 * answer to "when did something last happen here".
85 */
86export function reconcileFreshness(
87 stored: Date | null,
88 fromGit: Date | null
89): { value: Date | null; healed: boolean } {
90 if (!fromGit) return { value: stored, healed: false };
91 if (!stored) return { value: fromGit, healed: true };
92 return fromGit.getTime() > stored.getTime()
93 ? { value: fromGit, healed: true }
94 : { value: stored, healed: false };
95}
96
97/**
98 * Resolve the true "last activity" for a repo, healing the stored column when
99 * git knows better. Never throws: a freshness read must not be able to break
100 * the page it decorates.
101 */
102export async function resolvePushedAt(
103 owner: string,
104 name: string,
105 repoId: string,
106 stored: Date | null
107): Promise<Date | null> {
108 try {
109 const { value, healed } = reconcileFreshness(
110 stored,
111 await newestCommitDate(owner, name)
112 );
113 if (healed && value) {
114 // Fire and forget — the page has its answer either way, and a write
115 // failure must not cost the reader their repository page.
116 db.update(repositories)
117 .set({ pushedAt: value })
118 .where(eq(repositories.id, repoId))
119 .catch((err) => {
120 console.warn(`[freshness] heal failed for ${owner}/${name}:`, err);
121 });
122 }
123 return value;
124 } catch (err) {
125 console.warn(`[freshness] resolve failed for ${owner}/${name}:`, err);
126 return stored;
127 }
128}
Modifiedsrc/routes/web.tsx+12−1View fileUnifiedSplit
@@ -9,6 +9,7 @@ import { eq, and, desc, inArray, sql, gte, count, ne } from "drizzle-orm";
99import { db } from "../db";
1010import { fireWebhooks } from "./webhooks";
1111import { config } from "../lib/config";
12import { resolvePushedAt } from "../lib/repo-freshness";
1213import { hostHas } from "../lib/host-capabilities";
1314import {
1415 users,
@@ -3300,7 +3301,17 @@ web.get("/:owner/:repo", async (c) => {
33003301 isTemplate: repoRow.isTemplate,
33013302 forkCount: repoRow.forkCount,
33023303 description: repoRow.description as string | null,
3303 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3304 // Ask git, not just the column. pushed_at only advances on pushes
3305 // that go THROUGH the app; a ref written directly to the bare repo
3306 // (system sshd, a push run on the box, an admin fix) changes the
3307 // repository and never tells the database. That is how this page
3308 // reported "Updated 6d ago" while thirty branches were merging in.
3309 pushedAt: await resolvePushedAt(
3310 owner,
3311 repo,
3312 repoRow.id as string,
3313 (repoRow.pushedAt as Date | null) ?? null
3314 ),
33043315 createdAt: (repoRow.createdAt as Date | null) ?? null,
33053316 repoId: repoRow.id as string,
33063317 repoOwnerId: repoRow.ownerId as string,
33073318
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts