fix: force-add .gsd/ planning artifacts and guard handleAgentEnd reentrancy (#341)

Two bugs that interact to silently kill auto-mode:

1. smartStage() uses `git add -A` which respects .gitignore. When .gsd/
   is gitignored (common — GSD's own baseline patterns only ignore runtime
   files, but many projects ignore all of .gsd/), new planning artifacts
   (CONTEXT.md, SUMMARY.md, PLAN.md, UAT.md, DECISIONS.md) are never
   staged. They exist on disk but not in git. Squash-merges then delete
   them on main because they appear as "removed relative to main."

   Fix: after `git add -A`, force-add `.gsd/milestones/` and root
   planning files. Runtime paths are still excluded by the subsequent
   `git reset HEAD` step.

2. handleAgentEnd() has no reentrancy guard. Background job notifications
   (async_bash results) trigger additional agent_end events while the
   first handler is still running (it yields at every await). Concurrent
   dispatchNextUnit() calls race on newSession() — one cancels the other,
   silently stopping auto-mode. Combined with bug #1, the second
   dispatchNextUnit call may find the active milestone's CONTEXT.md
   missing (never committed, lost during branch switch) and stop with
   "No context or roadmap yet."

   Fix: boolean guard prevents concurrent execution. Reset in stopAuto()
   so restarts aren't blocked.

Fixes #TBD

Co-authored-by: TÂCHES <afromanguy@me.com>
This commit is contained in:
deseltrus 2026-03-14 14:00:07 +01:00 committed by GitHub
parent 9c1bf837fb
commit c13b1bfc6e
2 changed files with 48 additions and 0 deletions

View file

@ -276,6 +276,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
currentMilestoneId = null;
cachedSliceProgress = null;
pendingCrashRecovery = null;
_handlingAgentEnd = false;
ctx?.ui.setStatus("gsd-auto", undefined);
ctx?.ui.setWidget("gsd-progress", undefined);
ctx?.ui.setFooter(undefined);
@ -526,11 +527,23 @@ export async function startAuto(
// ─── Agent End Handler ────────────────────────────────────────────────────────
/** Guard against concurrent handleAgentEnd execution. Background job
* notifications and other system messages can trigger multiple agent_end
* events before the first handler finishes (the handler yields at every
* await). Without this guard, concurrent dispatchNextUnit calls race on
* newSession(), causing one to cancel the other and silently stopping
* auto-mode. */
let _handlingAgentEnd = false;
export async function handleAgentEnd(
ctx: ExtensionContext,
pi: ExtensionAPI,
): Promise<void> {
if (!active || !cmdCtx) return;
if (_handlingAgentEnd) return;
_handlingAgentEnd = true;
try {
// Unit completed — clear its timeout
clearUnitTimeout();
@ -580,6 +593,10 @@ export async function handleAgentEnd(
}
await dispatchNextUnit(ctx, pi);
} finally {
_handlingAgentEnd = false;
}
}
// ─── Step Mode Wizard ─────────────────────────────────────────────────────

View file

@ -98,6 +98,22 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
".gsd/STATE.md",
];
/**
* GSD planning artifact paths that must be force-added even when .gsd/
* is in .gitignore. These are durable planning files that the agent writes
* and that must survive squash-merges to main.
*
* `git add --force` is a no-op when the path doesn't exist or has no
* changes, so this list is safe to apply unconditionally.
*/
const GSD_DURABLE_PATHS: readonly string[] = [
".gsd/milestones/",
".gsd/DECISIONS.md",
".gsd/QUEUE.md",
".gsd/PROJECT.md",
".gsd/REQUIREMENTS.md",
];
// ─── Integration Branch Metadata ───────────────────────────────────────────
/**
@ -291,6 +307,21 @@ export class GitServiceImpl {
// git reset HEAD silently succeeds when the path isn't staged, so no
// error handling is needed per-path.
this.git(["add", "-A"]);
// Force-add GSD planning artifacts that live under .gsd/ but may be
// blocked by a .gsd/ gitignore pattern. `git add -A` respects .gitignore,
// so new files (CONTEXT.md, SUMMARY.md, PLAN.md, etc.) in gitignored
// directories are silently skipped. Without this force-add, planning
// artifacts are never committed — they exist on disk but not in git.
// Squash-merges then delete them on main because they appear as "removed
// relative to main" during the merge.
//
// Only force-add durable planning paths — runtime paths are excluded
// by the reset step below.
for (const durablePath of GSD_DURABLE_PATHS) {
this.git(["add", "--force", "--", durablePath], { allowFailure: true });
}
for (const exclusion of allExclusions) {
this.git(["reset", "HEAD", "--", exclusion], { allowFailure: true });
}