Skip to content

fix: enforce 48h data retention on AIC usage cache entries#39084

Merged
pelikhan merged 2 commits into
mainfrom
copilot/investigate-fix-cache-lookup
Jun 13, 2026
Merged

fix: enforce 48h data retention on AIC usage cache entries#39084
pelikhan merged 2 commits into
mainfrom
copilot/investigate-fix-cache-lookup

Conversation

Copilot AI commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Cache entries were stored as {run_id, aic} with no timestamp, making age-based pruning impossible and allowing stale data to accumulate indefinitely. The cache miss seen in the referenced CI run is expected on first activation (activation always runs before conclusion, so only prior runs' caches are eligible), but retention still needed enforcing.

Changes

write_daily_aic_usage_cache.cjs

  • Stamps each new entry with timestamp: new Date().toISOString()
  • Prunes entries older than 48 h before appending; entries without timestamp are preserved for backward compatibility
  • Extracts core logic into mainWithPaths(cachePath, usageDir) to enable unit testing

check_daily_aic_workflow_guardrail.cjsloadAICUsageCache

  • Skips entries with a timestamp older than 48 h; no-timestamp entries are still loaded
  • Adds skippedStale to the diagnostic log line

Tests

  • New write_daily_aic_usage_cache.test.cjs: timestamp presence, 48 h pruning, preserve-recent, preserve-no-timestamp, skip-when-no-run-id
  • Extended check_daily_aic_workflow_guardrail.test.cjs: recent timestamp kept, stale timestamp skipped, missing timestamp kept
// before
{"run_id":27468597133,"aic":142.5}
// after
{"run_id":27468597133,"aic":142.5,"timestamp":"2026-06-13T12:00:00.000Z"}

Copilot AI and others added 2 commits June 13, 2026 14:25
- Add `timestamp` field to entries written by write_daily_aic_usage_cache.cjs
- Prune entries older than 48h when writing the cache file
- Filter entries older than 48h when loading the cache in check_daily_aic_workflow_guardrail.cjs
- Both write and load preserve entries without a timestamp (backward compat)
- Add CACHE_RETENTION_MS = 48h constant to both files
- Export mainWithPaths() for testability
- Add write_daily_aic_usage_cache.test.cjs with 5 tests
- Extend check_daily_aic_workflow_guardrail.test.cjs with 3 new timestamp tests

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title fix: add 48h timestamp-based retention to AIC usage cache fix: enforce 48h data retention on AIC usage cache entries Jun 13, 2026
Copilot AI requested a review from pelikhan June 13, 2026 14:27
@pelikhan pelikhan marked this pull request as ready for review June 13, 2026 14:31
Copilot AI review requested due to automatic review settings June 13, 2026 14:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enforces a 48-hour retention policy for the daily AI Credits (AIC) usage cache by adding timestamps to cache entries, pruning stale entries on write, and skipping stale entries on read, with unit tests covering the new behavior.

Changes:

  • Add timestamp to new AIC cache entries and prune timestamped entries older than 48 hours when rewriting the cache.
  • Update cache loading to ignore timestamped entries older than 48 hours and include skippedStale in diagnostics.
  • Add/extend Vitest coverage for timestamp stamping, pruning, and backward compatibility (no timestamp).
Show a summary per file
File Description
actions/setup/js/write_daily_aic_usage_cache.cjs Adds timestamp stamping and 48h pruning on cache rewrite; introduces mainWithPaths for testability.
actions/setup/js/check_daily_aic_workflow_guardrail.cjs Skips cache entries older than 48h when loading and logs skippedStale.
actions/setup/js/write_daily_aic_usage_cache.test.cjs New tests validating timestamp presence and 48h pruning/compat behavior.
actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs Extends tests to cover recent/stale/missing timestamp handling.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 3

Comment on lines +133 to +139
if (typeof entry?.timestamp === "string") {
const ts = Date.parse(entry.timestamp);
if (Number.isFinite(ts) && ts < cutoff) {
skippedStale++;
continue;
}
}
Comment on lines +51 to +53
// Patch the module-level path constants by calling main() on the already-imported
// module; to make the paths configurable we call the exported helper that accepts
// explicit path arguments (defined below), falling back to swapping env vars.
Comment on lines +57 to +71
it("writes a new entry with run_id, aic, and a timestamp when no cache file exists", async () => {
writeUsageFile(7.5);
await runMain();

const content = fs.readFileSync(cacheFile, "utf8").trim();
const entry = JSON.parse(content);
expect(entry.run_id).toBe(12345);
expect(entry.aic).toBe(7.5);
expect(typeof entry.timestamp).toBe("string");
// Timestamp should be a valid ISO 8601 date within the last minute.
const ts = Date.parse(entry.timestamp);
expect(Number.isFinite(ts)).toBe(true);
expect(ts).toBeLessThanOrEqual(Date.now());
expect(ts).toBeGreaterThan(Date.now() - 60_000);
});
@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #39084 does not have the 'implementation' label and has 0 new lines of code in business logic directories (≤100 threshold). Neither ADR gate condition is met.

@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions github-actions Bot mentioned this pull request Jun 13, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /diagnose, /tdd, and /zoom-out — approving with minor suggestions.

The core fix is correct: timestamps are stamped atomically on write, pruned on both the read and write paths, and the backward-compat contract for no-timestamp entries is explicit and consistent across both files. The mainWithPaths extraction is a clean testability refactor with no production behaviour change.

📋 Key Themes & Highlights

Key Themes

  • DRY risk: CACHE_RETENTION_MS is defined independently in two files. Worth extracting to a shared constants module before the value ever needs to change.
  • Test coverage gaps: Missing boundary test (ts === cutoff → kept) and missing invalid-timestamp test. Both behaviours are correct but untested, which means a future refactor could silently break them.
  • Backward-compat convergence: No-timestamp entries are preserved indefinitely (by design), but the comment does not explain when full pruning enforcement is expected to kick in — helpful context for future maintainers.

Positive Highlights

  • Symmetric pruning enforcement in both the write and read paths
  • Number.isFinite guard on Date.parse result handles corrupt timestamps gracefully
  • Diagnostic log (skippedStale, pruned, total) makes retention behaviour observable in CI logs
  • 8 new test cases with meaningful margins (1 h, 2 h, 49 h, 50 h) — no brittle sub-second timing
  • mainWithPaths export is a minimal, non-breaking API surface for testing

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 510.4 AIC · ⌖ 47.9 AIC · ⊞ 27.9K

const PRIMARY_GUARDRAIL_ARTIFACT_NAMES = ["usage"];
const DAILY_WORKFLOW_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Cache entries older than this threshold (in ms) are skipped when loading. */
const CACHE_RETENTION_MS = 48 * 60 * 60 * 1000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] CACHE_RETENTION_MS is defined identically in both check_daily_aic_workflow_guardrail.cjs and write_daily_aic_usage_cache.cjs. If the retention window changes, both files must be updated atomically — easy to miss.

