fix: an asleep browser Lane is not a stalled one on the admin page (#110)

Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #110.
This commit is contained in:
2026-08-16 22:04:18 +07:00
committed by sulthan
parent 20fff588cc
commit f568fb5e8c
12 changed files with 834 additions and 615 deletions
+6
View File
@@ -266,3 +266,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
than recording zeroes; a Lane that has never reached a pace renders no gap at
all. `Checked` next to `Due` is what separates a stopped Lane from a quiet
one, so neither figure may be dropped from the row.
Due-without-Checked is *not* by itself a stall: a browser Lane under both
wake thresholds sets `LaneState.Asleep` at the on-demand gate and renders
"browser asleep" instead of "not checking", and never counts toward
`Attention`. That is the commonest healthy state for kagane, comix and
novelfull — one due Series, nothing checked — so spending the stall mark on
it would train the owner to ignore the mark that matters.
+1 -1
View File
@@ -263,7 +263,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
// browser): waking it for a single Poll would cost a challenge solve
// per request. The Lane still paces at the default gap, which is what
// the owner's page must show rather than a zero.
st.Gap = defaultGap
st.Gap, st.Asleep = defaultGap, true
return defaultGap
}
if s.Browser != nil {
+21
View File
@@ -1388,6 +1388,11 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
if got := browser.callCount(); got != 0 {
t.Fatalf("browser fetches with 3 freshly-due series = %d, want 0 (Chrome stays asleep)", got)
}
// The owner's page reads this state off the snapshot, and Due-without-
// Checked has to be distinguishable there from a Lane that has stopped.
if lane := laneByName(t, p, "kagane"); !lane.Asleep || lane.Due != 3 || lane.Checked != 0 {
t.Fatalf("asleep kagane lane = %+v, want Asleep with 3 due and 0 checked", lane)
}
// 5 due crosses the count threshold.
for i := 3; i < 5; i++ {
seed(i)
@@ -1396,6 +1401,9 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
if got := browser.callCount(); got != 5 {
t.Fatalf("browser fetches with 5 due series = %d, want 5", got)
}
if lane := laneByName(t, p, "kagane"); lane.Asleep {
t.Fatalf("woken kagane lane still reports Asleep: %+v", lane)
}
// A single long-neglected series wakes the browser by age alone.
seedForCheck(t, s, "kagane:ancient", "https://kagane.to/series/ancient", 0)
p.runOnce(context.Background())
@@ -1404,6 +1412,19 @@ func TestBrowserLaneWakeThresholds(t *testing.T) {
}
}
// laneByName pulls one Lane out of the poller's snapshot, failing rather than
// returning a zero LaneState a caller would assert against by accident.
func laneByName(t *testing.T, p *Poller, site string) LaneState {
t.Helper()
for _, lane := range p.LaneStatus().Lanes {
if lane.Site == site {
return lane
}
}
t.Fatalf("no %q lane in the snapshot", site)
return LaneState{}
}
// When one browser Lane loses the sidecar, the round's remaining browser
// Lanes are skipped: every fetch would fail anyway, and their Series must not
// burn their stamps on a dead Chrome (issue #100).
+5
View File
@@ -19,6 +19,11 @@ type LaneState struct {
Clamped bool
Refusing bool
Browser bool
// Asleep marks a browser Lane whose last pass declined to wake Chrome
// because it was under both wake thresholds (ADR-0005). Due without
// Checked then means "waiting for the group to gather", not "stopped", and
// the page must not draw it as a stall.
Asleep bool
}
// Status is the owner's page snapshot of the whole poller (issue #102).
+8 -3
View File
@@ -61,8 +61,11 @@ type laneRow struct {
// is configured, which stops those Series just as completely.
BrowserLost bool
// Stalled marks a Lane with Series waiting that its last pass did not read
// — the difference between a stopped Lane and a quiet one (story 13).
// — the difference between a stopped Lane and a quiet one (story 13). A
// browser Lane holding Chrome asleep under the wake thresholds is neither,
// so it carries Asleep instead and never Stalled.
Stalled bool
Asleep bool
// Attention is the one flag the template colours on, so an unhealthy Lane
// is found at a glance rather than read for.
Attention bool
@@ -152,8 +155,9 @@ func (h *Handler) lanesView() lanesView {
for _, l := range snap.Lanes {
lost := l.Browser && !snap.BrowserReachable
// Series waiting and none read is the shape of a Lane that has stopped
// working, as distinct from one that is quiet for want of work.
stalled := l.Due > 0 && l.Checked == 0
// working, as distinct from one that is quiet for want of work — or one
// deliberately leaving Chrome asleep until its group gathers.
stalled := l.Due > 0 && l.Checked == 0 && !l.Asleep
gap := ""
if l.Gap > 0 {
gap = l.Gap.Truncate(time.Second).String()
@@ -168,6 +172,7 @@ func (h *Handler) lanesView() lanesView {
Refusing: l.Refusing,
BrowserLost: lost,
Stalled: stalled,
Asleep: l.Asleep,
Attention: l.Clamped || l.Refusing || lost || stalled,
})
}
@@ -23,6 +23,7 @@
{{if .Refusing}}<span class="lane-mark">refusing</span>{{end}}
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
{{if .Stalled}}<span class="lane-mark">not checking</span>{{end}}
{{if .Asleep}}<span class="lane-mark">browser asleep</span>{{end}}
</li>
{{end}}
</ul>
+31
View File
@@ -776,6 +776,37 @@ func TestAdminPageShowsLaneStatus(t *testing.T) {
}
}
// A browser Lane under both wake thresholds holds Chrome asleep (ADR-0005), so
// Series due with none checked is the design working, not a stopped Lane. The
// two must not render the same mark: "not checking" is the owner's cue to go
// looking, and spending it on the commonest healthy browser-Lane state trains
// them to ignore it.
func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) {
lanes := fakeLanes{latest.Status{
Lanes: []latest.LaneState{
{Site: "kagane", Due: 1, LastRun: time.Now(), Gap: 10 * time.Second, Browser: true, Asleep: true},
},
BrowserConfigured: true,
BrowserReachable: true,
}}
router, st, _ := oauthWebTestServer(t, lanes)
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
if strings.Contains(body, "not checking") {
t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body)
}
if !strings.Contains(body, "browser asleep") {
t.Errorf("an asleep browser Lane says nothing about why it read nothing:\n%s", body)
}
if strings.Contains(body, `class="attention"`) {
t.Errorf("an asleep browser Lane is coloured as unhealthy:\n%s", body)
}
}
// A Lane whose pass never reached a figure must not have that figure drawn as
// a zero: a refusing Lane still reports the due count and gap its last real
// pass saw, and a Lane that has never reached one omits it entirely.