Skip to content

feat(loa): rework LOA cache into history store (#158)#161

Merged
SyniRon merged 3 commits into
developfrom
agent/issue-158
Jun 4, 2026
Merged

feat(loa): rework LOA cache into history store (#158)#161
SyniRon merged 3 commits into
developfrom
agent/issue-158

Conversation

@SyniRon

@SyniRon SyniRon commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Closes #158. Parent #157.

What changed

Rework the LOA store from a current-status cache into a history store. No user-facing change/awol and /loa render identically (regression-guarded by unchanged commands tests).

  • Multi-window per username, deduped by ThreadID. entries map[string]LOAEntrymap[string][]LOAEntry; refresh appends via upsertWindow (re-scan updates in place, never duplicates).
  • Retention replaces prune-on-expiry. isRetired(now) drops a window only when EndDate predates historyHorizon(now). Shared constant loaRetentionYears = 1 backs both retention and cold-start backfill lookback, so warm and cold-started caches converge on the identical window set.
  • New GetEntries(username) []LOAEntry — all retained windows, non-nil empty slice when none, returns a copy.
  • Read API preserved: GetEntry selects the most-relevant window (active > soonest-upcoming > most-recently-ended); IsOnLOA scans all windows; IsHealthy untouched.

Tests

Prune tests replaced with retention semantics (TestRefresh_RetainsEndedWindows, TestRefresh_RetentionBoundary); added multi-window, ThreadID-dedup, distinct-thread accumulation, GetEntries empty, and most-relevant-window coverage — all via the fakeLOAFetcher seam.

Gate

lint 0 issues · go mod tidy clean · go test ./... -race -cover pass · coverage floors OK (utils 89.5% ≥ 87%, commands 82.7% ≥ 81%) · build OK. Independently re-verified by orchestrator.

🤖 Generated with Claude Code

SyniRon and others added 3 commits June 4, 2026 14:42
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 SyniRon merged commit 9a5a208 into develop Jun 4, 2026
2 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LOA history store: multi-window per user, ThreadID-keyed, 1-year retention

1 participant