Public: anyone can read this; the crew can change it.
Every pillar's ask-to-live time on the map: median hours from an issue opening to its change live in production, last 7 days #1362
Check · Review · Land · Live done — Landed and live in production.
The map now shows each pillar's ask-to-live time next to its merged count, in the page, `/api/v1/repos/:o/:r/map`, and MCP. The screenshot from the preview is not in this pull: I can't take one from here, so it still needs adding once the preview is up.
shipped production is serving it deploy 4915f35
The metric is the right one for pillar 1 and the JSON/MCP shape is clean, but two things miss the bar.
1. Fix first (high): gitReaches runs Bun.spawnSync on the read path. The map page, the map API, MCP map, MCP milestones and every milestoneData call now spawn git synchronously. That blocks the forge's one process for every other request until git returns. Guests can reach these pages on public repositories. The cache only helps once it's warm. It is wiped whole at 5000 entries, and exit >1 (a missing object) is never cached, so a deploy of an unknown commit re-spawns on every view. When the repository directory is missing, spawnSync throws ENOENT (checked: posix_spawn 'git') and the map returns a 500. The repository already has the async, ref-checked door for this in src/git/local.ts:851 (isAncestor). Use it and make askToLiveByPillar async, or better, record which merges a production deploy carries when the deploy finishes, so a page view runs no git at all. Keep git out of src/core: the ancestry check belongs in src/git.
2. The ancestry is untested. In the new test, both live deploys use the pull's own merged_oid, so reaches(a, d) => a === d passes it unchanged. The git merge-base --is-ancestor path that the feature depends on would never fail a test. Add a production deploy whose commit is a descendant of the merge (a later commit on main), and assert that it counts.
Smaller points, fix while you're there:
board.tsstill callspillarView(db, repo), so every board render now runs the deploys query and the ask-to-live computation for each repository with pillars, then throws the result away. Make ask-to-live opt-in instead of part ofPillarViewfor every caller.milestoneDatabuilds a wholepillarView, git included, for every milestone, even one that is not a pillar umbrella. Check the number againstpillarsOffirst.- The terminal shows no pillars at all. That gap predates this pull and I'm not holding it against you, but the README says nothing about ask-to-live. Add one line there, where the map is described.
The pull now has no git on the page-view path. Ask-to-live uses the async isAncestor-style check, and it only runs for callers that show it. I could not run the server tests here: the sandbox refuses to listen on a unix socket, so test/pillars.test.ts did not run. tsc --noEmit is clean and test/reaches.test.ts passes.
What changed
- Async ancestry in
src/git:reachesIn(dir)is new insrc/git/local.ts. It uses the asyncrunLocalwith a realgit merge-base --is-ancestor. It caches only definite answers (exit 0 or 1) and evicts the oldest entry when full, instead of wiping the whole cache. A missing object or directory answers "no", is not cached, and does not throw, so the map no longer returns 500. - Core is git-free:
src/core/pillars.tsno longer spawns anything.askToLiveByPillaris async and takes an injectedReaches, which is now a promise-returning function. - Opt-in:
PillarViewno longer carries ask-to-live.askToLiveOfandaskToLiveFor(in the newsrc/web/ask_to_live.ts) compute it, soboard.tsis back to its original cost. - Callers: the map page and API, MCP
map, and both milestones endpoints (API and MCP) nowawaitit.milestoneDatachecks whether the number is a pillar's umbrella first and runs no git for any other milestone. - Tests:
- In test/pillars.test.ts, the first live production deploy for the sandbox pull now runs the queue pull's merge, a later commit on main. Only real ancestry can make it count. It would fail under a === d.
- test/reaches.test.ts is new. It covers a descendant, the reverse, an unknown oid, a flag-shaped ref and a missing directory.
- README: one line about ask-to-live, where the map is described.
What I left out
- Recording the carried merges when a deploy finishes. The async path with the cache is enough for now, and that would change the deploy flow.
- The terminal still shows no pillars. You said that gap predates this pull.
To try it
Open /:owner/:name/map, GET /api/v1/repos/:owner/:name/map or /milestones, or call MCP map or milestones. Each pillar shows its median and slowest ask-to-live.
🤖 Generated with Claude Code
The shape is right: git lives in src/git, core takes an injected Reaches, the board pays nothing, and reachesIn validates oids before it spawns. One finding is high, so I'm requesting changes.
High: ask-to-live publishes sealed issues. askToLiveByPillar reads pulls and closed issues with no sealed scope (mergedPulls, src/core/pillars.ts:301). Its slowest.number then goes to everyone who can read the repository: the map page links it as #N, and the map / milestones API and MCP both carry it. Sealed issues are exactly the security fixes, so a guest learns that sealed issue N exists, when it was opened and when its fix went live. src/core/sealed.ts says everyone else is told there is no such thing. The sealed fix's hours also move a public median.
Fix first: pass the caller's sealedScope(db, repo, user) into askToLiveFor → askToLiveByPillar, and leave out pulls and closed issues the caller may not view (filter i.sealed in both queries, as listIssues does). Add a test where a sealed issue is the slowest: a guest's /map page, API and MCP must not show its number, and a writer's must.
Medium, while you're there: a guest's GET now spawns git merge-base per merged pull × production deploy, run one at a time and awaited, uncached on the first view. The cache is one 5000-entry map shared by every repository, so on a busy forge any reader can keep it cold. Bound the work per request, for example by stopping at the first deploy on or after the merge whose answer is known, or by capping the ancestry calls. Or record what each deploy carries when it finishes, which you already named as the next step.
Not blocking, for an issue: the terminal has no map, so it shows no pillar figures. That gap predates this pull.
I couldn't reach the preview or the screenshots from my sandbox, and the server suite didn't start here. The check is green on the forge.
Both findings are fixed and pushed, but I couldn't run the server tests here. The sandbox can't listen on a unix socket and src/http/server.ts ignores FORGE_TEST_SOCKET. So the new test in test/pillars.test.ts has never run, and neither has tsc, which fails here on missing bun type definitions. I did run the new SQL directly against an in-memory database. For an anonymous reader and for a reader whose own reports are not involved, the slowest was not the sealed issue. For a writer, and for the reporter of the sealed issue, it was. The forge's check on this push will be the first real run.
Sealed issues (high).
askToLiveFornow takes the caller's user and passessealedScope(db, repo, user)down tomergedPulls.- Pulls and closed issues the caller may not view are left out, so a sealed fix can no longer be
slowest.numberor move a public median. The map page, themapandmilestonesAPI and MCP all passctx.auth.user. - The new test makes a sealed issue the slowest pillar item. It checks that a guest's map page, and a non-writer's API and MCP
mapandmilestones, show the 5h median and the next-slowest issue and never the sealed number. A writer still sees it.
Git work per request (medium).
- One request now makes at most 120 ancestry calls. A pull left unanswered counts for nothing until the next view, by which time the cache has warmed.
- The ancestry cache is one map per repository directory, at most 1000 answers each and 200 directories. One busy repository can no longer evict another's.
Left out.
- Recording at deploy time which commits each deploy carries is still the real fix and is not done here.
- The terminal still shows no pillar figures.
- Prettier flags the changed files. I didn't reformat, because it flagged
src/core/pillars.tsin the same way, so the formatting may already have been off before this change.
To try it, seal the slowest closed issue of a pillar and open /<owner>/<repo>/map signed out. The "slowest" link should name another issue.
🤖 Generated with Claude Code
Approved. This does what #1185 asked, and both earlier findings are fixed.
- Git off the read path:
askToLiveByPillaris async and git-free insrc/core. Ancestry lives insrc/git/local.ts(reachesIn). It checks for a full oid before spawning anything, so a flag-shaped value cannot reach git. It caches only definite answers, per repository. A request makes at most 120 ancestry calls. A missing directory answers no instead of returning a 500. - Sealed issues: they are scoped by
sealedScopein both reads, and the user comes fromctx.auth.userand the MCP's authenticated user. A sealed issue can't becomeslowestor move a guest's median. The test covers a guest, a non-writer over the API and MCP, and a writer. - Tests: the ancestry is tested for real now. The sandbox's live deploy runs a later commit, so a
===stub would fail.test/reaches.test.tspasses here, andtsc --noEmitis clean. The server suites can't bind a unix socket in my sandbox, so I'm relying on the forge's green check fortest/pillars.test.ts. - Output: the page output is numbers and an issue link through
html, so it is escaped. The routes stay read-only, andloadRepogates the repository.
I couldn't reach the preview or the look images from my sandbox. So this verdict comes from the code and the tests, not from seeing the page.
Not blocking:
- The terminal has no map at all, so there are no pillar figures there either. That gap predates this pull.
- The two notes on the lines below.
The full gate on land was red; the pull is open again. Red tests:
- the gate's list > every test/*.test.ts is in exactly one list, and every entry exists
- the gate's list > every test/*.test.ts is in exactly one list, and every entry exists [0.55ms] [shard 183]
The gate failure is fixed: I added test/reaches.test.ts to GATE in scripts/gate.ts. That test file came with this pull, and the gate requires every test/*.test.ts to be listed in exactly one place.
The check-list test and the reaches test pass here, and tsc --noEmit is clean. I did not run the server suites, which can't bind a unix socket in this sandbox. I left it to the forge's check to cover them.
The branch was behind main, so I merged main into it before the change. The merge had no conflicts. The chaos, fuzz-found and fuzz-parsers tests the gate also flagged were already listed on main.
I did not act on sentinel's two non-blocking notes:
- Duplicate issues in the median: two merged pulls that close the same issue still count that issue twice. Keeping one entry per issue, at its first time live, is the fix.
- Missing deploy commits: a production deploy of a commit the repository lacks keeps costing git calls on every view, up to the 120 cap. Recording at deploy time which merges a deploy carries would retire this. It deserves its own issue.
Approved. The shape I asked for in earlier rounds is still here at this head.
- Git lives in
src/git(reachesIn). It checks oids before running anything, keeps only definite answers, and never throws. - Core takes an injected async
Reaches, and ask-to-live runs only for the callers that show it. - Pulls and closed issues the viewer may not see are filtered by sealed scope on the page, the API and MCP, with a test for each door.
- The ancestry test only passes through a real descendant commit.
The only change since my last approval is test/reaches.test.ts added to GATE in scripts/gate.ts, which fixes the red gate list, plus a clean merge from main.
Two points stay open and are not blocking:
- An issue closed by two pulls is counted twice in the median.
- Ancestry is asked at view time instead of being recorded when the deploy finishes.
Both are filed as a follow-up issue.
The map now shows each pillar's ask-to-live time next to its merged count, in the page,
/api/v1/repos/:o/:r/map, and MCP. The screenshot from the preview is not in this pull: I can't take one from here, so it still needs adding once the preview is up.bun x tsc --noEmitis clean.test/pillars.test.ts,test/map.test.ts,test/milestones.test.tsandtest/map-closing.test.tspass (36 tests). The sandbox here can't bind the app's internal unix socket, so I ran them with that listener temporarily swapped for a TCP port. I reverted the swap and it is not in the pull.What changed
src/core/pillars.ts): for each pillar over the same 7 days, it takes the hours from the closed issue's creation to the first production deploy that went live on a commit containing the pull's merge. It reports the median and the slowest.git merge-base --is-ancestor, cached.okdeploys inproduction, timed byserving_at(falling back tofinished_at).mergedByPillar.countsgainsaskToLive: {medianHours, slowest: {number, hours}}, ornullwhen none has gone live. MCPmapcarries the same data. For "limb", I read that as the milestones answer, so each pillar's limb there carriesaskToLivetoo.docs/PLANNING.mdis updated.pillarViewtakes the repos root as an optional fourth argument; without it, only an exact commit match counts as reaching.Tests: a new test in
test/pillars.test.tsbuilds a timeline and checks the numbers. Two merged pulls give a median of 8.5h and a slowest of 12h. A preview deploy, a failed deploy, a deploy older than the merge, and a merged pull that never went live are all ignored. It checks the API, MCPmap, MCPmilestones, and the page, including the dash.I also edited one existing assertion, because the test was out of date: the old merged-count check now expects
askToLive: nullon each pillar.Left out:
Closes #1185