From 3c1e358a2d8f5985f692a30b75d464f7b66eb775 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 7 Aug 2026 05:32:49 -0400 Subject: [PATCH] fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic for unusable last_activity (#3139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .changeset/steady-wasps-roam.md | 5 ++ src/smart-entry.cts | 12 +++++ src/unusable-input.cts | 10 ++++ tests/smart-entry.unit.test.cjs | 86 +++++++++++++++++++++++++++++++++ tests/unusable-input.test.cjs | 2 +- 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 .changeset/steady-wasps-roam.md diff --git a/.changeset/steady-wasps-roam.md b/.changeset/steady-wasps-roam.md new file mode 100644 index 000000000..e1fdc2083 --- /dev/null +++ b/.changeset/steady-wasps-roam.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3139 +--- +**Unusable `last_activity` now emits a diagnostic** — a present-but-unparseable `last_activity` in STATE.md silently suppressed the idle-stranded recommendation. The fallback (`stale_activity: false`) stays for continuity, but a `last_activity_unparseable` warning is now emitted so the degradation is visible. (#3099) diff --git a/src/smart-entry.cts b/src/smart-entry.cts index 50e2c358e..20a81c711 100644 --- a/src/smart-entry.cts +++ b/src/smart-entry.cts @@ -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 diff --git a/src/unusable-input.cts b/src/unusable-input.cts index 96c7f5af6..83206d385 100644 --- a/src/unusable-input.cts +++ b/src/unusable-input.cts @@ -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> = 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 ────────────────────────────────────────────────────────────── diff --git a/tests/smart-entry.unit.test.cjs b/tests/smart-entry.unit.test.cjs index a76c228ad..2a54e3f2c 100644 --- a/tests/smart-entry.unit.test.cjs +++ b/tests/smart-entry.unit.test.cjs @@ -549,3 +549,89 @@ describe('#2427 — roadmap-grounded completion + tightened status regex', () => `legacy fallback must still reject completion when current_phase < total_phases. Got: ${result.situation}`); }); }); + +// --------------------------------------------------------------------------- +// #3099: unusable last_activity emits a diagnostic (ADR-1411 amendment: +// corrupt is not absent — the fallback stays, the silence is the defect) +// --------------------------------------------------------------------------- + +describe('#3099: unusable last_activity emits a diagnostic', () => { + const { + _resetUnusableInputWarningsForTests, + _unusableInputEmissionCountForTests, + } = require('../gsd-core/bin/lib/unusable-input.cjs'); + + function makeStateWithActivity(activity) { + return [ + '---', + 'status: executing', + `last_activity: ${activity}`, + '---', + '', + '# Project State', + '', + 'Phase: 1', + '', + ].join('\n'); + } + + test('unusable last_activity still resolves stale_activity: false (fallback unchanged)', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: makeStateWithActivity('yesterday - did some work'), + })); + const signals = detectSignals(dir); + assert.equal(signals.stale_activity, false, + 'unusable last_activity must still resolve stale_activity: false (continuity is correct)'); + }); + + test('unusable last_activity emits LAST_ACTIVITY_UNPARSEABLE diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: makeStateWithActivity('not-a-date-at-all'), + })); + detectSignals(dir); + assert.equal(_unusableInputEmissionCountForTests(), 1, + 'unusable last_activity must emit exactly one diagnostic'); + }); + + test('absent last_activity emits nothing (distinguishable from unusable)', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: [ + '---', + 'status: executing', + '---', + '', + '# Project State', + '', + 'Phase: 1', + '', + ].join('\n'), + })); + detectSignals(dir); + assert.equal(_unusableInputEmissionCountForTests(), 0, + 'absent last_activity must NOT emit a diagnostic (it is genuinely absent, not corrupt)'); + }); + + test('well-formed last_activity emits nothing', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: makeStateWithActivity('2026-06-13T12:00:00Z'), + })); + detectSignals(dir); + assert.equal(_unusableInputEmissionCountForTests(), 0, + 'well-formed last_activity must NOT emit a diagnostic'); + }); + + test('unusable last_activity does not re-emit on second call (dedup)', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: makeStateWithActivity('gibberish'), + })); + detectSignals(dir); + detectSignals(dir); + assert.equal(_unusableInputEmissionCountForTests(), 1, + 'dedup: second call on the same source must not re-emit'); + }); +}); diff --git a/tests/unusable-input.test.cjs b/tests/unusable-input.test.cjs index c2da7a9bf..1d9f8549b 100644 --- a/tests/unusable-input.test.cjs +++ b/tests/unusable-input.test.cjs @@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => { // (enum + call site + this assertion) instead of a silent widening. assert.deepStrictEqual( Object.keys(UNUSABLE_REASON).sort(), - ['FRONTMATTER_UNTERMINATED', 'ROADMAP_UNREADABLE'], + ['FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE'], ); assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated'); });