fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic for unusable last_activity (#3139)

* fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic when last_activity is present but unparseable

parseActivityTimestamp returned null for both absent AND present-but-unusable
last_activity values, silently suppressing the idle-stranded recommendation.
Per ADR-1411's amendment (corrupt is not absent), the fallback stays but a
diagnostic is now emitted via the warnUnusableInput seam.

- Added LAST_ACTIVITY_UNPARSEABLE to UNUSABLE_REASON enum + prose
- Wired warnUnusableInput into detectSignals when lastActivityRaw is truthy
  but parseActivityTimestamp returned null
- Updated UNUSABLE_REASON lock test
- Added 5 regression tests (unusable→diagnostic, absent→silent, well-formed→silent, dedup)

* chore(#3099): add changeset fragment

* chore(#3099): backfill changeset PR number 3139

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-07 05:32:49 -04:00
committed by GitHub
parent 3146ff36aa
commit 3c1e358a2d
5 changed files with 114 additions and 1 deletions

View File

@@ -51,6 +51,9 @@ const { stateExtractField } = stateDocument;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseId = require('./phase-id.cjs');
const { comparePhaseNum, extractPhaseToken, normalizePhaseName, phaseTokenMatches } = phaseId;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInput = require('./unusable-input.cjs');
const { warnUnusableInput, UNUSABLE_REASON } = unusableInput;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -355,7 +358,16 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE
// Stale = no recorded activity for IDLE_STALE_MS. Used only by idle-stranded.
// Computed here (with the clock seam) so the pure classify() stays a function
// of (signals, staleActivity) and detectSignals owns all disk reads.
// #3099 (ADR-1411 amendment): if last_activity is present but unparseable,
// emit a diagnostic so the silent fallback (stale_activity: false) is visible.
// The fallback itself stays — continuity is correct, the silence was the defect.
const lastActivityMs = parseActivityTimestamp(lastActivityRaw);
if (lastActivityRaw && lastActivityMs === null) {
warnUnusableInput({
reason: UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE,
source: paths.state,
});
}
const staleActivity = lastActivityMs !== null && now() - lastActivityMs > IDLE_STALE_MS;
// Verify-failed may be signalled either by STATE.md status or by a failed

View File

@@ -49,6 +49,14 @@ const UNUSABLE_REASON = Object.freeze({
* simply has no ROADMAP yet: absence returns the same sentinel, silently. (#1881)
*/
ROADMAP_UNREADABLE: 'roadmap_unreadable',
/**
* A `last_activity` value in STATE.md frontmatter is present but unparseable as a date.
* Distinct from absent: absence means the field was never set, while an unparseable value
* means it was set to something Date.parse rejects. Per ADR-1411 amendment: corrupt is not
* absent — the fallback (stale_activity: false) stays, but a diagnostic is emitted so the
* silent degradation is visible. (#3099, sixth #1879 site)
*/
LAST_ACTIVITY_UNPARSEABLE: 'last_activity_unparseable',
} as const);
type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON];
@@ -59,6 +67,8 @@ const REASON_PROSE: Readonly<Record<UnusableReason, string>> = Object.freeze({
'frontmatter opens with "---" but never closes; metadata was NOT applied',
[UNUSABLE_REASON.ROADMAP_UNREADABLE]:
'ROADMAP.md exists but could not be read; phase and milestone lookups fell back to defaults',
[UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE]:
'last_activity in STATE.md is present but unparseable as a date; stale_activity fell back to false (idle-stranded suppressed)',
});
// ─── Dedup state ──────────────────────────────────────────────────────────────