fix(#3311): milestone lock makes parallel-phase state conflicts visible (#3455)

* fix(#3311): milestone lock makes parallel-phase state conflicts visible

Two sessions running different phases in one working tree silently
clobbered STATE.md's single un-scoped ## Current Position slot: byte-level
serialization already existed (STATE.md.lock since #464), but nothing ever
surfaced that two sessions claimed two different phases, and
state.advance-plan — which takes no phase argument — kept advancing whatever
plan the just-clobbered position named.

Adds the maintainer-chosen milestone lock (issue #3311 comment): an advisory
.planning/milestone.lock claim keyed by phase + session id (session identity
via getWorkstreamSessionKey: env-first, then controlling TTY). begin-phase
claims (inside the STATE.md lock), advance-plan detects a claim/position
mismatch and heartbeats a matching claim, phase.complete warns via warnings[]
and releases the claim when the claimed phase completes. Conflicts warn
(stderr + typed milestone_conflict JSON field) instead of blocking, per the
decision's blocking/warning latitude; TTL 4h with heartbeat liveness expires
abandoned claims. milestone.lock is registered in the canonical artifact
registry so validate.health W019 recognizes it.

* chore(#3311): point changeset fragment at pr 3455

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 03:04:09 -04:00
committed by GitHub
parent 011153c076
commit 6b34557ba3
10 changed files with 1000 additions and 3 deletions

View File

@@ -83,6 +83,8 @@ const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = p
const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } =
planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
import milestoneLockMod = require('./milestone-lock.cjs');
const { extractFrontmatter } = frontmatterMod;
const {
readModifyWriteStateMd,
@@ -2248,7 +2250,28 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
let nextPhaseName: string | null = null;
let isLastPhase = true;
// #3311: typed conflict descriptor surfaced on the result JSON alongside the
// warnings[] entry below (same parity pattern as
// verification_stale_check_indeterminate).
let milestoneConflict: milestoneLockMod.MilestoneConflict | null = null;
const verificationBlocked = withPlanningLock(cwd, () => {
// #3311: completing a phase while a live milestone claim (phase + session)
// holds a DIFFERENT phase means two sessions are working two phases against
// the single Current Position slot. Warn via the established warnings[]
// channel (rendered by execute-phase.md's "If has_warnings is true" step)
// rather than blocking — the claim may simply be stale-but-live.
milestoneConflict = milestoneLockMod.checkMilestoneConflictForPhase(cwd, phaseNum);
if (milestoneConflict) {
const holder = milestoneConflict.locked_session ?? 'an unknown (headless) session';
const actor = milestoneConflict.session ?? 'an unknown (headless) session';
warnings.push(
`milestone lock conflict (#3311): ${holder} holds the milestone claim for phase ` +
`${milestoneConflict.locked_phase}, but ${actor} is completing phase ${phaseNum} — ` +
`STATE.md's Current Position is a single slot; verify it before trusting it`,
);
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `phase.complete ${phaseNum}`);
}
// #2617: pass the project's runtime so the blocked-completion error below
// suggests the command surface this runtime actually installs
// ($gsd-… on Codex) rather than a hard-coded Claude-style string.
@@ -2969,6 +2992,11 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
} else {
runPhaseCompleteTransaction();
}
// #3311: a successful completion of the CLAIMED phase releases the
// milestone claim — regardless of which session completes it (an
// orchestrator cleaning up after a dead session must not be blocked by the
// dead session's own claim). No-ops when the claim names another phase.
milestoneLockMod.releaseMilestonePhase(cwd, phaseNum);
return null;
});
@@ -3027,6 +3055,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
warnings,
has_warnings: warnings.length > 0,
verification_stale_check_indeterminate: staleCheckIndeterminate,
milestone_conflict: milestoneConflict,
};
output(result, raw);