gsd-pi 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +18 -1
- package/dist/resource-loader.d.ts +2 -0
- package/dist/resource-loader.js +36 -1
- package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
- package/dist/resources/extensions/gsd/auto.ts +222 -11
- package/dist/resources/extensions/gsd/doctor.ts +195 -1
- package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/dist/resources/extensions/gsd/git-service.ts +11 -0
- package/dist/resources/extensions/gsd/preferences.ts +17 -1
- package/dist/resources/extensions/gsd/prompts/system.md +32 -29
- package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
- package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
- package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
- package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
- package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
- package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
- package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
- package/packages/pi-coding-agent/src/cli/args.ts +1 -1
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
- package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
- package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
- package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
- package/packages/pi-tui/dist/components/editor.d.ts +11 -0
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +64 -6
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/components/editor.ts +71 -6
- package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
- package/src/resources/extensions/gsd/auto.ts +222 -11
- package/src/resources/extensions/gsd/doctor.ts +195 -1
- package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/src/resources/extensions/gsd/git-service.ts +11 -0
- package/src/resources/extensions/gsd/preferences.ts +17 -1
- package/src/resources/extensions/gsd/prompts/system.md +32 -29
- package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
- package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
- package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
- package/src/resources/extensions/search-the-web/native-search.ts +15 -10
|
@@ -86,6 +86,19 @@ import {
|
|
|
86
86
|
import { GitServiceImpl, runGit } from "./git-service.js";
|
|
87
87
|
import { nativeCommitCountBetween } from "./native-git-bridge.js";
|
|
88
88
|
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
|
|
89
|
+
import { formatGitError } from "./git-self-heal.js";
|
|
90
|
+
import {
|
|
91
|
+
createAutoWorktree,
|
|
92
|
+
enterAutoWorktree,
|
|
93
|
+
teardownAutoWorktree,
|
|
94
|
+
isInAutoWorktree,
|
|
95
|
+
getAutoWorktreePath,
|
|
96
|
+
getAutoWorktreeOriginalBase,
|
|
97
|
+
mergeSliceToMilestone,
|
|
98
|
+
mergeMilestoneToMain,
|
|
99
|
+
shouldUseWorktreeIsolation,
|
|
100
|
+
getMergeToMainMode,
|
|
101
|
+
} from "./auto-worktree.js";
|
|
89
102
|
import type { GitPreferences } from "./git-service.js";
|
|
90
103
|
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
|
91
104
|
import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
|
|
@@ -144,6 +157,7 @@ let stepMode = false;
|
|
|
144
157
|
let verbose = false;
|
|
145
158
|
let cmdCtx: ExtensionCommandContext | null = null;
|
|
146
159
|
let basePath = "";
|
|
160
|
+
let originalBasePath = "";
|
|
147
161
|
let gitService: GitServiceImpl | null = null;
|
|
148
162
|
|
|
149
163
|
/** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
|
|
@@ -151,6 +165,12 @@ const unitDispatchCount = new Map<string, number>();
|
|
|
151
165
|
const MAX_UNIT_DISPATCHES = 3;
|
|
152
166
|
/** Retry index at which a stub summary placeholder is written when the summary is still absent. */
|
|
153
167
|
const STUB_RECOVERY_THRESHOLD = 2;
|
|
168
|
+
/** Hard cap on total dispatches per unit across ALL reconciliation cycles.
|
|
169
|
+
* unitDispatchCount can be reset by loop-recovery/self-repair paths, but this
|
|
170
|
+
* counter is never reset — it catches infinite reconciliation loops where
|
|
171
|
+
* artifacts exist but deriveState keeps returning the same unit. */
|
|
172
|
+
const unitLifetimeDispatches = new Map<string, number>();
|
|
173
|
+
const MAX_LIFETIME_DISPATCHES = 6;
|
|
154
174
|
|
|
155
175
|
/** Tracks recovery attempt count per unit for backoff and diagnostics. */
|
|
156
176
|
const unitRecoveryCount = new Map<string, number>();
|
|
@@ -343,6 +363,27 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
|
|
|
343
363
|
|
|
344
364
|
// Remove SIGTERM handler registered at auto-mode start
|
|
345
365
|
deregisterSigtermHandler();
|
|
366
|
+
|
|
367
|
+
// ── Auto-worktree: exit worktree and reset basePath on stop ──
|
|
368
|
+
if (currentMilestoneId && isInAutoWorktree(basePath)) {
|
|
369
|
+
try {
|
|
370
|
+
teardownAutoWorktree(originalBasePath, currentMilestoneId);
|
|
371
|
+
basePath = originalBasePath;
|
|
372
|
+
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
373
|
+
ctx?.ui.notify("Exited auto-worktree.", "info");
|
|
374
|
+
} catch (err) {
|
|
375
|
+
ctx?.ui.notify(
|
|
376
|
+
`Auto-worktree teardown failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
377
|
+
"warning",
|
|
378
|
+
);
|
|
379
|
+
// Force basePath back to original even if teardown failed
|
|
380
|
+
if (originalBasePath) {
|
|
381
|
+
basePath = originalBasePath;
|
|
382
|
+
try { process.chdir(basePath); } catch { /* best-effort */ }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
346
387
|
const ledger = getLedger();
|
|
347
388
|
if (ledger && ledger.units.length > 0) {
|
|
348
389
|
const totals = getProjectTotals(ledger.units);
|
|
@@ -367,8 +408,10 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
|
|
|
367
408
|
stepMode = false;
|
|
368
409
|
unitDispatchCount.clear();
|
|
369
410
|
unitRecoveryCount.clear();
|
|
411
|
+
unitLifetimeDispatches.clear();
|
|
370
412
|
currentUnit = null;
|
|
371
413
|
currentMilestoneId = null;
|
|
414
|
+
originalBasePath = "";
|
|
372
415
|
cachedSliceProgress = null;
|
|
373
416
|
pendingCrashRecovery = null;
|
|
374
417
|
_handlingAgentEnd = false;
|
|
@@ -518,10 +561,17 @@ async function mergeOrphanedSliceBranches(
|
|
|
518
561
|
"info",
|
|
519
562
|
);
|
|
520
563
|
try {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
564
|
+
let mergeResult;
|
|
565
|
+
if (isInAutoWorktree(base) && getMergeToMainMode() !== "slice") {
|
|
566
|
+
mergeResult = mergeSliceToMilestone(
|
|
567
|
+
base, milestoneId, sliceId, sliceEntry.title || sliceId,
|
|
568
|
+
);
|
|
569
|
+
} else {
|
|
570
|
+
switchToMain(base);
|
|
571
|
+
mergeResult = mergeSliceToMain(
|
|
572
|
+
base, milestoneId, sliceId, sliceEntry.title || sliceId,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
525
575
|
ctx.ui.notify(
|
|
526
576
|
`Merged orphaned branch ${mergeResult.branch} → ${mainBranch}.`,
|
|
527
577
|
"info",
|
|
@@ -568,13 +618,38 @@ export async function startAuto(
|
|
|
568
618
|
cmdCtx = ctx;
|
|
569
619
|
basePath = base;
|
|
570
620
|
unitDispatchCount.clear();
|
|
621
|
+
unitLifetimeDispatches.clear();
|
|
571
622
|
// Re-initialize metrics in case ledger was lost during pause
|
|
572
623
|
if (!getLedger()) initMetrics(base);
|
|
573
624
|
// Ensure milestone ID is set on git service for integration branch resolution
|
|
574
625
|
if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
|
|
575
626
|
|
|
627
|
+
// ── Auto-worktree: re-enter worktree on resume if not already inside ──
|
|
628
|
+
if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && shouldUseWorktreeIsolation(originalBasePath)) {
|
|
629
|
+
try {
|
|
630
|
+
const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
|
|
631
|
+
if (existingWtPath) {
|
|
632
|
+
const wtPath = enterAutoWorktree(originalBasePath, currentMilestoneId);
|
|
633
|
+
basePath = wtPath;
|
|
634
|
+
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
635
|
+
ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info");
|
|
636
|
+
} else {
|
|
637
|
+
// Worktree was deleted while paused — recreate it.
|
|
638
|
+
const wtPath = createAutoWorktree(originalBasePath, currentMilestoneId);
|
|
639
|
+
basePath = wtPath;
|
|
640
|
+
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
641
|
+
ctx.ui.notify(`Recreated auto-worktree at ${wtPath}`, "info");
|
|
642
|
+
}
|
|
643
|
+
} catch (err) {
|
|
644
|
+
ctx.ui.notify(
|
|
645
|
+
`Auto-worktree re-entry failed: ${err instanceof Error ? err.message : String(err)}. Continuing at current path.`,
|
|
646
|
+
"warning",
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
576
651
|
// Re-register SIGTERM handler for the resumed session
|
|
577
|
-
registerSigtermHandler(
|
|
652
|
+
registerSigtermHandler(basePath);
|
|
578
653
|
|
|
579
654
|
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
|
|
580
655
|
ctx.ui.setFooter(hideFooter);
|
|
@@ -687,6 +762,7 @@ export async function startAuto(
|
|
|
687
762
|
basePath = base;
|
|
688
763
|
unitDispatchCount.clear();
|
|
689
764
|
unitRecoveryCount.clear();
|
|
765
|
+
unitLifetimeDispatches.clear();
|
|
690
766
|
completedKeySet.clear();
|
|
691
767
|
loadPersistedKeys(base, completedKeySet);
|
|
692
768
|
resetHookState();
|
|
@@ -710,6 +786,36 @@ export async function startAuto(
|
|
|
710
786
|
setActiveMilestoneId(base, currentMilestoneId);
|
|
711
787
|
}
|
|
712
788
|
|
|
789
|
+
// ── Auto-worktree: create or enter worktree for the active milestone ──
|
|
790
|
+
// Store the original project root before any chdir so we can restore on stop.
|
|
791
|
+
originalBasePath = base;
|
|
792
|
+
if (currentMilestoneId && shouldUseWorktreeIsolation(base)) {
|
|
793
|
+
try {
|
|
794
|
+
const existingWtPath = getAutoWorktreePath(base, currentMilestoneId);
|
|
795
|
+
if (existingWtPath) {
|
|
796
|
+
// Worktree already exists (e.g., previous session created it) — enter it.
|
|
797
|
+
const wtPath = enterAutoWorktree(base, currentMilestoneId);
|
|
798
|
+
basePath = wtPath;
|
|
799
|
+
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
800
|
+
ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info");
|
|
801
|
+
} else {
|
|
802
|
+
// Fresh start — create worktree and enter it.
|
|
803
|
+
const wtPath = createAutoWorktree(base, currentMilestoneId);
|
|
804
|
+
basePath = wtPath;
|
|
805
|
+
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
806
|
+
ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info");
|
|
807
|
+
}
|
|
808
|
+
// Re-register SIGTERM handler with the new basePath
|
|
809
|
+
registerSigtermHandler(basePath);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
// Worktree creation is non-fatal — continue in the project root.
|
|
812
|
+
ctx.ui.notify(
|
|
813
|
+
`Auto-worktree setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`,
|
|
814
|
+
"warning",
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
713
819
|
// Initialize metrics — loads existing ledger from disk
|
|
714
820
|
initMetrics(base);
|
|
715
821
|
|
|
@@ -827,6 +933,24 @@ export async function handleAgentEnd(
|
|
|
827
933
|
} catch {
|
|
828
934
|
// Non-fatal
|
|
829
935
|
}
|
|
936
|
+
|
|
937
|
+
// ── Path A fix: verify artifact and persist completion before re-entering dispatch ──
|
|
938
|
+
// After doctor + rebuildState, check whether the just-completed unit actually
|
|
939
|
+
// produced its expected artifact. If so, persist the completion key now so the
|
|
940
|
+
// idempotency check at the top of dispatchNextUnit() skips it — even if
|
|
941
|
+
// deriveState() still returns this unit as active (e.g. branch mismatch).
|
|
942
|
+
try {
|
|
943
|
+
if (verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath)) {
|
|
944
|
+
const completionKey = `${currentUnit.type}/${currentUnit.id}`;
|
|
945
|
+
if (!completedKeySet.has(completionKey)) {
|
|
946
|
+
persistCompletedKey(basePath, completionKey);
|
|
947
|
+
completedKeySet.add(completionKey);
|
|
948
|
+
}
|
|
949
|
+
invalidateStateCache();
|
|
950
|
+
}
|
|
951
|
+
} catch {
|
|
952
|
+
// Non-fatal — worst case we fall through to normal dispatch which has its own checks
|
|
953
|
+
}
|
|
830
954
|
}
|
|
831
955
|
|
|
832
956
|
// ── Post-unit hooks: check if a configured hook should run before normal dispatch ──
|
|
@@ -1382,6 +1506,9 @@ async function dispatchNextUnit(
|
|
|
1382
1506
|
|
|
1383
1507
|
// Clear stale directory listing cache so deriveState sees fresh disk state (#431)
|
|
1384
1508
|
clearPathCache();
|
|
1509
|
+
// Clear parsed roadmap/plan cache — doctor may have re-populated it with
|
|
1510
|
+
// stale data between handleAgentEnd and this dispatch call (Path B fix).
|
|
1511
|
+
clearParseCache();
|
|
1385
1512
|
|
|
1386
1513
|
let state = await deriveState(basePath);
|
|
1387
1514
|
let mid = state.activeMilestone?.id;
|
|
@@ -1396,8 +1523,9 @@ async function dispatchNextUnit(
|
|
|
1396
1523
|
// Reset stuck detection for new milestone
|
|
1397
1524
|
unitDispatchCount.clear();
|
|
1398
1525
|
unitRecoveryCount.clear();
|
|
1526
|
+
unitLifetimeDispatches.clear();
|
|
1399
1527
|
// Capture integration branch for the new milestone and update git service
|
|
1400
|
-
captureIntegrationBranch(basePath, mid);
|
|
1528
|
+
captureIntegrationBranch(originalBasePath || basePath, mid);
|
|
1401
1529
|
}
|
|
1402
1530
|
if (mid) {
|
|
1403
1531
|
currentMilestoneId = mid;
|
|
@@ -1502,10 +1630,17 @@ async function dispatchNextUnit(
|
|
|
1502
1630
|
if (sliceEntry?.done) {
|
|
1503
1631
|
try {
|
|
1504
1632
|
const sliceTitleForMerge = sliceEntry.title || branchSid;
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1633
|
+
let mergeResult;
|
|
1634
|
+
if (isInAutoWorktree(basePath) && getMergeToMainMode() !== "slice") {
|
|
1635
|
+
mergeResult = mergeSliceToMilestone(
|
|
1636
|
+
basePath, branchMid, branchSid, sliceTitleForMerge,
|
|
1637
|
+
);
|
|
1638
|
+
} else {
|
|
1639
|
+
switchToMain(basePath);
|
|
1640
|
+
mergeResult = mergeSliceToMain(
|
|
1641
|
+
basePath, branchMid, branchSid, sliceTitleForMerge,
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1509
1644
|
const targetBranch = getMainBranch(basePath);
|
|
1510
1645
|
ctx.ui.notify(
|
|
1511
1646
|
`Merged ${mergeResult.branch} → ${targetBranch}.`,
|
|
@@ -1569,7 +1704,7 @@ async function dispatchNextUnit(
|
|
|
1569
1704
|
}
|
|
1570
1705
|
|
|
1571
1706
|
// Non-conflict errors: reset and stop
|
|
1572
|
-
const message = error instanceof Error ? error
|
|
1707
|
+
const message = formatGitError(error instanceof Error ? error : String(error));
|
|
1573
1708
|
try {
|
|
1574
1709
|
const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
|
|
1575
1710
|
if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
|
|
@@ -1626,6 +1761,27 @@ async function dispatchNextUnit(
|
|
|
1626
1761
|
if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
|
|
1627
1762
|
completedKeySet.clear();
|
|
1628
1763
|
} catch { /* non-fatal */ }
|
|
1764
|
+
|
|
1765
|
+
// ── Milestone merge: squash-merge milestone branch to main before stopping ──
|
|
1766
|
+
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath && getMergeToMainMode() === "milestone") {
|
|
1767
|
+
try {
|
|
1768
|
+
const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
|
|
1769
|
+
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1770
|
+
const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
|
|
1771
|
+
basePath = originalBasePath;
|
|
1772
|
+
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
1773
|
+
ctx.ui.notify(
|
|
1774
|
+
`Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1775
|
+
"info",
|
|
1776
|
+
);
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
ctx.ui.notify(
|
|
1779
|
+
`Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1780
|
+
"warning",
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1629
1785
|
await stopAuto(ctx, pi);
|
|
1630
1786
|
return;
|
|
1631
1787
|
}
|
|
@@ -1888,6 +2044,26 @@ async function dispatchNextUnit(
|
|
|
1888
2044
|
// Pattern A→B→A→B would reset retryCount every time; this map catches it.
|
|
1889
2045
|
const dispatchKey = `${unitType}/${unitId}`;
|
|
1890
2046
|
const prevCount = unitDispatchCount.get(dispatchKey) ?? 0;
|
|
2047
|
+
|
|
2048
|
+
// Hard lifetime cap — survives counter resets from loop-recovery/self-repair.
|
|
2049
|
+
// Catches the case where reconciliation "succeeds" (artifacts exist) but
|
|
2050
|
+
// deriveState keeps returning the same unit, creating an infinite cycle.
|
|
2051
|
+
const lifetimeCount = (unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1;
|
|
2052
|
+
unitLifetimeDispatches.set(dispatchKey, lifetimeCount);
|
|
2053
|
+
if (lifetimeCount > MAX_LIFETIME_DISPATCHES) {
|
|
2054
|
+
if (currentUnit) {
|
|
2055
|
+
const modelId = ctx.model?.id ?? "unknown";
|
|
2056
|
+
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
2057
|
+
}
|
|
2058
|
+
saveActivityLog(ctx, basePath, unitType, unitId);
|
|
2059
|
+
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
|
|
2060
|
+
await stopAuto(ctx, pi);
|
|
2061
|
+
ctx.ui.notify(
|
|
2062
|
+
`Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles). Stopping.${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`,
|
|
2063
|
+
"error",
|
|
2064
|
+
);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
1891
2067
|
if (prevCount >= MAX_UNIT_DISPATCHES) {
|
|
1892
2068
|
if (currentUnit) {
|
|
1893
2069
|
const modelId = ctx.model?.id ?? "unknown";
|
|
@@ -1912,7 +2088,13 @@ async function dispatchNextUnit(
|
|
|
1912
2088
|
`Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`,
|
|
1913
2089
|
"warning",
|
|
1914
2090
|
);
|
|
2091
|
+
// Persist completion so idempotency check prevents re-dispatch
|
|
2092
|
+
// if deriveState keeps returning this unit (#462).
|
|
2093
|
+
const reconciledKey = `${unitType}/${unitId}`;
|
|
2094
|
+
persistCompletedKey(basePath, reconciledKey);
|
|
2095
|
+
completedKeySet.add(reconciledKey);
|
|
1915
2096
|
unitDispatchCount.delete(dispatchKey);
|
|
2097
|
+
invalidateStateCache();
|
|
1916
2098
|
await new Promise(r => setImmediate(r));
|
|
1917
2099
|
await dispatchNextUnit(ctx, pi);
|
|
1918
2100
|
return;
|
|
@@ -1921,6 +2103,30 @@ async function dispatchNextUnit(
|
|
|
1921
2103
|
}
|
|
1922
2104
|
}
|
|
1923
2105
|
|
|
2106
|
+
// General reconciliation: if the last attempt DID produce the expected
|
|
2107
|
+
// artifact on disk, clear the counter and advance instead of stopping.
|
|
2108
|
+
// The execute-task path above handles its special case (writing placeholder
|
|
2109
|
+
// summaries). This catch-all covers complete-slice, plan-slice,
|
|
2110
|
+
// research-slice, and all other unit types where the Nth attempt at the
|
|
2111
|
+
// dispatch limit succeeded but the counter check fires before anyone
|
|
2112
|
+
// verifies disk state. Without this, a successful final attempt is
|
|
2113
|
+
// indistinguishable from a failed one.
|
|
2114
|
+
if (verifyExpectedArtifact(unitType, unitId, basePath)) {
|
|
2115
|
+
ctx.ui.notify(
|
|
2116
|
+
`Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`,
|
|
2117
|
+
"info",
|
|
2118
|
+
);
|
|
2119
|
+
// Persist completion so the idempotency check prevents re-dispatch
|
|
2120
|
+
// if deriveState keeps returning this unit (see #462).
|
|
2121
|
+
persistCompletedKey(basePath, dispatchKey);
|
|
2122
|
+
completedKeySet.add(dispatchKey);
|
|
2123
|
+
unitDispatchCount.delete(dispatchKey);
|
|
2124
|
+
invalidateStateCache();
|
|
2125
|
+
await new Promise(r => setImmediate(r));
|
|
2126
|
+
await dispatchNextUnit(ctx, pi);
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
1924
2130
|
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
|
|
1925
2131
|
const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
|
|
1926
2132
|
await stopAuto(ctx, pi);
|
|
@@ -1947,7 +2153,12 @@ async function dispatchNextUnit(
|
|
|
1947
2153
|
`Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`,
|
|
1948
2154
|
"warning",
|
|
1949
2155
|
);
|
|
2156
|
+
// Persist completion so idempotency check prevents re-dispatch (#462).
|
|
2157
|
+
const repairedKey = `${unitType}/${unitId}`;
|
|
2158
|
+
persistCompletedKey(basePath, repairedKey);
|
|
2159
|
+
completedKeySet.add(repairedKey);
|
|
1950
2160
|
unitDispatchCount.delete(dispatchKey);
|
|
2161
|
+
invalidateStateCache();
|
|
1951
2162
|
await new Promise(r => setImmediate(r));
|
|
1952
2163
|
await dispatchNextUnit(ctx, pi);
|
|
1953
2164
|
return;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
1
2
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
@@ -5,6 +6,9 @@ import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPla
|
|
|
5
6
|
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
|
|
6
7
|
import { deriveState, isMilestoneComplete } from "./state.js";
|
|
7
8
|
import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js";
|
|
9
|
+
import { listWorktrees } from "./worktree-manager.js";
|
|
10
|
+
import { abortAndReset } from "./git-self-heal.js";
|
|
11
|
+
import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
|
|
8
12
|
|
|
9
13
|
export type DoctorSeverity = "info" | "warning" | "error";
|
|
10
14
|
export type DoctorIssueCode =
|
|
@@ -23,7 +27,11 @@ export type DoctorIssueCode =
|
|
|
23
27
|
| "active_requirement_missing_owner"
|
|
24
28
|
| "blocked_requirement_missing_reason"
|
|
25
29
|
| "blocker_discovered_no_replan"
|
|
26
|
-
| "delimiter_in_title"
|
|
30
|
+
| "delimiter_in_title"
|
|
31
|
+
| "orphaned_auto_worktree"
|
|
32
|
+
| "stale_milestone_branch"
|
|
33
|
+
| "corrupt_merge_state"
|
|
34
|
+
| "tracked_runtime_files";
|
|
27
35
|
|
|
28
36
|
export interface DoctorIssue {
|
|
29
37
|
severity: DoctorSeverity;
|
|
@@ -451,6 +459,189 @@ export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
|
|
|
451
459
|
}).join("\n");
|
|
452
460
|
}
|
|
453
461
|
|
|
462
|
+
async function checkGitHealth(
|
|
463
|
+
basePath: string,
|
|
464
|
+
issues: DoctorIssue[],
|
|
465
|
+
fixesApplied: string[],
|
|
466
|
+
shouldFix: (code: DoctorIssueCode) => boolean,
|
|
467
|
+
): Promise<void> {
|
|
468
|
+
// Degrade gracefully if not a git repo
|
|
469
|
+
try {
|
|
470
|
+
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
|
|
471
|
+
} catch {
|
|
472
|
+
return; // Not a git repo — skip all git health checks
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const gitDir = join(basePath, ".git");
|
|
476
|
+
|
|
477
|
+
// ── Orphaned auto-worktrees ──────────────────────────────────────────
|
|
478
|
+
try {
|
|
479
|
+
const worktrees = listWorktrees(basePath);
|
|
480
|
+
const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/"));
|
|
481
|
+
|
|
482
|
+
// Load roadmap state once for cross-referencing
|
|
483
|
+
const state = await deriveState(basePath);
|
|
484
|
+
|
|
485
|
+
for (const wt of milestoneWorktrees) {
|
|
486
|
+
// Extract milestone ID from branch name "milestone/M001" → "M001"
|
|
487
|
+
const milestoneId = wt.branch.replace(/^milestone\//, "");
|
|
488
|
+
const milestoneEntry = state.registry.find(m => m.id === milestoneId);
|
|
489
|
+
|
|
490
|
+
// Check if milestone is complete via roadmap
|
|
491
|
+
let isComplete = false;
|
|
492
|
+
if (milestoneEntry) {
|
|
493
|
+
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
494
|
+
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
495
|
+
if (roadmapContent) {
|
|
496
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
497
|
+
isComplete = isMilestoneComplete(roadmap);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (isComplete) {
|
|
502
|
+
issues.push({
|
|
503
|
+
severity: "warning",
|
|
504
|
+
code: "orphaned_auto_worktree",
|
|
505
|
+
scope: "milestone",
|
|
506
|
+
unitId: milestoneId,
|
|
507
|
+
message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`,
|
|
508
|
+
fixable: true,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
if (shouldFix("orphaned_auto_worktree")) {
|
|
512
|
+
// Never remove a worktree matching current working directory
|
|
513
|
+
const cwd = process.cwd();
|
|
514
|
+
if (wt.path === cwd || cwd.startsWith(wt.path + "/")) {
|
|
515
|
+
fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
|
|
516
|
+
} else {
|
|
517
|
+
try {
|
|
518
|
+
execSync(`git worktree remove --force "${wt.path}"`, { cwd: basePath, stdio: "pipe" });
|
|
519
|
+
fixesApplied.push(`removed orphaned worktree ${wt.path}`);
|
|
520
|
+
} catch {
|
|
521
|
+
fixesApplied.push(`failed to remove worktree ${wt.path}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Stale milestone branches ─────────────────────────────────────────
|
|
529
|
+
try {
|
|
530
|
+
const branchOutput = execSync("git branch --list 'milestone/*'", { cwd: basePath, stdio: "pipe" }).toString().trim();
|
|
531
|
+
if (branchOutput) {
|
|
532
|
+
const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
|
|
533
|
+
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
|
|
534
|
+
|
|
535
|
+
for (const branch of branches) {
|
|
536
|
+
// Skip branches that have a worktree (handled above)
|
|
537
|
+
if (worktreeBranches.has(branch)) continue;
|
|
538
|
+
|
|
539
|
+
const milestoneId = branch.replace(/^milestone\//, "");
|
|
540
|
+
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
541
|
+
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
542
|
+
if (!roadmapContent) continue;
|
|
543
|
+
|
|
544
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
545
|
+
if (isMilestoneComplete(roadmap)) {
|
|
546
|
+
issues.push({
|
|
547
|
+
severity: "info",
|
|
548
|
+
code: "stale_milestone_branch",
|
|
549
|
+
scope: "milestone",
|
|
550
|
+
unitId: milestoneId,
|
|
551
|
+
message: `Branch ${branch} exists for completed milestone ${milestoneId}`,
|
|
552
|
+
fixable: true,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (shouldFix("stale_milestone_branch")) {
|
|
556
|
+
try {
|
|
557
|
+
execSync(`git branch -D "${branch}"`, { cwd: basePath, stdio: "pipe" });
|
|
558
|
+
fixesApplied.push(`deleted stale branch ${branch}`);
|
|
559
|
+
} catch {
|
|
560
|
+
fixesApplied.push(`failed to delete branch ${branch}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
// git branch list failed — skip stale branch check
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
// listWorktrees or deriveState failed — skip worktree/branch checks
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── Corrupt merge state ────────────────────────────────────────────────
|
|
574
|
+
try {
|
|
575
|
+
const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"];
|
|
576
|
+
const mergeStateDirs = ["rebase-apply", "rebase-merge"];
|
|
577
|
+
const found: string[] = [];
|
|
578
|
+
|
|
579
|
+
for (const f of mergeStateFiles) {
|
|
580
|
+
if (existsSync(join(gitDir, f))) found.push(f);
|
|
581
|
+
}
|
|
582
|
+
for (const d of mergeStateDirs) {
|
|
583
|
+
if (existsSync(join(gitDir, d))) found.push(d);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (found.length > 0) {
|
|
587
|
+
issues.push({
|
|
588
|
+
severity: "error",
|
|
589
|
+
code: "corrupt_merge_state",
|
|
590
|
+
scope: "project",
|
|
591
|
+
unitId: "project",
|
|
592
|
+
message: `Corrupt merge/rebase state detected: ${found.join(", ")}`,
|
|
593
|
+
fixable: true,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
if (shouldFix("corrupt_merge_state")) {
|
|
597
|
+
const result = abortAndReset(basePath);
|
|
598
|
+
fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
// Can't check .git dir — skip
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── Tracked runtime files ──────────────────────────────────────────────
|
|
606
|
+
try {
|
|
607
|
+
const trackedPaths: string[] = [];
|
|
608
|
+
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
|
609
|
+
try {
|
|
610
|
+
const output = execSync(`git ls-files "${exclusion}"`, { cwd: basePath, stdio: "pipe" }).toString().trim();
|
|
611
|
+
if (output) {
|
|
612
|
+
trackedPaths.push(...output.split("\n").filter(Boolean));
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
// Individual ls-files can fail — continue
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (trackedPaths.length > 0) {
|
|
620
|
+
issues.push({
|
|
621
|
+
severity: "warning",
|
|
622
|
+
code: "tracked_runtime_files",
|
|
623
|
+
scope: "project",
|
|
624
|
+
unitId: "project",
|
|
625
|
+
message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`,
|
|
626
|
+
fixable: true,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
if (shouldFix("tracked_runtime_files")) {
|
|
630
|
+
try {
|
|
631
|
+
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
|
632
|
+
execSync(`git rm --cached -r --ignore-unmatch "${exclusion}"`, { cwd: basePath, stdio: "pipe" });
|
|
633
|
+
}
|
|
634
|
+
fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
|
|
635
|
+
} catch {
|
|
636
|
+
fixesApplied.push("failed to untrack runtime files");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
} catch {
|
|
641
|
+
// git ls-files failed — skip
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
454
645
|
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {
|
|
455
646
|
const issues: DoctorIssue[] = [];
|
|
456
647
|
const fixesApplied: string[] = [];
|
|
@@ -491,6 +682,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
491
682
|
}
|
|
492
683
|
}
|
|
493
684
|
|
|
685
|
+
// Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files)
|
|
686
|
+
await checkGitHealth(basePath, issues, fixesApplied, shouldFix);
|
|
687
|
+
|
|
494
688
|
const milestonesPath = milestonesDir(basePath);
|
|
495
689
|
if (!existsSync(milestonesPath)) {
|
|
496
690
|
return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };
|