fix(#948): guard STATE.md no-op writes; preserve milestone_name/stopped_at (closes #944) (#952)

* test(#948): add regression tests for no-op write guard and record-session auto-create (#944)

Red before fix: 11/15 tests fail. Green after: 15/15.
Covers zero-match patch byte-identity, milestone_name preservation,
stopped_at frontmatter-wins, record-session auto-create fallback, and
adversarial fixtures (CRLF, empty body, non-canonical labels).

Also registers bug-948-state-noop-write-guard.test.cjs in the state
bucket of lint-test-file-count.allowlist.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#948): guard STATE.md no-op writes; preserve milestone_name/stopped_at (closes #944)

Shared root cause: `readModifyWriteStateMd` wrote STATE.md unconditionally
even when the transform produced no change, and `syncStateFrontmatter`
re-derived frontmatter from the possibly-stale body on every write.

Three coordinated fixes in src/state.cts:

1. readModifyWriteStateMd: add no-op guard — when transform result ===
   input content, skip the write entirely (no platformWriteSync, no
   last_updated bump, no frontmatter re-derive). Fixes #948 zero-match
   phantom write and the #944 phantom last_updated bump.

2. syncStateFrontmatter: extend existing-frontmatter preserve logic —
   fall back to existingFm['milestone_name'] / existingFm['milestone']
   when the derived value is the template placeholder 'milestone'
   (getMilestoneInfo returns this literal when it cannot match the
   version in ROADMAP.md); prefer existingFm['stopped_at'] /
   existingFm['paused_at'] over a body-derived value (the frontmatter
   value, written by the canonical record-session path, wins over stale
   historical body lines). Mirrors the fallback already in cmdStateJson.

3. cmdStateRecordSession: when --stopped-at / --resume-file are supplied
   but body labels are absent, DWIM auto-create a canonical ## Session
   section (mirroring how add-decision / add-blocker / record-metric
   auto-create their sections). Never return a silent recorded:false when
   the caller supplied values.

SDK check: no sdk/src/state.ts exists in this repo (the comment in
cmdStateSnapshot references a sibling concern in the TypeScript SDK
codebase, which is a separate repo not present here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: add changeset for PR #952 (fix #948/#944)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#948): correct stopped_at preserve rule; adjust test for sync behaviour

The "always prefer frontmatter stopped_at" rule in syncStateFrontmatter
was too aggressive — it broke phase.complete which intentionally updates
stopped_at in the body and expects syncStateFrontmatter to pick it up.

The primary fix (no-op guard in readModifyWriteStateMd) already prevents
the stale-body-overwrites-frontmatter scenario from #948: the file is not
written when the transform produces no change, so syncStateFrontmatter
never runs on a zero-match patch. The body-derived value can only win when
an actual write occurs, which means the body was legitimately updated.

Reverted to the original #905 rule for stopped_at/paused_at: fall back to
existing frontmatter only when the derived value is absent (empty/null).

Also adjusted the sync-suite test to assert what state sync actually does
(milestone_name preservation) rather than a stopped_at-wins property that
state sync does not have by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#944): update existing session block in place (adversarial review)

HIGH finding: the DWIM auto-create in cmdStateRecordSession was appending
a second ## Session block unconditionally, even when one already existed
with non-canonical content (e.g. a markdown table). Both
buildStateFrontmatter and cmdStateSnapshot read only the FIRST ## Session
block via regex, so the newly-written Stopped at / Resume file values
landed in the second, invisible block — frontmatter stopped_at stayed
stale and state-snapshot returned nulls.

Fix: check for an existing ## Session heading. When one is present,
normalize that section in place by replacing its body with canonical
**Last session:** / **Stopped at:** / **Resume file:** bold-label lines.
Only append a brand-new section when NO ## Session heading exists.

LOW finding: the auto-create scaffold emits **Last session:** but
cmdStateSnapshot only matched **Last Date:**, so session.last_date was
null after auto-create despite a valid timestamp being written.

Fix: extend the lastDateMatch regex in cmdStateSnapshot to also accept
**Last session:** / Last session: (the form the scaffold writes).

Tests: 3 new tests added to bug-948-state-noop-write-guard.test.cjs that
confirmed failure against the previous HEAD and pass after this fix:
- exactly one ## Session block after record-session with non-canonical existing block
- state-snapshot sees correct stopped_at via first Session block (not a duplicate)
- state-snapshot session.last_date is non-null after auto-create on body-less file

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#944): improve in-place section replace to cleanly remove old body content

The previous regex `/(^## Session[ \t]*$)([\s\S]*?)(?=\n^## |\n*$)/im`
with a lazy match consumed nothing after the heading, so old non-canonical
body content (e.g. table rows) remained after the new canonical lines.

While functionally correct (parsers found the canonical lines first in the
FIRST ## Session block), it left stale content in the section. Replace with
a negative-lookahead per-line pattern that consumes all content from the
heading up to (but not including) the next ## heading, producing a clean
section with only the canonical bold-label lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-10 00:22:29 -04:00
committed by GitHub
parent baa169abe0
commit 46967baae8
4 changed files with 827 additions and 2 deletions

View File

@@ -713,6 +713,7 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
const now = realClock.nowIso();
const updated: string[] = [];
let sessionCreated = false;
readModifyWriteStateMd(statePath, (content) => {
// Update Last session / Last Date
@@ -755,11 +756,85 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
}
}
// Bug #944: DWIM normalize/auto-create — when the caller supplied --stopped-at or
// --resume-file but the body lacks the canonical labels (in-place replace
// returned a miss), persist the values durably. Mirrors the DWIM pattern used
// by add-decision, add-blocker, and record-metric. Never silently drop
// caller-supplied values.
//
// Guard: only act when the caller actually supplied a value. When no
// --stopped-at / --resume-file are given and the body already had no session
// labels (nothing was updated), we return recorded:false — the existing
// behaviour for a no-op call that didn't supply any values.
//
// Correctness invariant: both buildStateFrontmatter and cmdStateSnapshot read
// only the FIRST `## Session` block (via a /##\s*Session\s*\n…/i regex).
// If we blindly append a second `## Session` block when one already exists, the
// newly-written Stopped at / Resume file end up in the second (invisible) block.
// Fix: when a `## Session` heading already exists, normalize THAT block in place
// (insert / replace canonical bold-label lines within the existing section).
// Only append a brand-new section when NO `## Session` heading exists at all.
const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
const resumeValue = (options.resume_file !== undefined && options.resume_file !== null)
? options.resume_file
: 'None';
const stoppedAtValue = options.stopped_at || 'None';
// Determine whether a ## Session heading already exists in the body.
const existingSessionHeading = /^## Session\s*$/im.test(content);
if (existingSessionHeading) {
// Normalize in place: replace the ENTIRE BODY of the existing ## Session
// section (heading + all content up to the next ## heading or EOF) with
// canonical bold-label lines. The negative-lookahead per-line pattern
// `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
// which correctly stops at the next section boundary without consuming it.
// A trailing blank line is added so the next ## heading keeps its spacing.
content = content.replace(
/^(## Session[ \t]*\n(?:(?!^## )[\s\S])*)/m,
[
'## Session',
'',
`**Last session:** ${now}`,
`**Stopped at:** ${stoppedAtValue}`,
`**Resume file:** ${resumeValue}`,
'',
'',
].join('\n'),
);
} else {
// No ## Session heading exists at all — append a new canonical section.
const scaffold = [
'',
'## Session',
'',
`**Last session:** ${now}`,
`**Stopped at:** ${stoppedAtValue}`,
`**Resume file:** ${resumeValue}`,
'',
].join('\n');
content = content.trimEnd() + '\n' + scaffold;
}
sessionCreated = true;
if (needsLastSession) updated.push('Last session');
if (needsStoppedAt) updated.push('Stopped At');
if (needsResumeFile) updated.push('Resume File');
}
return content;
}, cwd);
if (updated.length > 0) {
output({ recorded: true, updated }, raw, 'true');
const result: Record<string, unknown> = { recorded: true, updated };
if (sessionCreated) result['created'] = true;
output(result, raw, 'true');
} else {
output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
}
@@ -850,8 +925,12 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void {
const sessionMatch = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
if (sessionMatch) {
const sessionSection = sessionMatch[1];
// Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
// (the form written by the DWIM auto-create / normalize path added for #944).
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
|| sessionSection.match(/^Last Date:\s*(.+)/im);
|| sessionSection.match(/^Last Date:\s*(.+)/im)
|| sessionSection.match(/\*\*Last session:\*\*\s*(.+)/i)
|| sessionSection.match(/^Last session:\s*(.+)/im);
const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
|| sessionSection.match(/^Stopped At:\s*(.+)/im);
const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
@@ -1073,12 +1152,42 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string
derivedFm['status'] = existingFm['status'];
}
// Bug #948: preserve `milestone_name` / `milestone` when the derived value
// is the template placeholder 'milestone'. getMilestoneInfo returns the
// literal string 'milestone' when it cannot match the version from the roadmap
// (e.g. no ROADMAP.md, roadmap lacks the heading for the stored version, or the
// milestone version read from STATE.md itself triggers the lookup before the
// file is fully written). A placeholder must never overwrite a real name that the
// existing frontmatter already holds; only an empty derived value falls through
// to this guard (the primary #905 preserve path below handles that).
const MILESTONE_NAME_PLACEHOLDER = 'milestone';
if (
derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER &&
existingFm['milestone_name'] &&
existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER
) {
derivedFm['milestone_name'] = existingFm['milestone_name'];
// Keep the stored milestone version consistent with the preserved name.
if (existingFm['milestone']) {
derivedFm['milestone'] = existingFm['milestone'];
}
}
// Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
// from body annotations (Current Phase:, Current Plan:, etc.). When those
// annotations are absent — e.g. after an agent or tool rewrites the body —
// buildStateFrontmatter returns no value for those keys. Mirror the same
// fallback pattern used in cmdStateJson so the existing frontmatter values
// survive every writeStateMd call.
//
// For stopped_at / paused_at: the original #905 "fall back when derived is
// absent" rule is preserved here. The stale-body-overwrites-frontmatter
// scenario from #948 is prevented by the no-op guard in
// readModifyWriteStateMd: when the transform produces no change the file is
// never written, so syncStateFrontmatter never even runs. Attempting to
// "always prefer frontmatter" here breaks legitimate callers like phase.complete
// that intentionally write a new stopped_at value to the body and expect
// syncStateFrontmatter to pick it up.
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
derivedFm['stopped_at'] = existingFm['stopped_at'];
}
@@ -1247,6 +1356,18 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
// restore it when resync is false.
const preFm = resync ? null : extractFrontmatter(content) as Record<string, unknown>;
const modified = transformFn(content);
// Bug #948: no-op guard — if the transform produced no change, do NOT write
// the file. An unconditional write would bump `last_updated`, reset
// `milestone_name` to the template placeholder, and resurrect stale
// body-derived `stopped_at` values via syncStateFrontmatter. Skipping the
// write when content is unchanged is safe because every caller that mutates
// content already returns the mutated string, and callers that detect a
// no-op explicitly return the original content unchanged.
if (modified === content) {
return;
}
let synced = syncStateFrontmatter(modified, cwd);
if (!resync && preFm && preFm['progress']) {