fix(gsd): use project root for prior-slice dispatch guard (#2863)

Resolve the prior-slice completion guard against originalBasePath when auto-mode is running in a worktree. This keeps completed upstream milestones from blocking new dispatches because their SUMMARY state lives at the project root, not the stale worktree snapshot.

Closes #2838

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
mastertyko 2026-03-27 18:29:03 +01:00 committed by GitHub
parent 27f66100e5
commit 36930694e4
2 changed files with 69 additions and 2 deletions

View file

@ -45,6 +45,17 @@ export function _resolveReportBasePath(s: Pick<AutoSession, "originalBasePath" |
return s.originalBasePath || s.basePath;
}
/**
* Resolve the authoritative project base for dispatch guards.
* Prior-milestone completion lives at the project root, even when the active
* unit is running inside an auto worktree.
*/
export function _resolveDispatchGuardBasePath(
s: Pick<AutoSession, "originalBasePath" | "basePath">,
): string {
return s.originalBasePath || s.basePath;
}
/**
* Generate and write an HTML milestone report snapshot.
* Extracted from the milestone-transition block in autoLoop.
@ -667,9 +678,10 @@ export async function runDispatch(
prompt = preDispatchResult.prompt;
}
const guardBasePath = _resolveDispatchGuardBasePath(s);
const priorSliceBlocker = deps.getPriorSliceCompletionBlocker(
s.basePath,
deps.getMainBranch(s.basePath),
guardBasePath,
deps.getMainBranch(guardBasePath),
unitType,
unitId,
);

View file

@ -260,6 +260,61 @@ test("runDispatch emits dispatch-stop when dispatch returns stop action", async
assert.equal(stopEvents[0].flowId, ic.flowId);
});
test("runDispatch checks prior-slice completion against the project root in worktree mode", async () => {
const capture = createEventCapture();
const guardCalls: Array<{ fn: string; args: unknown[] }> = [];
const deps = makeMockDeps(capture, {
getMainBranch: (basePath: string) => {
guardCalls.push({ fn: "getMainBranch", args: [basePath] });
return "main";
},
getPriorSliceCompletionBlocker: (
basePath: string,
mainBranch: string,
unitType: string,
unitId: string,
) => {
guardCalls.push({
fn: "getPriorSliceCompletionBlocker",
args: [basePath, mainBranch, unitType, unitId],
});
return null;
},
});
const ic = makeIC(deps, {
s: {
...makeSession(),
basePath: "/tmp/project/.gsd/worktrees/M029-xoklo9",
originalBasePath: "/tmp/project",
} as any,
});
const preData: PreDispatchData = {
state: {
phase: "executing",
activeMilestone: { id: "M029-xoklo9", title: "Test", status: "active" },
activeSlice: { id: "S01", title: "Slice 1" },
registry: [{ id: "M029-xoklo9", status: "active" }],
blockers: [],
} as any,
mid: "M029-xoklo9",
midTitle: "Test Milestone",
};
const result = await runDispatch(ic, preData, {
recentUnits: [],
stuckRecoveryAttempts: 0,
});
assert.equal(result.action, "next");
assert.deepEqual(guardCalls, [
{ fn: "getMainBranch", args: ["/tmp/project"] },
{
fn: "getPriorSliceCompletionBlocker",
args: ["/tmp/project", "main", "execute-task", "M001/S01/T01"],
},
]);
});
test("runUnitPhase emits unit-start and unit-end with causedBy reference", async () => {
const capture = createEventCapture();