diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 9d35324..a03c7a2 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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. diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index 38bf0ca..50741a8 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -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 { diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index aaedcc7..72736bb 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -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). diff --git a/backend/internal/latest/status.go b/backend/internal/latest/status.go index 0c0ba08..49328a8 100644 --- a/backend/internal/latest/status.go +++ b/backend/internal/latest/status.go @@ -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). diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index efb925b..5ae53e3 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -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, }) } diff --git a/backend/internal/web/templates/lanes.html b/backend/internal/web/templates/lanes.html index 6cf61b7..32a3976 100644 --- a/backend/internal/web/templates/lanes.html +++ b/backend/internal/web/templates/lanes.html @@ -23,6 +23,7 @@ {{if .Refusing}}refusing{{end}} {{if .BrowserLost}}no browser{{end}} {{if .Stalled}}not checking{{end}} + {{if .Asleep}}browser asleep{{end}} {{end}} diff --git a/backend/web_test.go b/backend/web_test.go index 445f0bb..baf61c7 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -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. diff --git a/graphify-out/.graphify_labels.json b/graphify-out/.graphify_labels.json index c4e62f3..bdc641e 100644 --- a/graphify-out/.graphify_labels.json +++ b/graphify-out/.graphify_labels.json @@ -12,9 +12,8 @@ "10": "Go Test Helpers", "11": "Store Tests", "12": "Bookmarks API Handler", - "13": "Web UI Handlers", + "13": "displayChapter", "14": "Go Error Handling", - "15": "CDP Browser Client", "16": "Cloudflare bot scoring and poll cadence — what is actually documented", "17": "Go Code Style Guide", "18": "Agent Skills", @@ -30,7 +29,6 @@ "28": "Allocation Patterns", "29": "Observability & Alerting", "30": "AGENTS.md", - "31": "Store", "32": "Open", "33": "Go Testing Guide", "34": "Session Store", @@ -41,7 +39,6 @@ "39": "UI Critique 2026-07-26A", "40": "UI Critique 2026-07-26B", "41": "Handler", - "42": "pgtest.go", "43": "Issue Tracker & Triage", "44": "Ticket Workflow", "45": "Go Perf Alert Rules", @@ -124,7 +121,6 @@ "122": "Identity comes from Discord OAuth; we store no passwords and send no email", "123": "The wire format stays flat and deliberately does not mirror the schema", "124": "ADR-0005: On-demand browser sidecar", - "125": "sessions_test.go", "126": "Bookmark Manager", "127": "triage-labels.md", "128": "Cross-Ticket Contract", diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index ba847a7..078924b 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,16 +1,16 @@ # Graph Report - mangaBookmark (2026-08-16) ## Corpus Check -- 119 files · ~290,953 words +- 119 files · ~291,777 words - Verdict: corpus is large enough that graph structure adds value. ## Summary -- 1736 nodes · 3597 edges · 221 communities (70 shown, 151 thin omitted) -- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 361 edges (avg confidence: 0.78) +- 1739 nodes · 3610 edges · 217 communities (67 shown, 150 thin omitted) +- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 363 edges (avg confidence: 0.78) - Token cost: 0 input · 0 output ## Graph Freshness -- Built from commit: `56afb9f2` +- Built from commit: `20fff588` - Run `git rev-parse HEAD` and compare to check if the graph is stale. - Run `graphify update .` after code changes (no API cost). @@ -28,9 +28,8 @@ - [[_COMMUNITY_Go Test Helpers|Go Test Helpers]] - [[_COMMUNITY_Store Tests|Store Tests]] - [[_COMMUNITY_Bookmarks API Handler|Bookmarks API Handler]] -- [[_COMMUNITY_Web UI Handlers|Web UI Handlers]] +- [[_COMMUNITY_displayChapter|displayChapter]] - [[_COMMUNITY_Go Error Handling|Go Error Handling]] -- [[_COMMUNITY_CDP Browser Client|CDP Browser Client]] - [[_COMMUNITY_Cloudflare bot scoring and poll cadence — what is actually documented|Cloudflare bot scoring and poll cadence — what is actually documented]] - [[_COMMUNITY_Go Code Style Guide|Go Code Style Guide]] - [[_COMMUNITY_Agent Skills|Agent Skills]] @@ -46,7 +45,6 @@ - [[_COMMUNITY_Allocation Patterns|Allocation Patterns]] - [[_COMMUNITY_Observability & Alerting|Observability & Alerting]] - [[_COMMUNITY_AGENTS|AGENTS.md]] -- [[_COMMUNITY_Store|Store]] - [[_COMMUNITY_Go Testing Guide|Go Testing Guide]] - [[_COMMUNITY_Session Store|Session Store]] - [[_COMMUNITY_Web UI Filter Logic|Web UI Filter Logic]] @@ -56,7 +54,6 @@ - [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]] - [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]] - [[_COMMUNITY_Handler|Handler]] -- [[_COMMUNITY_pgtest.go|pgtest.go]] - [[_COMMUNITY_Issue Tracker & Triage|Issue Tracker & Triage]] - [[_COMMUNITY_Ticket Workflow|Ticket Workflow]] - [[_COMMUNITY_Go Perf Alert Rules|Go Perf Alert Rules]] @@ -139,7 +136,6 @@ - [[_COMMUNITY_Identity comes from Discord OAuth; we store no passwords and send no email|Identity comes from Discord OAuth; we store no passwords and send no email]] - [[_COMMUNITY_The wire format stays flat and deliberately does not mirror the schema|The wire format stays flat and deliberately does not mirror the schema]] - [[_COMMUNITY_ADR-0005 On-demand browser sidecar|ADR-0005: On-demand browser sidecar]] -- [[_COMMUNITY_sessions_test.go|sessions_test.go]] - [[_COMMUNITY_Bookmark Manager|Bookmark Manager]] - [[_COMMUNITY_triage-labels|triage-labels.md]] - [[_COMMUNITY_Cross-Ticket Contract|Cross-Ticket Contract]] @@ -270,7 +266,7 @@ - **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85] - **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85] -## Communities (221 total, 151 thin omitted) +## Communities (217 total, 150 thin omitted) ### Community 0 - "HTMX Library Internals" Cohesion: 0.08 @@ -278,7 +274,7 @@ Nodes (101): A(), ae(), an(), at(), B(), be(), bn(), bt() (+93 more) ### Community 1 - "Cover Fetch Test Helpers" Cohesion: 0.09 -Nodes (89): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecorder, T, TestListRendersAcquiredCover() (+81 more) +Nodes (88): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecorder, T, TestListRendersAcquiredCover() (+80 more) ### Community 2 - "Manga Userscript Adapters" Cohesion: 0.06 @@ -293,12 +289,12 @@ Cohesion: 0.07 Nodes (103): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+95 more) ### Community 5 - "Bookmarks API Tests" -Cohesion: 0.07 -Nodes (71): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+63 more) +Cohesion: 0.11 +Nodes (54): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+46 more) ### Community 7 - "Cover & Acquire Internals" -Cohesion: 0.06 -Nodes (43): Addr, fakeLanes, Context, Store, WaitGroup, isInterstitial(), defaultCoverResolver(), fetchCoverBytes() (+35 more) +Cohesion: 0.08 +Nodes (31): fakeLanes, Context, Store, WaitGroup, isInterstitial(), fetchCoverBytes(), browserWakeDue(), fetcherFor() (+23 more) ### Community 8 - "System Architecture Concepts" Cohesion: 0.15 @@ -313,21 +309,21 @@ Cohesion: 0.05 Nodes (39): Test Helpers, Test Timeout, Basic Handler Test, HTTP Handler Testing, Query Parameters and Headers, Docker Compose Fixture, Integration Testing, SQL Schema Fixture (+31 more) ### Community 11 - "Store Tests" -Cohesion: 0.14 -Nodes (45): Store, T, newTestStore(), readLatestCheckedAt(), readSeries(), secondReader(), seedForCheck(), seedSecondReader() (+37 more) +Cohesion: 0.07 +Nodes (81): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+73 more) ### Community 12 - "Bookmarks API Handler" Cohesion: 0.08 Nodes (32): Handler, Request, ResponseWriter, Store, Healthz(), writeJSON(), Auth(), compressible() (+24 more) +### Community 13 - "displayChapter" +Cohesion: 0.09 +Nodes (36): Addr, Config, NewBrowserFetcher(), defaultCoverResolver(), Client, Context, NewCoverFetcher(), NewCoverFetcherWithResolver() (+28 more) + ### Community 14 - "Go Error Handling" Cohesion: 0.06 Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more) -### Community 15 - "CDP Browser Client" -Cohesion: 0.20 -Nodes (17): applyMigration(), migrate(), Open(), refreshOwnerToken(), seedOwner(), TestCoverIsContentAddressedOnFilesystem(), TestCoverPersistsAcrossReopen(), TestMigration0002BackfillsExistingBookmarks() (+9 more) - ### Community 16 - "Cloudflare bot scoring and poll cadence — what is actually documented" Cohesion: 0.06 Nodes (33): 1.1 The score itself, 1.2 The detection engines (Enterprise Bot Management), 1.3 Rate limiting is a separate product, 1. What a bot score is and what feeds it, 2.1 What each plan gets, 2.2 Bot Fight Mode specifics (the Free-plan product), 2.3 Does the free tier "score" continuously?, 2. The free-plan reality (+25 more) @@ -365,8 +361,8 @@ Cohesion: 0.33 Nodes (12): coverResponse(), Request, T, TestCoverFetcherCanonicalisesJpgAlias(), TestCoverFetcherFetchesPublicHTTPSImage(), TestCoverFetcherRefusesUnsafeDestinationsBeforeRequest(), TestCoverFetcherRejectsNonImage(), TestCoverFetcherRejectsOversizedBody() (+4 more) ### Community 26 - "Open" -Cohesion: 0.05 -Nodes (61): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+53 more) +Cohesion: 0.06 +Nodes (57): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+49 more) ### Community 27 - "Find Skills Guide" Cohesion: 0.14 @@ -384,10 +380,6 @@ Nodes (9): Alerting rules (examples), CPU saturation, GC pressure, Goroutine lea Cohesion: 0.40 Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct -### Community 31 - "Store" -Cohesion: 0.09 -Nodes (5): coverRelativePath(), coverSourceAddress(), Store, scanSeries(), ReaderSummary - ### Community 33 - "Go Testing Guide" Cohesion: 0.20 Nodes (10): CI Regression Detection, Common Mistakes, Core Philosophy, Cross-References, Decision Tree: Where Is Time Spent?, Deep Dives, Go Performance Optimization, Iterative Optimization Methodology (+2 more) @@ -417,12 +409,8 @@ Cohesion: 0.29 Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider ### Community 41 - "Handler" -Cohesion: 0.14 -Nodes (18): currentLib(), currentTab(), filterBookmarks(), Client, HandlerFunc, Request, ResponseWriter, Store (+10 more) - -### Community 42 - "pgtest.go" -Cohesion: 0.18 -Nodes (12): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+4 more) +Cohesion: 0.05 +Nodes (25): coverRelativePath(), coverSourceAddress(), displayChapter(), Store, scanSeries(), currentLib(), currentTab(), filterBookmarks() (+17 more) ### Community 45 - "Go Perf Alert Rules" Cohesion: 0.50 @@ -532,10 +520,6 @@ Nodes (3): Consequence, The wire format stays flat and deliberately does not mir Cohesion: 0.50 Nodes (3): ADR-0005: On-demand browser sidecar, Constraints, Decision -### Community 125 - "sessions_test.go" -Cohesion: 0.48 -Nodes (6): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID() - ### Community 154 - "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them" Cohesion: 0.33 Nodes (5): ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them, Constraints preserved, Decision, Tradeoffs and rejections, Why @@ -551,17 +535,17 @@ Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against ## Knowledge Gaps - **524 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+519 more) These have ≤1 connection - possible missing edges or undocumented components. -- **151 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. +- **150 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **Why does `Open()` connect `CDP Browser Client` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `pgtest.go`, `Store Tests`, `Store`?** - _High betweenness centrality (0.060) - this node is a cross-community bridge._ -- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Handler`, `Session Middleware`, `CDP Browser Client`, `ResponseWriter`?** - _High betweenness centrality (0.050) - this node is a cross-community bridge._ -- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`, `ResponseWriter`?** - _High betweenness centrality (0.030) - this node is a cross-community bridge._ +- **Why does `Open()` connect `Store Tests` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `Handler`, `displayChapter`?** + _High betweenness centrality (0.057) - this node is a cross-community bridge._ +- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Handler`, `Session Middleware`, `Store Tests`, `displayChapter`, `ResponseWriter`?** + _High betweenness centrality (0.051) - this node is a cross-community bridge._ +- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Handler`, `displayChapter`, `ResponseWriter`?** + _High betweenness centrality (0.031) - this node is a cross-community bridge._ - **Are the 25 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?** _`newTestStore()` has 25 INFERRED edges - model-reasoned connections that need verification._ - **Are the 48 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?** diff --git a/graphify-out/graph.html b/graphify-out/graph.html index c77ae56..07eaff4 100644 --- a/graphify-out/graph.html +++ b/graphify-out/graph.html @@ -63,12 +63,12 @@
-
1736 nodes · 3597 edges · 221 communities
+
1739 nodes · 3610 edges · 217 communities