feat(loa): rework LOA cache into history store (#158)#161
Merged
Conversation
Multi-window per forum username, deduped by ThreadID, replacing the single-overwrite entry model. Retention replaces prune-on-expiry: ended windows survive until EndDate predates a shared 1-year horizon (loaRetentionYears), so warm and cold-started caches converge on the identical window set. Add GetEntries(username) returning all retained windows. GetEntry, IsOnLOA, IsHealthy keep their contracts via mostRelevant selection, so /awol and /loa render identically. Prune tests updated to retention semantics; dedup/multi-window/retention covered via fakeLOAFetcher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sts) Address the review handoff on the LOA history-store rework: - docs: reword refresh/Refresh comments — refresh drops windows past the retention horizon (keeping recently-ended ones as history) then applies fetched posts; Refresh references historyHorizon/loaRetentionYears instead of the hardcoded "past year" prose so it can't drift. - refactor: delete dead isExpiredAt (only its own test referenced it; the prune loop uses isRetired) and its test; scrub hasEnded/isActiveAt comments that cited isExpiredAt as a live coupling-partner / prune cutoff. - S1: guard threadID==0 in upsertWindow — treat 0 as non-dedupable and Warn, so a stray 0-thread LOA can't silently fold every such window into one. - S4: collapse /awol's two separate cache reads (IsOnLOA + GetEntry) into a single GetEntry snapshot per user; OnLOA is now derived from that snapshot via the new exported LOAEntry.IsActive, so a concurrent refresh can't make the [[LOA]] link disagree with the OnLOA verdict. Drops the now-unused IsOnLOA from the loaCacheReader interface (concrete method retained). - S3: keep /loa rendering one window/user via GetEntry (no GetEntries iteration); pin the choice with a comment + test. Tests added (item 3, mutation-flagged gaps): two simultaneously-active windows → latest-ending wins; exact isRetired/historyHorizon boundary (±1 tick); mixed-window in-place compaction; GetEntries copy isolation; zero-ThreadID non-dedup; single-window /loa contract; S4 single-snapshot-per-user. No user-facing change to /loa or /awol — regression-guarded by commands tests. Coverage: utils 89.4%, commands 82.8%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-review items 1, 3, 5 (items 2/4/6 deferred to #159): 1. Fix stale loaCacheView doc in commands/loa.go: it claimed /awol's loaCacheReader "needs IsOnLOA but not IsHealthy" — both false after 41251b4. Describe the real distinction: per-command minimal surfaces over the same cache, /awol additionally gating on health for the #96 On-LOA column. 3. Correct unhealthy-cache comments in commands/awol.go (#96 block + the loaCacheReader interface doc): IsHealthy is an age check, so an unhealthy cache is stale not empty — GetEntry may still return outdated windows. The safety is the cacheHealthy render gate forcing the column to "unknown", not GetEntry returning nothing. 5. Delete the now-caller-less LOACache.IsOnLOA method (dropped from the loaCacheReader interface in 41251b4), matching the isExpiredAt dead-code removal precedent in this PR. Remove TestIsOnLOA_ActiveWindowBoundaries (its exact-edge boundary contract is already owned, deterministically, by TestLOAEntry_isActiveAt) and the dead IsOnLOA methods on the test fakes; prune trailing IsOnLOA references in comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SyniRon
added a commit
that referenced
this pull request
Jun 4, 2026
…162) * feat(awol): accountable-day count + days-AWOL severity report Implements #159 (parent PRD #157, ADR 0008). /awol now flags on accountable days — UTC calendar dates in (lastPost, today] not covered by a valid LOA window — instead of raw time since last forum post, and renders the agreed days-AWOL severity report. - New pure calc utils.AccountableDaysAWOL(lastPost, now, []LOAEntry) int: candidate set minus the union of inclusive [Start,End] LOA ranges, overage past the 7-day requirement. Overlap/adjacent/multiple/future windows fall out of set coverage; backwards ranges are ignored and DEBUG-logged. Exhaustively table-tested. Companion utils.RawDaysAWOL / utils.DaysSinceLastPost and a clock-injected utils.ActiveWindow selector. - loaCacheReader gains GetEntries (drops GetEntry); diverges from /loa's loaCacheView, which stays {GetEntry, IsHealthy} and single-window (#161 #6). - AwolUser models the LOA backing as *utils.LOAEntry (nil = none) instead of the zero-value LOAEntry + HasLOAEntry pair (#161 #4). - Active-window selection and the accountable-day verdict both run against the handler's single injected now off one GetEntries read per user, so selection and verdict can't disagree at a boundary (#161 #2). - Render: worst-first by days AWOL (tie-break username), severity glyphs (🔴 >14 · 🟠 >7 · 🟡 >0 · ⚪ active LOA), milpac + [LOA] thread links, "N total · M LOA" summary, "AWOL days subtracts valid LOA days." footer. - Degraded mode (unhealthy cache): raw inactivity, NO LOA subtraction, loud "accountable-day adjustment SKIPPED; LOA NOT subtracted" warning on both embed and file paths; summary reads "LOA unknown", never a silent non-LOA all-clear. /loa is unchanged. No-LOA troopers render raw == accountable as before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(awol): keep embed description within Discord 4096 limit + warn on degraded empty result Reserve the summary-line prefix + separator length when sizing the per-chunk budget so the final embed description (summary + "\n\n" + chunk) can never exceed Discord's 4096-char cap (was: raw chunk capped at 4096, then ~18 bytes of prefix prepended → API 400). Also surface the degraded/SKIPPED warning on the empty-result reply when the LOA cache is unhealthy, so an unhealthy cache is never reported as a silent all-clear (every other terminal degraded path already warns). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(awol): drop zero-value LOA windows from the accountable-day calc validLOAWindows previously only dropped end-before-start ranges; a zero-value LOAEntry (both dates zero) slipped through and would "cover" the epoch-zero UTC date. Unreachable from production today (parseLOAPost only emits when both dates parse) but cheap to harden. Also reword the coveredByLOA/validLOAWindows comments: a zero-width range (StartDate == EndDate) is VALID and covers exactly one day — only end-before-start covers nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(awol): drop orphaned LOAEntry.IsActive + correct stale interface comments IsActive had zero callers after this PR switched /awol to ActiveWindow/isActiveAt (`rg IsActive` shows only its own def). Delete it and scrub its stale doc comment. Correct loaCacheReader's doc (it is {GetEntries,IsHealthy}, a different surface from /loa's {GetEntry,IsHealthy}, not a superset) and /loa's loaCacheView comment that still claimed /awol's reader had the same {GetEntry,IsHealthy} shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(awol): pin embed-limit + degraded-empty paths and boundary coverage gaps New behavioral coverage: - embed description stays <= 4096 with a near-limit accumulated chunk - unhealthy cache + empty list surfaces the SKIPPED warning; healthy stays all-clear New boundary/contract coverage: - DaysSinceLastPost no-clamp contract vs RawDaysAWOL; rawAccountableDates early-return guard - severityGlyph EXACT tier edges (14->orange, 7->yellow) + active-LOA override - ActiveWindow now==StartDate inclusive edge and the no-active (_, false) return - validLOAWindows drops a zero-value entry; mixed slice still subtracts the valid window Also fix test fixture comment drift (expired-LOA EndDate, scratch glyph notes) and the IsActive reference in the isActiveAt doc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(awol): make embed-limit guard real + relabel calc-level smoke Rework TestRunAwol_EmbedDescriptionWithinDiscordLimit so a chunk provably lands in the danger band (4077, 4096]: 51 no-LOA rows render at exactly 80 bytes each, so the pre-fix budget (4096) packs all 51 into one chunk (19-byte summary prefix + 4080 = 4099 > 4096) while the prefix-aware budget (4096 - 19 = 4077) flushes after 50 rows (4019 <= 4096). The old ~139-byte lines stepped over the band, so the test passed even against the un-fixed budget. Assert on bytes (the conservative production measure) across every emitted embed, and reference discordEmbedDescriptionLimit. Verified: PASS on current code, FAIL (4099 bytes) against a reverted budget, PASS after restore. Relabel TestAccountableDaysAWOL_MixedSliceValidStillSubtracts as a calc-level smoke test: it does not pin the validLOAWindows end-before-start filter (that branch is guarded by TestValidLOAWindows_MixedSliceKeepsValid); coveredByLOA's range check already covers nothing for a backwards window. No production logic changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #158. Parent #157.
What changed
Rework the LOA store from a current-status cache into a history store. No user-facing change —
/awoland/loarender identically (regression-guarded by unchangedcommandstests).ThreadID.entries map[string]LOAEntry→map[string][]LOAEntry; refresh appends viaupsertWindow(re-scan updates in place, never duplicates).isRetired(now)drops a window only whenEndDatepredateshistoryHorizon(now). Shared constantloaRetentionYears = 1backs both retention and cold-start backfill lookback, so warm and cold-started caches converge on the identical window set.GetEntries(username) []LOAEntry— all retained windows, non-nil empty slice when none, returns a copy.GetEntryselects the most-relevant window (active > soonest-upcoming > most-recently-ended);IsOnLOAscans all windows;IsHealthyuntouched.Tests
Prune tests replaced with retention semantics (
TestRefresh_RetainsEndedWindows,TestRefresh_RetentionBoundary); added multi-window, ThreadID-dedup, distinct-thread accumulation,GetEntriesempty, and most-relevant-window coverage — all via thefakeLOAFetcherseam.Gate
lint 0 issues ·
go mod tidyclean ·go test ./... -race -coverpass · coverage floors OK (utils 89.5% ≥ 87%, commands 82.7% ≥ 81%) · build OK. Independently re-verified by orchestrator.🤖 Generated with Claude Code