Measure staleness from where trunk really is, not from the calendar - #319
Measure staleness from where trunk really is, not from the calendar#319juanmaguitar wants to merge 7 commits into
Conversation
Two useEffects were both keyed off tracTicket: one auto-triggered a scrape right after a ticket was freshly linked, the other bumped scrapeGenRef to mark later scrapes as stale. React runs same-component passive effects in declaration order, so the auto-scrape fired first and captured the previous ticket's generation — its finally guard then never matched, leaving the "Reading ticket..." spinner stuck with no way to recover short of switching tasks again. Merges the two effects and bumps the generation synchronously at the top, before anything below can trigger a scrape. Fixes #299 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The pre-apply warning ("You have your own edits to ...") asked
`collectDirtyFiles`, which is HEAD-relative. Under the ticket-as-branch
model a ticket's work lives in its parked WIP commit, so HEAD is the
ticket's last saved state — and a ticket that has been left and resumed
has a worktree matching it exactly. The warning went silent on precisely
the tree it exists to protect, and spoke up about files edited back to
what the base holds.
`git:preview-patch` now asks `collectUnsubmittedFiles` — the same
base-relative walk the patch itself, the PR, the card's note and
`git:discard-to-base` are measured with. On trunk `patchBaseOid` answers
null and the walk falls back to HEAD, so trunk sites keep their answer.
Fixes #301
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A failed apply narrated an open pull request from its state alone: written against an older trunk, and here is a button to ask its author for a rebase. That was never checked against the checkout it had just tried to apply to, so a change that would not fit because the contributor's own work sits in the same lines — the ordinary shape of a resumed ticket — sent a newcomer to a stranger to ask for work that would not help. The app applies two-way, without the pull request's base, so it cannot prove which side any single failed region belongs to. It does not need to: it already knows which files this ticket has work in, and #302 makes that measurement base-relative. The preview's collision list is now threaded into describeApplyFailure, and the framing branches on it — own work in the way, a pull request behind trunk, or both said together, since picking one would be guessing again. Closed, merged and already-in-trunk pull requests are untouched, and so are loose patches, which have no author to misblame. Fixes #303. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A ticket's base — the trunk snapshot its branch was born at — is what the patch flow measures against: the contributor's own work, the generated patch, and the files the pre-apply warning names (#301). It was read through a helper that answered with an oid or null, folding four situations into two, both folds silent. A ticket branch with no recorded base got today's `refs/heads/trunk` substituted for it, which an update has very likely moved past the point the branch was really born at. And any throw came back as null, which every caller reads as "this site is on trunk" — sending the measurement back to HEAD, the exact reading #301 exists to remove, reached through the failure path. So the status is the answer: `patchBase` returns {status: trunk|recorded|unrecorded|unreadable, baseOid}, and the callers read the last two as themselves. An unrecorded base still measures against today's trunk, because there is nothing better available, but every surface that shows that answer hedges it — the preview's warning, and the handoff patch's Base line. An unreadable base refuses: no patch, no pull request, no discard, and a preview that fails through the fail-closed path it already had rather than reporting a clean tree. Recorded and trunk sites are untouched, which the tests pin. Fixes #308. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A ticket is a branch (#108): trunk, at most one applied patch or pull request, and the contributor's own edits. The branch holds that faithfully; the app's bookkeeping did not. `appliedPatch` was remembered only as something to undo, so everything the UI said about the ticket was read off the bare diff — and every file the applied pull request brought was announced as the contributor's own writing. Attribution. The record already stores which paths the patch touched, so the pre-apply warning now names two owners instead of one: a file the layer brought and nobody has edited over is described as coming from it, and a file the contributor has also worked on keeps naming their work, which is the answer that decides what they do next. Neither is dropped from the warning. The same split reaches the failure narration: a revert that will not come back out is the contributor's own edits sitting on lines they applied, never a pull request its author should rebase. Absorption. `revertable` used to mean "we stored the text". It now means what the banner actually asks — can this layer still be lifted out of this checkout, right now — measured by `diagnoseRemoval`, which reverses the stored patch through the same `resolveFile`/`diagnoseHunks` machinery the apply path uses and writes nothing. Once the contributor's edits are on the patch's own lines it is absorbed: Revert stands down, and the honest exits are saving a copy of the work and discarding the ticket to its base, which on this project is a normal way forward rather than a defeat. Nothing persists an "absorbed" flag, so undoing the overlapping edit brings Revert back on its own — and absorption never frees the one-patch slot, because the record survives as provenance. The banner's decision lives in `src/renderer/applied-layer.cjs`, unit tested directly; the patch text still never crosses IPC. Fixes #306 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#306 and #308 each replaced the block above Apply — one to say whose work a patch would land on, the other to say how sure the app is of the base that was measured from. Rebased together they collided, and neither should simply win: the attribution owns the file list, and the base is what says how far that list can be trusted. describePreviewNotice composes them, so the renderer picks a style and renders sentences rather than deciding between two modules. The fuller hedge is used where files are named, since the consequence — a list built against today's trunk can name files the contributor never touched and miss ones they did — is the actionable half; the quiet one stays for the case where nothing collided. describeOwnWorkWarning is superseded and goes with its per-file builders. Its cases move onto the composed answer rather than being deleted with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The amber staleness signal answered "how old is this snapshot", by date. Age is a proxy for distance from trunk and it misses both ways: a three-day-old snapshot can be dozens of commits behind in a busy week, and a two-week-old one can be nearly current. Ask the remote instead. src/trunk-remote.js reads refs/heads/trunk with git.listServerRefs — a refs lookup over protocol v2, not a fetch, so no objects are downloaded and no dependency is added. site:status starts the probe behind its reply and never awaits it, stamps the attempt before making it so a failure backs off for the full hour, and pushes the answer on trunk:remote when it lands. Opening a site never waits on the network, and offline, proxied or rate-limited all fall back to the 14-day threshold exactly as before. The decision stays pure: trunkAgeInfo takes the oid as data and returns a three-valued `behind`, and a new trunkUpdateAdvice owns every sentence the contributor reads, so the renderer holds no branching. Two things the copy is careful about. It does not nag toward destruction: an applied patch is removed by an update, so it turns the recommendation off and changes the wording rather than urging. And it says what updating fixes — the site. A ticket branch keeps the base it was born at, deliberately; carrying it forward is #305's action, and this copy must not imply otherwise. The signal also appears where it is cheapest to act on: just before a ticket is linked. Fixes #307. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8a52aec to
e20e1e9
Compare
|
Rebased into the stack and retargeted at #318, so the chain is now #313 → #317 → #318 → #319. Only two import lines collided — this change barely overlaps the others — but as a sibling it still could not be folded into a single build alongside them. Suite on the stacked branches: 1028 pass, 0 fail; lint clean. |
|
Parked for after 1.0, deliberately. The app is still proving whether it is useful at all, and 1.0 is being cut to what must not lie or lose work — this change replaces a heuristic that errs on the side of a cheap, idempotent click with a network probe, a throttle and a push channel. Good machinery, wrong phase. The calendar dot stays as the 1.0 signal. The branch stays alive; if the app earns its keep, this is ready to pick back up. Removed from the |
90d2bad to
4fe4d87
Compare
|
Closing as a deliberate post-1.0 deferral. For 1.0, the existing calendar signal is sufficient: a remote probe, throttling, timeouts and offline behavior add disproportionate machinery to an experimental app. The branch remains available if real use proves exact remote staleness worth that cost. |
Why
The amber staleness signal answers "how old is this site's trunk snapshot", by the calendar. Age is a proxy for the thing every downstream failure is actually about — distance from trunk — and it misses in both directions: a three-day-old snapshot can be dozens of commits behind in a busy week, and a two-week-old one can be nearly current. A contributor who follows the advice faithfully still writes patches against a trunk that has moved, and one who ignores a spurious dot spends minutes updating for nothing.
The app never measured the distance because measuring it seemed to mean talking to the network. It does not: the remote will name the commit its trunk points at for the cost of one small request, no objects downloaded.
What changes
src/trunk-remote.js(new) asks the remote whererefs/heads/trunkis, withgit.listServerRefs— protocol v2ls-refs, a refs lookup and not a fetch. No new dependency (it is the bundledisomorphic-git), no objects on disk, and the answer is one oid. It rejects rather than absorbing failures, so the caller can tell "this remote has no trunk" from "we could not ask".The probe never blocks anything.
site:statusreturns the last known answer as it stands and starts the refresh behind its reply, so opening a site never waits on the network. When the answer lands it is written to the site record and pushed ontrunk:remote, becausesite:statusis not on a timer — it is read on mount and after the long operations, so a stored-only answer would first be read on the next launch.It probes at most once an hour per site, stamped before the attempt. That does two jobs: the several
site:statuscalls a session makes (mount, then each install/build/update/apply/switch) collapse to one request, and a failure backs off for the full hour instead of retrying on every call. An hour is chosen because the signal it feeds is measured in days — an answer up to an hour old never changes the recommendation. A finished update records the oid it just fetched, so the signal clears immediately rather than an hour later.The decision stays pure.
trunkAgeInfoinsrc/renderer/update-plan.cjstakes the oid as plain data and returns a three-valuedbehind(moved / current / could-not-ask). The comment that used to state the opposite invariant — "never from a network probe" — is rewritten rather than left contradicting the code. The 14-day threshold survives as the offline fallback, which is what keeps the original promise: with no answer, the dot behaves exactly as it did before.A new
trunkUpdateAdviceowns every sentence the contributor reads about staleness, so the renderer holds no branching. It is careful about two things:src/main.jsclears the record for exactly this reason). So an applied patch turns the recommendation off — the amber block and the next-action cue go away, and a neutral block states the fact and the cost instead. The dot's tooltip changes too, since it has no detail line under it to soften it.And it surfaces where it is cheapest to act on: the "Trac ticket" card, before a ticket exists. Updating first means the ticket is not born behind, which removes the cheapest-to-prevent class of apply failures. Shown only when no ticket is linked and nothing is at risk.
Deliberately not in this PR: how many commits behind. The refs protocol carries oids, not distances — counting means downloading the objects between them, which is the fetch this avoids. "Trunk has moved" is what can be known for free, and it is already truer than a date.
How to test this
Platforms: any. The change touches no paths, spawning or line endings. Buildkite has a signed artifact for this branch — check it matches the head commit.
Starting state: a site with
wordpress-developcloned, installed and built, whose trunk snapshot is less than 14 days old (a site set up this week). That is the case the old signal could not see, so it is the one worth starting from. Have a Trac ticket number and a pull request that touches a file you are willing to edit.trunk as of …label. Before this PR the site looked perfectly fresh: too young for the calendar to flag. Hover the dot: the tooltip names trunk, not a number of days.wordpress-developto land a commit (usually minutes to hours) or, if you are impatient, editremoteTrunkOidin the app'sconfig.json(~/Library/Application Support/wordpress-contributor-toolkit/on macOS,%APPDATA%on Windows) to any other 40-character hex string, and setremoteTrunkCheckedAtto a time within the last hour so the probe does not immediately correct you. Reopen the app.github.com). Open the app and select the site. It opens at normal speed, with no spinner and no pause: nothing on the launch path waits on the network. A snapshot older than 14 days still shows the amber dot and the block, worded "This site's WordPress code is N days old" — the calendar fallback. A snapshot younger than that shows nothing. Nothing appears in the terminal panel about the network. Turn Wi-Fi back on and reselect the site: within a few seconds the wording switches to "Trunk has moved since this snapshot" if it has.What must not have happened:
trunk-remote) and nowhere else.node_moduleswas not reinstalled or rebuilt except where step 3's update said it would be.What could not be tested by hand: rate-limiting from github.com — the request is one unauthenticated
ls-refsper site per hour, which is far below any threshold reachable by trying. It is covered by the same path as offline (the probe rejects, the calendar takes over), which step 8 does exercise.Risks and limitations
7 findings across two review passes · 5 fixed here · 2 deferred. The two deferrals are named below and neither is user-visible today.
ls-refscalls an hour instead of one. Small (one tiny request each) but it is the "work should not scale with the site registry" rule applied to the network. Deferred: the fix is a per-URL memo or in-flight dedupe, and it has its own shape question. Related: twosite:statuscalls racing on the same site can both find no stamp and both probe.updateToLatestTrunkwrites the local ref before the checkout; if it throws after that, the site record is not updated and local trunk is briefly ahead of the last probed remote oid. Self-correcting at the next probe, and the site is already showing an "Update incomplete" banner in that state. Deferred.trunkwould read as "the remote moved". The app never makes one — contributor work goes to ticket branches (Working on a second ticket means rebuilding the world: tickets should be branches, not sites #108) — so this is a hand-edited repository only.Related
Fixes #307. Part of #309. Adjacent to #305 (bringing a ticket forward), which this PR deliberately does not do and takes care not to promise.
Design decisions and alternatives considered
Why
listServerRefsand not a shallow fetch. A fetch would give the count, and it would also download objects into the site, need a writable repository, and take long enough to need progress.ls-refswithprefix=refs/heads/trunkis one small request with no side effects, which is what makes it safe to run unattended in the background.Why the answer is cached in
electron-storeand not in a module variable.electron-storeis this repo's only persistence layer, and the invariant explicitly calls out state parked in a module-level variable that outlives a handler as drift. Two new keys,remoteTrunkOidandremoteTrunkCheckedAt, both absent-safe: an existing site record with neither behaves exactly as it does today, so no migration is needed.Why the stamp is written before the probe, not after. It is the throttle and the offline back-off in one. Writing it after would leave a failing probe re-firing on every
site:status, which is the worst behaviour for the machine most likely to be offline.Why a push channel rather than making the renderer re-poll. The first review pass caught that
site:statusis not on a timer — it is called on mount and after long operations — so a store-only answer would have been read a session late, and the feature would have silently done nothing in the session that probed. Adding a poll to the renderer would put a timing decision inindex.jsx, which the architecture rules keep out; a one-waywebContents.sendmatches the existingticket:carried-workandswitch:progressshape.Why uncommitted edits do not silence the advice, but an applied patch does. The issue asks the signal to stay quiet over "an applied patch or unsaved work". Those turned out to be different situations. An update removes an applied patch without asking. It does not do that to edits:
startTrunkUpdateasks the narrow "is the worktree dirty" question first and opens a dialog that offers saving them (#234). Work that cannot be lost without a prompt is not a reason to withhold the signal, so it is named in the copy instead. That also keepstrunkUpdateAdviceaway from a measure it would have got wrong — the value the panel holds is the branch-point one (#239), which counts parked work a force checkout survives, so using it would have gone quiet on trees that are perfectly safe to update. This is a conscious departure from the issue's wording, and the reasoning is in the module's own comment.Why the pre-link prompt is a note in the ticket card and not a modal. It is worth a moment of attention, not an interruption — and it appears only when no ticket is linked and nothing is at risk, so it can never be the thing standing between a contributor and their work.
Review outcome (required — see AGENTS.md)
Two passes of
.github/instructions/code-review.instructions.md, both dispatched to a fresh context.First pass: 4 [fix here] · 3 [follow-up].
site:statusis not polled. Fixed: thetrunk:remotepush channel.detailonly rendered inside the block gated onrecommendUpdate, which is false exactly when the at-risk sentences are non-empty. Green tests asserted copy the app could not show. Fixed: the neutral block.test/trunk-remote.test.cjs, withlistServerRefsandtimeoutMsinjected (the house pattern) so the prefix over-match and the deadline are exercised without a network.nullleft the previous oid in place, so a site could report itself behind for good. Fixed: the answer is always stored,nullincluded.Promise.race.hasChangesfed the wide branch-point measure.Second pass (reconciliation): 1–5 resolved, 7 deferral accepted, 6 reopened, plus 3 new.
deriveNextActioncall is correct with the wide one. Fixed, differently: rather than plumb a second measure, uncommitted edits stopped being a suppressor at all — see the design note above.subscribeRemoteTrunkwas missing fromSUBSCRIPTIONSintest/preload-listeners.test.cjs, andtrunk:remoteis the first channel where every mountedSiteRowis a live subscriber — precisely the leak shape that file's header describes. Fixed: added to the list, which buys all four of its tests.siteMetaentry, leaving a phantom record for a directory that is gone. Fixed: both writes check the site is still registered.Style notes from both passes (mixed apostrophe glyphs; a test leaning on
process._getActiveHandles()) are fixed; the latter is now asserted through an injected clock.npm test— 970 pass, 0 fail, on both.nvmrc's Node and Electron's bundled Node (npm run test:electron).npm run lint— clean.npm run build:once— clean.Implementation notes
src/trunk-remote.js(new, 126 lines) —remoteProbeDue(pure) andreadRemoteTrunkOid. A stamp in the future reads as due, not as never-again: that is a clock that moved backwards, and the alternative silences the signal until the stamp's hour arrives. The refs row is matched on the full ref name, not taken as the first —prefixis a prefix, sorefs/heads/trunk-experimentcomes back under it too, and taking the first row would pin the site against a branch nobody works on.src/main.js—refreshRemoteTrunk(started, never awaited),REMOTE_TRUNK_CHANNEL, the throttle check insite:status, and the post-update record of the oid the fetch just resolved. The failure is logged to the app log and not streamed to the renderer: the "no silent catch" rule exists so a contributor is never left with a button that did nothing, and nobody pressed anything here.src/renderer/update-plan.cjs—trunkAgeInfogainstrunkOid/remoteTrunkOidand returnsbehind+source;trunkUpdateAdviceis new. No I/O, no DOM, as before.src/renderer/index.jsx— state for the two oids, thetrunk:remotesubscription, and five call sites reading the advice instead of formatting their own strings. No new branching in the component.src/preload.js—subscribeRemoteTrunk, a named function like every other bridge entry; no widening.test/ipc-wiring.test.cjs— the harness now stubs./trunk-remoteby default, with areadRemoteTrunkOidthat rejects. Same rationale as replacingelectronoutright:site:statusstarts a network call behind its reply, so every one of the manysite:statustests would otherwise reach github.com — slowly, flakily, and invisibly, since the call is deliberately not awaited. Tests that exercise the probe pass their own.Screenshots or recording
Not captured. The visible surface is described precisely enough to check in "How to test this" — an amber block whose first line changes from "This site's WordPress code is N days old" to "Trunk has moved since this snapshot", the same block turning grey and losing its button when a patch is applied, and a new amber note in the "Trac ticket" card before one is linked. Reproducing the "behind" state for a screenshot needs
wordpress-developto have moved (or a hand-edited store), which is the same setup step a reviewer runs anyway.