* 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user