* 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:
5
.changeset/steady-wasps-roam.md
Normal file
5
.changeset/steady-wasps-roam.md
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user