fix(#4763): surface the displaced session record and pass --phase from the executor decision loop (#4919)
* test(#4763): failing-first — replaced-record payload and executor --phase pins * fix(#4763): surface the displaced session record and pass --phase from the executor decision loop state record-session keeps its last-writer-wins write (the recorded single-slot handoff design) but no longer displaces silently: when a non-empty Stopped At or authored Resume File record is replaced, the payload carries the full prior text under replacedRecord. Same-value rewrites, the insert path, and the #944 template-default DWIM are not displacements and report nothing. The executor decision loop now passes --phase "${PHASE}" to state.add-decision, matching execute-plan.md, so decisions stop inheriting whichever phase the global pointer names (#4763 case 2). advance-plan is unchanged (#3311 by-design). Emitted-Drift-Ack-Growth: gsd-executor.md — the decision loop gained its --phase guard and a comment naming why (#4763) * docs(#4763): add the changeset fragment * docs(#4763): backfill the changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1961,11 +1961,32 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
||||
const updated: string[] = [];
|
||||
let sessionCreated = false;
|
||||
const divergedFields: string[] = [];
|
||||
// #4763 (1): last-writer-wins stays (the recorded single-slot handoff design),
|
||||
// but a displaced record is no longer silent. The pre-write session record is
|
||||
// captured below and surfaced in the payload under `replacedRecord` whenever a
|
||||
// replacement actually changes a non-empty prior value.
|
||||
const priorRecord: { stoppedAt?: string; resumeFile?: string } = {};
|
||||
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
||||
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
||||
const preWriteState: StatePreWriteSnapshot = {};
|
||||
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
// #4763 (1): read the pre-write session record. The capture mirrors the
|
||||
// WRITER, not the snapshot reader: stateReplaceField replaces the FIRST
|
||||
// case-insensitive label match anywhere in the document, so the capture is
|
||||
// document-wide too — scoping it to ## Session would miss an archive-section
|
||||
// line the writer actually displaced. Continuation lines join via
|
||||
// stateFieldContinuation (the sanctioned joiner — stateExtractField alone is
|
||||
// first-line-only), so a wrapped multi-line handoff is surfaced whole.
|
||||
const capturePrior = (fieldName: string): string | undefined => {
|
||||
const first = stateExtractField(content, fieldName);
|
||||
if (first === null) return undefined;
|
||||
const cont = stateFieldContinuation(content, fieldName);
|
||||
return cont ? `${first}\n${cont}` : first;
|
||||
};
|
||||
priorRecord.stoppedAt = capturePrior('Stopped At');
|
||||
priorRecord.resumeFile = capturePrior('Resume File');
|
||||
|
||||
// Update Last session / Last Date
|
||||
let result = stateReplaceField(content, 'Last session', now);
|
||||
if (result) { content = result; updated.push('Last session'); }
|
||||
@@ -2181,6 +2202,27 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions,
|
||||
if (reconciledUpdated.length > 0) {
|
||||
const result: Record<string, unknown> = { recorded: true, updated: reconciledUpdated };
|
||||
if (sessionCreated) result['created'] = true;
|
||||
// #4763 (1): surface any non-empty prior record the write displaced. Gated
|
||||
// on reconciledUpdated (the fields that actually persisted, post-#3957
|
||||
// reconcile) rather than the pre-reconciliation updated[]. Only fields with
|
||||
// real prior content differing from the caller's value count — same-value
|
||||
// rewrites, the insert path (no prior label), and the #944 template-default
|
||||
// DWIM (defaults match case-insensitively, so a case-variant 'none' →
|
||||
// 'None' rewrite is normalization, not displacement) are excluded.
|
||||
const isResumeTemplateDefault = priorRecord.resumeFile !== undefined
|
||||
&& KNOWN_TEMPLATE_DEFAULTS['Resume File'].some(
|
||||
(d) => d.toLowerCase() === priorRecord.resumeFile?.toLowerCase());
|
||||
const replacedRecord: Record<string, string> = {};
|
||||
if (reconciledUpdated.includes('Stopped At') && priorRecord.stoppedAt
|
||||
&& priorRecord.stoppedAt !== options.stopped_at) {
|
||||
replacedRecord['Stopped At'] = priorRecord.stoppedAt;
|
||||
}
|
||||
if (reconciledUpdated.includes('Resume File') && priorRecord.resumeFile
|
||||
&& !isResumeTemplateDefault
|
||||
&& priorRecord.resumeFile !== (options.resume_file ?? undefined)) {
|
||||
replacedRecord['Resume File'] = priorRecord.resumeFile;
|
||||
}
|
||||
if (Object.keys(replacedRecord).length > 0) result['replacedRecord'] = replacedRecord;
|
||||
output(result, raw, 'true');
|
||||
} else if (updated.length === 0) {
|
||||
// Nothing was ever attempted — no --stopped-at/--resume-file supplied
|
||||
|
||||
Reference in New Issue
Block a user