💡 Suggestion

Extract the constant into a shared module (e.g. daily_aic_constants.cjs) and require it in both files:

// daily_aic_constants.cjs
module.exports = {
  CACHE_RETENTION_MS: 48 * 60 * 60 * 1000,
};

This also makes it clear that both the write and read paths are intentionally coupled to the same window.

// Read existing cache content (restored from the previous run's cache snapshot, if any).
let existingLines = "";
// Entries with a `timestamp` older than CACHE_RETENTION_MS are pruned to keep the file
// bounded. Entries without a `timestamp` (written by an older version of this script)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] No-timestamp entries are preserved forever — they are never pruned by the write path, and never skipped by the read path. This is intentional for migration safety, but it means caches that existed before this PR deployed will carry unbounded legacy entries until GitHub Actions expires the cache key naturally (typically 7 days).

💡 Suggestion

Consider documenting the expected convergence timeline in the comment, e.g.:

// Entries without a `timestamp` (written by an older version of this script before YYYY-MM-DD)
// are preserved indefinitely until the GitHub Actions cache key expires (~7 days).
// After all active cache files have cycled through at least one write with the new code,
// every entry will have a timestamp and pruning will be fully enforced.

This helps future maintainers understand when the backward-compat clause can safely be removed.

async function runMain() {
// Patch the module-level path constants by calling main() on the already-imported
// module; to make the paths configurable we call the exported helper that accepts
// explicit path arguments (defined below), falling back to swapping env vars.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The comment "falling back to swapping env vars" describes a strategy that does not exist in this implementation. runMain() always calls mainWithPaths directly with explicit paths — there is no env-var fallback.

Suggest simplifying the comment:

async function runMain() {
  await exports.mainWithPaths(cacheFile, usageDir);
}

delete process.env.GITHUB_RUN_ID;
writeUsageFile(5.0);
await runMain();
expect(fs.existsSync(cacheFile)).toBe(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test verifies the cache file is not created, but does not assert that core.warning was called. When GITHUB_RUN_ID is unset, the module calls core.warning("[daily-aic-cache] GITHUB_RUN_ID not set; skipping cache write.") — worth asserting to confirm the skip is explicitly signalled and not silently swallowed.

💡 Suggested addition
it("skips writing when GITHUB_RUN_ID is not set", async () => {
  delete process.env.GITHUB_RUN_ID;
  writeUsageFile(5.0);
  await runMain();
  expect(fs.existsSync(cacheFile)).toBe(false);
  expect(global.core.warning).toHaveBeenCalledWith(
    expect.stringContaining("GITHUB_RUN_ID not set")
  );
});

writeUsageFile(5.0);
await runMain();
expect(fs.existsSync(cacheFile)).toBe(false);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Two edge cases are missing from the test suite:

  1. Invalid timestamp string — e.g. { run_id: 9001, aic: 1.0, timestamp: "not-a-date" }. The code handles this correctly (Date.parse returns NaN, Number.isFinite(NaN) is false, entry is kept), but without a test this silent-keep behaviour could regress unnoticed.

  2. Exact boundary — an entry timestamped exactly Date.now() - CACHE_RETENTION_MS. The check is ts < cutoff (strict), so boundary entries are kept — a test pinning this would prevent an accidental <= change.

💡 Suggested tests
it("keeps entries with an unparseable timestamp (treats as no-timestamp)", async () => {
  fs.writeFileSync(cacheFile, JSON.stringify({ run_id: 9001, aic: 1.0, timestamp: "not-a-date" }) + "\n", "utf8");
  writeUsageFile(0);
  await runMain();
  const lines = fs.readFileSync(cacheFile, "utf8").trim().split("\n");
  expect(lines.map(l => JSON.parse(l).run_id)).toContain(9001);
});

it("keeps an entry at exactly the 48 h cutoff boundary", async () => {
  const boundaryTs = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
  fs.writeFileSync(cacheFile, JSON.stringify({ run_id: 9002, aic: 2.0, timestamp: boundaryTs }) + "\n", "utf8");
  writeUsageFile(0);
  await runMain();
  const lines = fs.readFileSync(cacheFile, "utf8").trim().split("\n");
  expect(lines.map(l => JSON.parse(l).run_id)).toContain(9002);
});

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 83/100 — Excellent

Analyzed 8 test(s): 8 design, 0 implementation, 0 guideline violation(s).

📊 Metrics & Test Classification (8 tests analyzed)
Metric Value
New/modified tests analyzed 8
✅ Design tests (behavioral contracts) 8 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 6 (75%)
Duplicate test clusters 0
Test inflation detected ⚠️ Marginal (write_daily_aic_usage_cache: 123 added / 61 production = 2.02:1)
🚨 Coding-guideline violations 0

Test Classification Details

Test File Classification Notes
loads entries that have a recent timestamp (within 48 h) check_daily_aic_workflow_guardrail.test.cjs ✅ Design Positive case: verifies 1-hour-old entry is retained
skips entries whose timestamp is older than 48 h check_daily_aic_workflow_guardrail.test.cjs ✅ Design Boundary: 49h stale + 30-min recent; both behaviors asserted
keeps entries without a timestamp (backward compatibility) check_daily_aic_workflow_guardrail.test.cjs ✅ Design Edge case: missing timestamp field preserved
writes a new entry with run_id, aic, and a timestamp when no cache file exists write_daily_aic_usage_cache.test.cjs ✅ Design Initial state; 6 assertions including ISO 8601 validity and recency window
appends to an existing cache file and preserves entries within 48 h write_daily_aic_usage_cache.test.cjs ✅ Design Append + count + field values all verified
prunes existing entries whose timestamp is older than 48 h write_daily_aic_usage_cache.test.cjs ✅ Design Core retention contract: 50h stale removed, 1h recent kept
preserves entries without a timestamp (backward compatibility) write_daily_aic_usage_cache.test.cjs ✅ Design Backward compat: no-timestamp entries survive pruning
skips writing when GITHUB_RUN_ID is not set write_daily_aic_usage_cache.test.cjs ✅ Design Error case: missing env var; no file created

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 0 tests
  • 🟨 JavaScript (*.test.cjs): 8 tests (vitest)

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%). All 8 tests assert observable, user-facing behavior covering the 48-hour data-retention contract, backward compatibility, and error paths. The marginal 2.02:1 test-to-production line ratio on write_daily_aic_usage_cache.test.cjs technically triggers the inflation flag but reflects thorough behavioral coverage across 5 distinct scenarios — not padding.

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

References: §27469570702

🧪 Test quality analysis by Test Quality Sentinel · 441.4 AIC · ⌖ 35.1 AIC · ⊞ 27.3K ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 83/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). All 8 new tests assert observable behavioral contracts covering the 48h data-retention feature, backward compatibility, and error paths.

@pelikhan pelikhan merged commit fd3630c into main Jun 13, 2026
60 of 70 checks passed
@pelikhan pelikhan deleted the copilot/investigate-fix-cache-lookup branch June 13, 2026 15:24
Copilot stopped work on behalf of pelikhan due to an error June 13, 2026 15:25
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.

3 participants