From c13b1bfc6e4293d91b340de8631d0e7320e5bcee Mon Sep 17 00:00:00 2001 From: deseltrus <101901449+deseltrus@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:00:07 +0100 Subject: [PATCH] fix: force-add .gsd/ planning artifacts and guard handleAgentEnd reentrancy (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/resources/extensions/gsd/auto.ts | 17 +++++++++++ src/resources/extensions/gsd/git-service.ts | 31 +++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index f23e0fa36..a5d312c4b 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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 { 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 ───────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index be460082e..0866155a7 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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 }); }