gsd-pi 2.13.0 → 2.14.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/README.md +3 -3
- package/dist/cli.js +1 -0
- package/dist/loader.js +50 -6
- package/dist/resource-loader.d.ts +7 -6
- package/dist/resource-loader.js +15 -8
- package/dist/resources/extensions/gsd/auto-worktree.ts +29 -183
- package/dist/resources/extensions/gsd/auto.ts +252 -370
- package/dist/resources/extensions/gsd/commands.ts +118 -34
- package/dist/resources/extensions/gsd/doctor.ts +29 -4
- package/dist/resources/extensions/gsd/git-self-heal.ts +0 -71
- package/dist/resources/extensions/gsd/git-service.ts +8 -431
- package/dist/resources/extensions/gsd/gitignore.ts +11 -4
- package/dist/resources/extensions/gsd/guided-flow.ts +141 -5
- package/dist/resources/extensions/gsd/preferences.ts +18 -17
- package/dist/resources/extensions/gsd/prompts/discuss.md +35 -0
- package/dist/resources/extensions/gsd/prompts/queue.md +7 -1
- package/dist/resources/extensions/gsd/state.ts +26 -8
- package/dist/resources/extensions/gsd/templates/state.md +0 -1
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
- package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
- package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
- package/dist/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
- package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
- package/dist/resources/extensions/gsd/tests/git-service.test.ts +11 -770
- package/dist/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
- package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
- package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
- package/dist/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
- package/dist/resources/extensions/gsd/tests/worktree.test.ts +1 -426
- package/dist/resources/extensions/gsd/types.ts +0 -1
- package/dist/resources/extensions/gsd/worktree-manager.ts +7 -3
- package/dist/resources/extensions/gsd/worktree.ts +7 -65
- package/dist/resources/extensions/search-the-web/command-search-provider.ts +3 -1
- package/package.json +1 -1
- package/packages/pi-ai/dist/providers/google.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google.js +12 -4
- package/packages/pi-ai/dist/providers/google.js.map +1 -1
- package/packages/pi-ai/dist/providers/mistral.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/mistral.js +10 -2
- package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
- package/packages/pi-ai/src/providers/google.ts +20 -8
- package/packages/pi-ai/src/providers/mistral.ts +14 -2
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts +3 -0
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +10 -7
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +4 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +12 -3
- package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +13 -9
- package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +4 -1
- package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +14 -3
- package/packages/pi-tui/dist/components/input.d.ts +1 -0
- package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/input.js +10 -0
- package/packages/pi-tui/dist/components/input.js.map +1 -1
- package/packages/pi-tui/src/components/input.ts +11 -0
- package/src/resources/extensions/gsd/auto-worktree.ts +29 -183
- package/src/resources/extensions/gsd/auto.ts +252 -370
- package/src/resources/extensions/gsd/commands.ts +118 -34
- package/src/resources/extensions/gsd/doctor.ts +29 -4
- package/src/resources/extensions/gsd/git-self-heal.ts +0 -71
- package/src/resources/extensions/gsd/git-service.ts +8 -431
- package/src/resources/extensions/gsd/gitignore.ts +11 -4
- package/src/resources/extensions/gsd/guided-flow.ts +141 -5
- package/src/resources/extensions/gsd/preferences.ts +18 -17
- package/src/resources/extensions/gsd/prompts/discuss.md +35 -0
- package/src/resources/extensions/gsd/prompts/queue.md +7 -1
- package/src/resources/extensions/gsd/state.ts +26 -8
- package/src/resources/extensions/gsd/templates/state.md +0 -1
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
- package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
- package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
- package/src/resources/extensions/gsd/tests/git-service.test.ts +11 -770
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
- package/src/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
- package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
- package/src/resources/extensions/gsd/tests/worktree.test.ts +1 -426
- package/src/resources/extensions/gsd/types.ts +0 -1
- package/src/resources/extensions/gsd/worktree-manager.ts +7 -3
- package/src/resources/extensions/gsd/worktree.ts +7 -65
- package/src/resources/extensions/search-the-web/command-search-provider.ts +3 -1
- package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
- package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
- package/dist/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
- package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
- package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
- package/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
|
@@ -69,22 +69,20 @@ import {
|
|
|
69
69
|
getProjectTotals, formatCost, formatTokenCount,
|
|
70
70
|
} from "./metrics.js";
|
|
71
71
|
import { dirname, join } from "node:path";
|
|
72
|
-
import {
|
|
72
|
+
import { sep as pathSep } from "node:path";
|
|
73
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, renameSync, statSync } from "node:fs";
|
|
73
74
|
import { execSync, execFileSync } from "node:child_process";
|
|
74
75
|
import {
|
|
75
76
|
autoCommitCurrentBranch,
|
|
76
77
|
captureIntegrationBranch,
|
|
77
|
-
|
|
78
|
+
detectWorktreeName,
|
|
78
79
|
getCurrentBranch,
|
|
79
80
|
getMainBranch,
|
|
80
81
|
MergeConflictError,
|
|
81
82
|
parseSliceBranch,
|
|
82
83
|
setActiveMilestoneId,
|
|
83
|
-
switchToMain,
|
|
84
|
-
mergeSliceToMain,
|
|
85
84
|
} from "./worktree.js";
|
|
86
85
|
import { GitServiceImpl, runGit } from "./git-service.js";
|
|
87
|
-
import { nativeCommitCountBetween } from "./native-git-bridge.js";
|
|
88
86
|
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
|
|
89
87
|
import { formatGitError } from "./git-self-heal.js";
|
|
90
88
|
import {
|
|
@@ -94,10 +92,7 @@ import {
|
|
|
94
92
|
isInAutoWorktree,
|
|
95
93
|
getAutoWorktreePath,
|
|
96
94
|
getAutoWorktreeOriginalBase,
|
|
97
|
-
mergeSliceToMilestone,
|
|
98
95
|
mergeMilestoneToMain,
|
|
99
|
-
shouldUseWorktreeIsolation,
|
|
100
|
-
getMergeToMainMode,
|
|
101
96
|
} from "./auto-worktree.js";
|
|
102
97
|
import type { GitPreferences } from "./git-service.js";
|
|
103
98
|
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
|
@@ -122,7 +117,10 @@ function persistCompletedKey(base: string, key: string): void {
|
|
|
122
117
|
} catch { /* corrupt file — start fresh */ }
|
|
123
118
|
if (!keys.includes(key)) {
|
|
124
119
|
keys.push(key);
|
|
125
|
-
|
|
120
|
+
// Atomic write: tmp file + rename prevents partial writes on crash
|
|
121
|
+
const tmpFile = file + ".tmp";
|
|
122
|
+
writeFileSync(tmpFile, JSON.stringify(keys), "utf-8");
|
|
123
|
+
renameSync(tmpFile, file);
|
|
126
124
|
}
|
|
127
125
|
}
|
|
128
126
|
|
|
@@ -360,6 +358,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
|
|
|
360
358
|
clearUnitTimeout();
|
|
361
359
|
if (basePath) clearLock(basePath);
|
|
362
360
|
clearSkillSnapshot();
|
|
361
|
+
_dispatching = false;
|
|
362
|
+
_skipDepth = 0;
|
|
363
363
|
|
|
364
364
|
// Remove SIGTERM handler registered at auto-mode start
|
|
365
365
|
deregisterSigtermHandler();
|
|
@@ -468,136 +468,41 @@ async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Prom
|
|
|
468
468
|
const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
|
|
469
469
|
const records = listUnitRuntimeRecords(base);
|
|
470
470
|
let healed = 0;
|
|
471
|
+
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
|
472
|
+
const now = Date.now();
|
|
471
473
|
for (const record of records) {
|
|
472
474
|
const { unitType, unitId } = record;
|
|
473
475
|
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
476
|
+
|
|
477
|
+
// Case 1: Artifact exists — unit completed but closeout didn't finish
|
|
474
478
|
if (artifactPath && existsSync(artifactPath)) {
|
|
475
|
-
// Artifact exists — unit completed but closeout didn't finish.
|
|
476
479
|
clearUnitRuntimeRecord(base, unitType, unitId);
|
|
480
|
+
// Also persist completion key if missing
|
|
481
|
+
const key = `${unitType}/${unitId}`;
|
|
482
|
+
if (!completedKeySet.has(key)) {
|
|
483
|
+
persistCompletedKey(base, key);
|
|
484
|
+
completedKeySet.add(key);
|
|
485
|
+
}
|
|
477
486
|
healed++;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Case 2: No artifact but record is stale (dispatched > 1h ago, process crashed)
|
|
491
|
+
const age = now - (record.startedAt ?? 0);
|
|
492
|
+
if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) {
|
|
493
|
+
clearUnitRuntimeRecord(base, unitType, unitId);
|
|
494
|
+
healed++;
|
|
495
|
+
continue;
|
|
478
496
|
}
|
|
479
497
|
}
|
|
480
498
|
if (healed > 0) {
|
|
481
|
-
ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s)
|
|
499
|
+
ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s).`, "info");
|
|
482
500
|
}
|
|
483
501
|
} catch {
|
|
484
502
|
// Non-fatal — self-heal should never block auto-mode start
|
|
485
503
|
}
|
|
486
504
|
}
|
|
487
505
|
|
|
488
|
-
/**
|
|
489
|
-
* Startup check: scan for orphaned completed slice branches and merge them.
|
|
490
|
-
*
|
|
491
|
-
* An orphaned completed slice branch is a `gsd/MID/SID` branch where the slice
|
|
492
|
-
* is marked done in the roadmap (on that branch) but hasn't been squash-merged
|
|
493
|
-
* to main yet. This happens when `complete-slice` succeeds and commits on the
|
|
494
|
-
* slice branch, but the subsequent merge to main is interrupted (crash, timeout,
|
|
495
|
-
* Ctrl+C, merge conflict that wasn't auto-resolved).
|
|
496
|
-
*
|
|
497
|
-
* Without this check, GSD gets stuck in an infinite loop: `deriveState()` on
|
|
498
|
-
* main sees no slice artifacts → wants research-slice → idempotency key removed
|
|
499
|
-
* (artifact not on main) → ensurePreconditions switches branch → merge guard
|
|
500
|
-
* merges → re-derives → repeats.
|
|
501
|
-
*/
|
|
502
|
-
async function mergeOrphanedSliceBranches(
|
|
503
|
-
base: string,
|
|
504
|
-
ctx: Pick<ExtensionContext, "ui">,
|
|
505
|
-
): Promise<void> {
|
|
506
|
-
// List all local gsd/<MID>/<SID> branches (non-worktree pattern).
|
|
507
|
-
// Use execFileSync (not runGit/execSync) to avoid shell glob-expanding gsd/*/*
|
|
508
|
-
// and to avoid shell syntax errors from %(refname:short) on /bin/sh.
|
|
509
|
-
let branchListRaw = "";
|
|
510
|
-
try {
|
|
511
|
-
branchListRaw = execFileSync(
|
|
512
|
-
"git",
|
|
513
|
-
["branch", "--list", "gsd/*/*", "--format=%(refname:short)"],
|
|
514
|
-
{ cwd: base, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
|
|
515
|
-
).trim();
|
|
516
|
-
} catch {
|
|
517
|
-
return; // no slice branches or git unavailable
|
|
518
|
-
}
|
|
519
|
-
if (!branchListRaw) return;
|
|
520
|
-
|
|
521
|
-
const branches = branchListRaw.split("\n").map(b => b.trim()).filter(Boolean);
|
|
522
|
-
for (const branch of branches) {
|
|
523
|
-
const parsed = parseSliceBranch(branch);
|
|
524
|
-
// Skip worktree-namespaced branches — those are managed by the worktree
|
|
525
|
-
// manager and should not be merged by the main-tree auto-mode.
|
|
526
|
-
if (!parsed || parsed.worktreeName) continue;
|
|
527
|
-
|
|
528
|
-
const { milestoneId, sliceId } = parsed;
|
|
529
|
-
|
|
530
|
-
// Ensure Git operations for this branch use the correct milestone context.
|
|
531
|
-
setActiveMilestoneId(base, milestoneId);
|
|
532
|
-
|
|
533
|
-
// Skip if already merged (no commits ahead of main)
|
|
534
|
-
const mainBranch = getMainBranch(base);
|
|
535
|
-
const aheadCount = nativeCommitCountBetween(base, mainBranch, branch);
|
|
536
|
-
if (aheadCount === 0) continue;
|
|
537
|
-
|
|
538
|
-
// Read the roadmap from the slice branch to check if the slice is done.
|
|
539
|
-
// relMilestoneFile resolves the actual directory name on disk (handles
|
|
540
|
-
// milestone directories with title suffixes like "M007 Payment System").
|
|
541
|
-
const roadmapRelPath = relMilestoneFile(base, milestoneId, "ROADMAP");
|
|
542
|
-
let roadmapContent: string | undefined;
|
|
543
|
-
try {
|
|
544
|
-
roadmapContent = execFileSync(
|
|
545
|
-
"git",
|
|
546
|
-
["-C", base, "show", `${branch}:${roadmapRelPath}`],
|
|
547
|
-
{ encoding: "utf8" },
|
|
548
|
-
);
|
|
549
|
-
} catch {
|
|
550
|
-
roadmapContent = undefined;
|
|
551
|
-
}
|
|
552
|
-
if (!roadmapContent) continue;
|
|
553
|
-
|
|
554
|
-
const roadmap = parseRoadmap(roadmapContent);
|
|
555
|
-
const sliceEntry = roadmap.slices.find(s => s.id === sliceId);
|
|
556
|
-
if (!sliceEntry?.done) continue;
|
|
557
|
-
|
|
558
|
-
// Orphaned completed branch detected — merge it to main now.
|
|
559
|
-
ctx.ui.notify(
|
|
560
|
-
`Orphaned completed slice branch detected: ${branch}. Merging to main before dispatch...`,
|
|
561
|
-
"info",
|
|
562
|
-
);
|
|
563
|
-
try {
|
|
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
|
-
}
|
|
575
|
-
ctx.ui.notify(
|
|
576
|
-
`Merged orphaned branch ${mergeResult.branch} → ${mainBranch}.`,
|
|
577
|
-
"info",
|
|
578
|
-
);
|
|
579
|
-
} catch (error) {
|
|
580
|
-
if (error instanceof MergeConflictError) {
|
|
581
|
-
// Abort and reset the incomplete merge so auto-mode can still start cleanly.
|
|
582
|
-
runGit(base, ["merge", "--abort"], { allowFailure: true });
|
|
583
|
-
runGit(base, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
584
|
-
ctx.ui.notify(
|
|
585
|
-
`Orphaned branch ${branch} has merge conflicts — resolve manually and restart.\nConflicts in: ${error.conflictedFiles.join(", ")}`,
|
|
586
|
-
"error",
|
|
587
|
-
);
|
|
588
|
-
// Stop processing further branches after a conflict to avoid
|
|
589
|
-
// leaving the repo in a partially-merged state.
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
593
|
-
ctx.ui.notify(
|
|
594
|
-
`Failed to merge orphaned branch ${branch}: ${message}`,
|
|
595
|
-
"warning",
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
506
|
export async function startAuto(
|
|
602
507
|
ctx: ExtensionCommandContext,
|
|
603
508
|
pi: ExtensionAPI,
|
|
@@ -625,7 +530,8 @@ export async function startAuto(
|
|
|
625
530
|
if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
|
|
626
531
|
|
|
627
532
|
// ── Auto-worktree: re-enter worktree on resume if not already inside ──
|
|
628
|
-
if
|
|
533
|
+
// Skip if already inside a worktree (manual /worktree) to prevent nesting.
|
|
534
|
+
if (currentMilestoneId && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) {
|
|
629
535
|
try {
|
|
630
536
|
const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
|
|
631
537
|
if (existingWtPath) {
|
|
@@ -673,7 +579,7 @@ export async function startAuto(
|
|
|
673
579
|
return;
|
|
674
580
|
}
|
|
675
581
|
|
|
676
|
-
// Ensure git repo exists — GSD needs it for
|
|
582
|
+
// Ensure git repo exists — GSD needs it for worktree isolation
|
|
677
583
|
try {
|
|
678
584
|
execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
|
|
679
585
|
} catch {
|
|
@@ -788,8 +694,22 @@ export async function startAuto(
|
|
|
788
694
|
|
|
789
695
|
// ── Auto-worktree: create or enter worktree for the active milestone ──
|
|
790
696
|
// Store the original project root before any chdir so we can restore on stop.
|
|
697
|
+
// Skip if already inside a worktree (manual /worktree or another auto-worktree)
|
|
698
|
+
// to prevent nested worktree creation.
|
|
791
699
|
originalBasePath = base;
|
|
792
|
-
|
|
700
|
+
|
|
701
|
+
const isUnderGsdWorktrees = (p: string): boolean => {
|
|
702
|
+
// Prevent creating nested auto-worktrees when running from within any
|
|
703
|
+
// `.gsd/worktrees/...` directory (including manual worktrees).
|
|
704
|
+
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
|
|
705
|
+
if (p.includes(marker)) {
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`;
|
|
709
|
+
return p.endsWith(worktreesSuffix);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
if (currentMilestoneId && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) {
|
|
793
713
|
try {
|
|
794
714
|
const existingWtPath = getAutoWorktreePath(base, currentMilestoneId);
|
|
795
715
|
if (existingWtPath) {
|
|
@@ -855,15 +775,46 @@ export async function startAuto(
|
|
|
855
775
|
);
|
|
856
776
|
}
|
|
857
777
|
|
|
858
|
-
// Merge any orphaned completed slice branches before dispatching.
|
|
859
|
-
// Orphaned branches arise when complete-slice commits on the slice branch
|
|
860
|
-
// but the merge to main is interrupted (crash, timeout, Ctrl+C).
|
|
861
|
-
// Without this check, GSD enters an infinite "Skipping ... Advancing" loop.
|
|
862
|
-
await mergeOrphanedSliceBranches(base, ctx);
|
|
863
|
-
|
|
864
778
|
// Self-heal: clear stale runtime records where artifacts already exist
|
|
865
779
|
await selfHealRuntimeRecords(base, ctx);
|
|
866
780
|
|
|
781
|
+
// Self-heal: remove stale .git/index.lock from prior crash.
|
|
782
|
+
// A stale lock file blocks all git operations (commit, merge, checkout).
|
|
783
|
+
// Only remove if older than 60 seconds (not from a concurrent process).
|
|
784
|
+
try {
|
|
785
|
+
const gitLockFile = join(base, ".git", "index.lock");
|
|
786
|
+
if (existsSync(gitLockFile)) {
|
|
787
|
+
const lockAge = Date.now() - statSync(gitLockFile).mtimeMs;
|
|
788
|
+
if (lockAge > 60_000) {
|
|
789
|
+
unlinkSync(gitLockFile);
|
|
790
|
+
ctx.ui.notify("Removed stale .git/index.lock from prior crash.", "info");
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
} catch { /* non-fatal */ }
|
|
794
|
+
|
|
795
|
+
// Pre-flight: validate milestone queue for multi-milestone runs.
|
|
796
|
+
// Warn about issues that will cause auto-mode to pause or block.
|
|
797
|
+
try {
|
|
798
|
+
const msDir = join(base, ".gsd", "milestones");
|
|
799
|
+
if (existsSync(msDir)) {
|
|
800
|
+
const milestoneIds = readdirSync(msDir, { withFileTypes: true })
|
|
801
|
+
.filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
|
|
802
|
+
.map(d => d.name.match(/^(M\d{3})/)?.[1] ?? d.name);
|
|
803
|
+
if (milestoneIds.length > 1) {
|
|
804
|
+
const issues: string[] = [];
|
|
805
|
+
for (const id of milestoneIds) {
|
|
806
|
+
const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT");
|
|
807
|
+
if (draft) issues.push(`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`);
|
|
808
|
+
}
|
|
809
|
+
if (issues.length > 0) {
|
|
810
|
+
ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map(i => ` ⚠ ${i}`).join("\n")}`, "warning");
|
|
811
|
+
} else {
|
|
812
|
+
ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, "info");
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
} catch { /* non-fatal — pre-flight should never block auto-mode */ }
|
|
817
|
+
|
|
867
818
|
// Dispatch the first unit
|
|
868
819
|
await dispatchNextUnit(ctx, pi);
|
|
869
820
|
}
|
|
@@ -939,17 +890,37 @@ export async function handleAgentEnd(
|
|
|
939
890
|
// produced its expected artifact. If so, persist the completion key now so the
|
|
940
891
|
// idempotency check at the top of dispatchNextUnit() skips it — even if
|
|
941
892
|
// deriveState() still returns this unit as active (e.g. branch mismatch).
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
893
|
+
//
|
|
894
|
+
// IMPORTANT: For non-hook units, defer persistence until after the hook check.
|
|
895
|
+
// If a post-unit hook requests a retry, we need to remove the completion key
|
|
896
|
+
// so dispatchNextUnit re-dispatches the trigger unit.
|
|
897
|
+
let triggerArtifactVerified = false;
|
|
898
|
+
if (!currentUnit.type.startsWith("hook/")) {
|
|
899
|
+
try {
|
|
900
|
+
triggerArtifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
|
901
|
+
if (triggerArtifactVerified) {
|
|
902
|
+
const completionKey = `${currentUnit.type}/${currentUnit.id}`;
|
|
903
|
+
if (!completedKeySet.has(completionKey)) {
|
|
904
|
+
persistCompletedKey(basePath, completionKey);
|
|
905
|
+
completedKeySet.add(completionKey);
|
|
906
|
+
}
|
|
907
|
+
invalidateStateCache();
|
|
948
908
|
}
|
|
949
|
-
|
|
909
|
+
} catch {
|
|
910
|
+
// Non-fatal — worst case we fall through to normal dispatch which has its own checks
|
|
911
|
+
}
|
|
912
|
+
} else {
|
|
913
|
+
// Hook unit completed — finalize its runtime record and clear it
|
|
914
|
+
try {
|
|
915
|
+
writeUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, {
|
|
916
|
+
phase: "finalized",
|
|
917
|
+
progressCount: 1,
|
|
918
|
+
lastProgressKind: "hook-completed",
|
|
919
|
+
});
|
|
920
|
+
clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
|
|
921
|
+
} catch {
|
|
922
|
+
// Non-fatal
|
|
950
923
|
}
|
|
951
|
-
} catch {
|
|
952
|
-
// Non-fatal — worst case we fall through to normal dispatch which has its own checks
|
|
953
924
|
}
|
|
954
925
|
}
|
|
955
926
|
|
|
@@ -1005,6 +976,31 @@ export async function handleAgentEnd(
|
|
|
1005
976
|
writeLock(basePath, hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
|
|
1006
977
|
// Persist hook state so cycle counts survive crashes
|
|
1007
978
|
persistHookState(basePath);
|
|
979
|
+
|
|
980
|
+
// Start supervision timers for hook units — hooks can get stuck just
|
|
981
|
+
// like normal units, and without a watchdog auto-mode would hang forever.
|
|
982
|
+
clearUnitTimeout();
|
|
983
|
+
const supervisor = resolveAutoSupervisorConfig();
|
|
984
|
+
const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
|
|
985
|
+
unitTimeoutHandle = setTimeout(async () => {
|
|
986
|
+
unitTimeoutHandle = null;
|
|
987
|
+
if (!active) return;
|
|
988
|
+
if (currentUnit) {
|
|
989
|
+
writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, currentUnit.startedAt, {
|
|
990
|
+
phase: "timeout",
|
|
991
|
+
timeoutAt: Date.now(),
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
ctx.ui.notify(
|
|
995
|
+
`Hook ${hookUnit.hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`,
|
|
996
|
+
"warning",
|
|
997
|
+
);
|
|
998
|
+
resetHookState();
|
|
999
|
+
await pauseAuto(ctx, pi);
|
|
1000
|
+
}, hookHardTimeoutMs);
|
|
1001
|
+
|
|
1002
|
+
// Guard against race with timeout/pause before sending
|
|
1003
|
+
if (!active) return;
|
|
1008
1004
|
pi.sendMessage(
|
|
1009
1005
|
{ customType: "gsd-auto", content: hookUnit.prompt, display: verbose },
|
|
1010
1006
|
{ triggerTurn: true },
|
|
@@ -1016,6 +1012,11 @@ export async function handleAgentEnd(
|
|
|
1016
1012
|
if (isRetryPending()) {
|
|
1017
1013
|
const trigger = consumeRetryTrigger();
|
|
1018
1014
|
if (trigger) {
|
|
1015
|
+
// Remove the trigger unit's completion key so dispatchNextUnit
|
|
1016
|
+
// will re-dispatch it instead of skipping it as already-complete.
|
|
1017
|
+
const triggerKey = `${trigger.unitType}/${trigger.unitId}`;
|
|
1018
|
+
completedKeySet.delete(triggerKey);
|
|
1019
|
+
removePersistedKey(basePath, triggerKey);
|
|
1019
1020
|
ctx.ui.notify(
|
|
1020
1021
|
`Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
|
|
1021
1022
|
"info",
|
|
@@ -1180,7 +1181,6 @@ function unitVerb(unitType: string): string {
|
|
|
1180
1181
|
case "replan-slice": return "replanning";
|
|
1181
1182
|
case "reassess-roadmap": return "reassessing";
|
|
1182
1183
|
case "run-uat": return "running UAT";
|
|
1183
|
-
case "fix-merge": return "resolving conflicts";
|
|
1184
1184
|
default: return unitType;
|
|
1185
1185
|
}
|
|
1186
1186
|
}
|
|
@@ -1197,7 +1197,6 @@ function unitPhaseLabel(unitType: string): string {
|
|
|
1197
1197
|
case "replan-slice": return "REPLAN";
|
|
1198
1198
|
case "reassess-roadmap": return "REASSESS";
|
|
1199
1199
|
case "run-uat": return "UAT";
|
|
1200
|
-
case "fix-merge": return "MERGE-FIX";
|
|
1201
1200
|
default: return unitType.toUpperCase();
|
|
1202
1201
|
}
|
|
1203
1202
|
}
|
|
@@ -1221,7 +1220,6 @@ function peekNext(unitType: string, state: GSDState): string {
|
|
|
1221
1220
|
case "replan-slice": return `re-execute ${sid}`;
|
|
1222
1221
|
case "reassess-roadmap": return "advance to next slice";
|
|
1223
1222
|
case "run-uat": return "reassess roadmap";
|
|
1224
|
-
case "fix-merge": return "continue merge";
|
|
1225
1223
|
default: return "";
|
|
1226
1224
|
}
|
|
1227
1225
|
}
|
|
@@ -1493,6 +1491,16 @@ function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks
|
|
|
1493
1491
|
|
|
1494
1492
|
// ─── Core Loop ────────────────────────────────────────────────────────────────
|
|
1495
1493
|
|
|
1494
|
+
/** Tracks recursive skip depth to prevent TUI freeze on cascading completed-unit skips */
|
|
1495
|
+
let _skipDepth = 0;
|
|
1496
|
+
const MAX_SKIP_DEPTH = 20;
|
|
1497
|
+
|
|
1498
|
+
/** Reentrancy guard for dispatchNextUnit itself (not just handleAgentEnd).
|
|
1499
|
+
* Prevents concurrent dispatch from watchdog timers, step wizard, and direct calls
|
|
1500
|
+
* that bypass the _handlingAgentEnd guard. Recursive calls (from skip paths) are
|
|
1501
|
+
* allowed via _skipDepth > 0. */
|
|
1502
|
+
let _dispatching = false;
|
|
1503
|
+
|
|
1496
1504
|
async function dispatchNextUnit(
|
|
1497
1505
|
ctx: ExtensionContext,
|
|
1498
1506
|
pi: ExtensionAPI,
|
|
@@ -1504,6 +1512,22 @@ async function dispatchNextUnit(
|
|
|
1504
1512
|
return;
|
|
1505
1513
|
}
|
|
1506
1514
|
|
|
1515
|
+
// Reentrancy guard: allow recursive calls from skip paths (_skipDepth > 0)
|
|
1516
|
+
// but block concurrent external calls (watchdog, step wizard, etc.)
|
|
1517
|
+
if (_dispatching && _skipDepth === 0) {
|
|
1518
|
+
return; // Another dispatch is in progress — bail silently
|
|
1519
|
+
}
|
|
1520
|
+
_dispatching = true;
|
|
1521
|
+
|
|
1522
|
+
// Recursion depth guard: when many units are skipped in sequence (e.g., after
|
|
1523
|
+
// crash recovery with 10+ completed units), recursive dispatchNextUnit calls
|
|
1524
|
+
// can freeze the TUI or overflow the stack. Yield generously after MAX_SKIP_DEPTH.
|
|
1525
|
+
if (_skipDepth > MAX_SKIP_DEPTH) {
|
|
1526
|
+
_skipDepth = 0;
|
|
1527
|
+
ctx.ui.notify(`Skipped ${MAX_SKIP_DEPTH}+ completed units. Yielding to UI before continuing.`, "info");
|
|
1528
|
+
await new Promise(r => setTimeout(r, 200));
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1507
1531
|
// Clear stale directory listing cache so deriveState sees fresh disk state (#431)
|
|
1508
1532
|
clearPathCache();
|
|
1509
1533
|
// Clear parsed roadmap/plan cache — doctor may have re-populated it with
|
|
@@ -1551,9 +1575,9 @@ async function dispatchNextUnit(
|
|
|
1551
1575
|
return;
|
|
1552
1576
|
}
|
|
1553
1577
|
|
|
1554
|
-
// ── Mid-merge safety check: detect leftover state from a prior
|
|
1555
|
-
// If MERGE_HEAD or SQUASH_MSG exists,
|
|
1556
|
-
//
|
|
1578
|
+
// ── Mid-merge safety check: detect leftover merge state from a prior session ──
|
|
1579
|
+
// If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
|
|
1580
|
+
// If resolved: finalize the commit. If still conflicted: abort and reset.
|
|
1557
1581
|
{
|
|
1558
1582
|
const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
|
|
1559
1583
|
const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
|
|
@@ -1562,178 +1586,37 @@ async function dispatchNextUnit(
|
|
|
1562
1586
|
if (hasMergeHead || hasSquashMsg) {
|
|
1563
1587
|
const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
|
|
1564
1588
|
if (!unmerged || !unmerged.trim()) {
|
|
1565
|
-
//
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
// Commit may already exist; non-fatal
|
|
1573
|
-
}
|
|
1589
|
+
// All conflicts resolved — finalize the merge/squash commit
|
|
1590
|
+
try {
|
|
1591
|
+
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
|
|
1592
|
+
const mode = hasMergeHead ? "merge" : "squash commit";
|
|
1593
|
+
ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
|
|
1594
|
+
} catch {
|
|
1595
|
+
// Commit may already exist; non-fatal
|
|
1574
1596
|
}
|
|
1575
|
-
// Re-derive state from the now-merged working tree
|
|
1576
|
-
invalidateStateCache();
|
|
1577
|
-
clearParseCache();
|
|
1578
|
-
clearPathCache();
|
|
1579
|
-
state = await deriveState(basePath);
|
|
1580
|
-
mid = state.activeMilestone?.id;
|
|
1581
|
-
midTitle = state.activeMilestone?.title;
|
|
1582
1597
|
} else {
|
|
1583
|
-
//
|
|
1598
|
+
// Still conflicted — abort and reset
|
|
1584
1599
|
if (hasMergeHead) {
|
|
1585
|
-
// Properly abort an in-progress merge so MERGE_HEAD and related metadata are cleared
|
|
1586
1600
|
runGit(basePath, ["merge", "--abort"], { allowFailure: true });
|
|
1587
1601
|
} else if (hasSquashMsg) {
|
|
1588
|
-
|
|
1589
|
-
try {
|
|
1590
|
-
unlinkSync(squashMsgPath);
|
|
1591
|
-
} catch {
|
|
1592
|
-
// Best-effort cleanup; ignore failures
|
|
1593
|
-
}
|
|
1602
|
+
try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
|
|
1594
1603
|
}
|
|
1595
1604
|
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
1596
1605
|
ctx.ui.notify(
|
|
1597
|
-
"
|
|
1598
|
-
"
|
|
1606
|
+
"Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.",
|
|
1607
|
+
"warning",
|
|
1599
1608
|
);
|
|
1600
|
-
if (currentUnit) {
|
|
1601
|
-
const modelId = ctx.model?.id ?? "unknown";
|
|
1602
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1603
|
-
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1604
|
-
}
|
|
1605
|
-
await stopAuto(ctx, pi);
|
|
1606
|
-
return;
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
// ── General merge guard: merge completed slice branches before advancing ──
|
|
1612
|
-
// If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]),
|
|
1613
|
-
// merge to main before dispatching the next unit. This handles:
|
|
1614
|
-
// - Normal complete-slice → merge → reassess flow
|
|
1615
|
-
// - LLM writes summary during task execution, skipping complete-slice
|
|
1616
|
-
// - Doctor post-hook marks everything done, skipping complete-slice
|
|
1617
|
-
// - complete-milestone runs on a slice branch (last slice bypass)
|
|
1618
|
-
{
|
|
1619
|
-
const currentBranch = getCurrentBranch(basePath);
|
|
1620
|
-
const parsedBranch = parseSliceBranch(currentBranch);
|
|
1621
|
-
if (parsedBranch) {
|
|
1622
|
-
const branchMid = parsedBranch.milestoneId;
|
|
1623
|
-
const branchSid = parsedBranch.sliceId;
|
|
1624
|
-
// Check if this slice is marked done in the roadmap
|
|
1625
|
-
const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
|
|
1626
|
-
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
1627
|
-
if (roadmapContent) {
|
|
1628
|
-
const roadmap = parseRoadmap(roadmapContent);
|
|
1629
|
-
const sliceEntry = roadmap.slices.find(s => s.id === branchSid);
|
|
1630
|
-
if (sliceEntry?.done) {
|
|
1631
|
-
try {
|
|
1632
|
-
const sliceTitleForMerge = sliceEntry.title || branchSid;
|
|
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
|
-
}
|
|
1644
|
-
const targetBranch = getMainBranch(basePath);
|
|
1645
|
-
ctx.ui.notify(
|
|
1646
|
-
`Merged ${mergeResult.branch} → ${targetBranch}.`,
|
|
1647
|
-
"info",
|
|
1648
|
-
);
|
|
1649
|
-
// Re-derive state from main so downstream logic sees merged state
|
|
1650
|
-
invalidateStateCache();
|
|
1651
|
-
clearParseCache();
|
|
1652
|
-
clearPathCache();
|
|
1653
|
-
state = await deriveState(basePath);
|
|
1654
|
-
mid = state.activeMilestone?.id;
|
|
1655
|
-
midTitle = state.activeMilestone?.title;
|
|
1656
|
-
} catch (error) {
|
|
1657
|
-
// MergeConflictError: dispatch a fix-merge session to resolve conflicts
|
|
1658
|
-
if (error instanceof MergeConflictError) {
|
|
1659
|
-
const fixMergeUnitId = `${parsedBranch.milestoneId}/${parsedBranch.sliceId}`;
|
|
1660
|
-
const fixMergePrompt = buildFixMergePrompt(error);
|
|
1661
|
-
ctx.ui.notify(
|
|
1662
|
-
`Merge conflict in ${error.conflictedFiles.length} file(s) — dispatching fix-merge session.`,
|
|
1663
|
-
"warning",
|
|
1664
|
-
);
|
|
1665
|
-
|
|
1666
|
-
// Close out the previously active unit before overwriting currentUnit.
|
|
1667
|
-
if (currentUnit) {
|
|
1668
|
-
const modelId = ctx.model?.id ?? "unknown";
|
|
1669
|
-
snapshotUnitMetrics(
|
|
1670
|
-
ctx,
|
|
1671
|
-
currentUnit.type,
|
|
1672
|
-
currentUnit.id,
|
|
1673
|
-
currentUnit.startedAt,
|
|
1674
|
-
modelId,
|
|
1675
|
-
);
|
|
1676
|
-
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
// Dispatch fix-merge as the next unit (early-dispatch-and-return)
|
|
1680
|
-
const fixMergeUnitType = "fix-merge";
|
|
1681
|
-
currentUnit = { type: fixMergeUnitType, id: fixMergeUnitId, startedAt: Date.now() };
|
|
1682
|
-
writeUnitRuntimeRecord(basePath, fixMergeUnitType, fixMergeUnitId, currentUnit.startedAt, {
|
|
1683
|
-
phase: "dispatched",
|
|
1684
|
-
wrapupWarningSent: false,
|
|
1685
|
-
timeoutAt: null,
|
|
1686
|
-
lastProgressAt: currentUnit.startedAt,
|
|
1687
|
-
progressCount: 0,
|
|
1688
|
-
lastProgressKind: "dispatch",
|
|
1689
|
-
});
|
|
1690
|
-
updateProgressWidget(ctx, fixMergeUnitType, fixMergeUnitId, state);
|
|
1691
|
-
const result = await cmdCtx!.newSession();
|
|
1692
|
-
if (result.cancelled) {
|
|
1693
|
-
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
1694
|
-
await stopAuto(ctx, pi);
|
|
1695
|
-
return;
|
|
1696
|
-
}
|
|
1697
|
-
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1698
|
-
writeLock(basePath, fixMergeUnitType, fixMergeUnitId, completedUnits.length, sessionFile);
|
|
1699
|
-
pi.sendMessage(
|
|
1700
|
-
{ customType: "gsd-auto", content: fixMergePrompt, display: verbose },
|
|
1701
|
-
{ triggerTurn: true },
|
|
1702
|
-
);
|
|
1703
|
-
return;
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
// Non-conflict errors: reset and stop
|
|
1707
|
-
const message = formatGitError(error instanceof Error ? error : String(error));
|
|
1708
|
-
try {
|
|
1709
|
-
const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true });
|
|
1710
|
-
if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) {
|
|
1711
|
-
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
|
|
1712
|
-
ctx.ui.notify(
|
|
1713
|
-
`Cleaned up conflicted merge state after failed squash-merge.`,
|
|
1714
|
-
"warning",
|
|
1715
|
-
);
|
|
1716
|
-
}
|
|
1717
|
-
} catch { /* best-effort cleanup */ }
|
|
1718
|
-
|
|
1719
|
-
ctx.ui.notify(
|
|
1720
|
-
`Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`,
|
|
1721
|
-
"error",
|
|
1722
|
-
);
|
|
1723
|
-
if (currentUnit) {
|
|
1724
|
-
const modelId = ctx.model?.id ?? "unknown";
|
|
1725
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1726
|
-
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1727
|
-
}
|
|
1728
|
-
await stopAuto(ctx, pi);
|
|
1729
|
-
return;
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
1609
|
}
|
|
1610
|
+
invalidateStateCache();
|
|
1611
|
+
clearParseCache();
|
|
1612
|
+
clearPathCache();
|
|
1613
|
+
state = await deriveState(basePath);
|
|
1614
|
+
mid = state.activeMilestone?.id;
|
|
1615
|
+
midTitle = state.activeMilestone?.title;
|
|
1733
1616
|
}
|
|
1734
1617
|
}
|
|
1735
1618
|
|
|
1736
|
-
// After merge, mid/midTitle
|
|
1619
|
+
// After merge guard removal (branchless architecture), mid/midTitle could be undefined
|
|
1737
1620
|
if (!mid || !midTitle) {
|
|
1738
1621
|
if (currentUnit) {
|
|
1739
1622
|
const modelId = ctx.model?.id ?? "unknown";
|
|
@@ -1763,7 +1646,7 @@ async function dispatchNextUnit(
|
|
|
1763
1646
|
} catch { /* non-fatal */ }
|
|
1764
1647
|
|
|
1765
1648
|
// ── Milestone merge: squash-merge milestone branch to main before stopping ──
|
|
1766
|
-
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath
|
|
1649
|
+
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) {
|
|
1767
1650
|
try {
|
|
1768
1651
|
const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
|
|
1769
1652
|
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
@@ -1852,7 +1735,7 @@ async function dispatchNextUnit(
|
|
|
1852
1735
|
|
|
1853
1736
|
// ── Phase-first dispatch: complete-slice MUST run before reassessment ──
|
|
1854
1737
|
// If the current phase is "summarizing", complete-slice is responsible for
|
|
1855
|
-
//
|
|
1738
|
+
// complete-slice must run before reassessment.
|
|
1856
1739
|
if (state.phase === "summarizing") {
|
|
1857
1740
|
const sid = state.activeSlice!.id;
|
|
1858
1741
|
const sTitle = state.activeSlice!.title;
|
|
@@ -2024,10 +1907,10 @@ async function dispatchNextUnit(
|
|
|
2024
1907
|
`Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
|
|
2025
1908
|
"info",
|
|
2026
1909
|
);
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
await new Promise(r => setImmediate(r));
|
|
1910
|
+
_skipDepth++;
|
|
1911
|
+
await new Promise(r => setTimeout(r, 50));
|
|
2030
1912
|
await dispatchNextUnit(ctx, pi);
|
|
1913
|
+
_skipDepth = Math.max(0, _skipDepth - 1);
|
|
2031
1914
|
return;
|
|
2032
1915
|
} else {
|
|
2033
1916
|
// Stale completion record — artifact missing. Remove and re-run.
|
|
@@ -2040,6 +1923,26 @@ async function dispatchNextUnit(
|
|
|
2040
1923
|
}
|
|
2041
1924
|
}
|
|
2042
1925
|
|
|
1926
|
+
// Fallback: if the idempotency key is missing but the expected artifact already
|
|
1927
|
+
// exists on disk, the task completed in a prior session without persisting the key.
|
|
1928
|
+
// Persist it now and skip re-dispatch. This prevents infinite loops where a task
|
|
1929
|
+
// completes successfully but the completion key was never written (e.g., completed
|
|
1930
|
+
// on the first attempt before hitting the retry-threshold persistence logic).
|
|
1931
|
+
if (verifyExpectedArtifact(unitType, unitId, basePath)) {
|
|
1932
|
+
persistCompletedKey(basePath, idempotencyKey);
|
|
1933
|
+
completedKeySet.add(idempotencyKey);
|
|
1934
|
+
invalidateStateCache();
|
|
1935
|
+
ctx.ui.notify(
|
|
1936
|
+
`Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
|
|
1937
|
+
"info",
|
|
1938
|
+
);
|
|
1939
|
+
_skipDepth++;
|
|
1940
|
+
await new Promise(r => setTimeout(r, 50));
|
|
1941
|
+
await dispatchNextUnit(ctx, pi);
|
|
1942
|
+
_skipDepth = Math.max(0, _skipDepth - 1);
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
2043
1946
|
// Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
|
|
2044
1947
|
// Pattern A→B→A→B would reset retryCount every time; this map catches it.
|
|
2045
1948
|
const dispatchKey = `${unitType}/${unitId}`;
|
|
@@ -2127,6 +2030,29 @@ async function dispatchNextUnit(
|
|
|
2127
2030
|
return;
|
|
2128
2031
|
}
|
|
2129
2032
|
|
|
2033
|
+
// Last resort for complete-milestone: generate stub summary to unblock pipeline.
|
|
2034
|
+
// All slices are done (otherwise we wouldn't be in completing-milestone phase),
|
|
2035
|
+
// but the LLM failed to write the summary N times. A stub lets the pipeline advance.
|
|
2036
|
+
if (unitType === "complete-milestone") {
|
|
2037
|
+
try {
|
|
2038
|
+
const mPath = resolveMilestonePath(basePath, unitId);
|
|
2039
|
+
if (mPath) {
|
|
2040
|
+
const stubPath = join(mPath, `${unitId}-SUMMARY.md`);
|
|
2041
|
+
if (!existsSync(stubPath)) {
|
|
2042
|
+
writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`);
|
|
2043
|
+
ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning");
|
|
2044
|
+
persistCompletedKey(basePath, dispatchKey);
|
|
2045
|
+
completedKeySet.add(dispatchKey);
|
|
2046
|
+
unitDispatchCount.delete(dispatchKey);
|
|
2047
|
+
invalidateStateCache();
|
|
2048
|
+
await new Promise(r => setImmediate(r));
|
|
2049
|
+
await dispatchNextUnit(ctx, pi);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
} catch { /* non-fatal — fall through to normal stop */ }
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2130
2056
|
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
|
|
2131
2057
|
const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
|
|
2132
2058
|
await stopAuto(ctx, pi);
|
|
@@ -2207,12 +2133,19 @@ async function dispatchNextUnit(
|
|
|
2207
2133
|
// Only mark the previous unit as completed if:
|
|
2208
2134
|
// 1. We're not about to re-dispatch the same unit (retry scenario)
|
|
2209
2135
|
// 2. The expected artifact actually exists on disk
|
|
2136
|
+
// For hook units, skip artifact verification — hooks don't produce standard
|
|
2137
|
+
// artifacts and their runtime records were already finalized in handleAgentEnd.
|
|
2210
2138
|
const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
|
|
2211
2139
|
const incomingKey = `${unitType}/${unitId}`;
|
|
2212
|
-
const
|
|
2140
|
+
const isHookUnit = currentUnit.type.startsWith("hook/");
|
|
2141
|
+
const artifactVerified = isHookUnit || verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
|
2213
2142
|
if (closeoutKey !== incomingKey && artifactVerified) {
|
|
2214
|
-
|
|
2215
|
-
|
|
2143
|
+
if (!isHookUnit) {
|
|
2144
|
+
// Only persist completion keys for real units — hook keys are
|
|
2145
|
+
// ephemeral and should not pollute the idempotency set.
|
|
2146
|
+
persistCompletedKey(basePath, closeoutKey);
|
|
2147
|
+
completedKeySet.add(closeoutKey);
|
|
2148
|
+
}
|
|
2216
2149
|
|
|
2217
2150
|
completedUnits.push({
|
|
2218
2151
|
type: currentUnit.type,
|
|
@@ -3136,45 +3069,6 @@ async function buildReassessRoadmapPrompt(
|
|
|
3136
3069
|
});
|
|
3137
3070
|
}
|
|
3138
3071
|
|
|
3139
|
-
/**
|
|
3140
|
-
* Build a prompt for the fix-merge LLM session that resolves merge conflicts.
|
|
3141
|
-
*/
|
|
3142
|
-
function buildFixMergePrompt(err: MergeConflictError): string {
|
|
3143
|
-
const strategyLabel = err.strategy === "merge" ? "merge --no-ff" : "squash merge";
|
|
3144
|
-
const fileList = err.conflictedFiles.map(f => ` - \`${f}\``).join("\n");
|
|
3145
|
-
|
|
3146
|
-
return [
|
|
3147
|
-
`# Fix Merge Conflicts`,
|
|
3148
|
-
``,
|
|
3149
|
-
`A ${strategyLabel} of branch \`${err.branch}\` into \`${err.mainBranch}\` produced conflicts in the following files:`,
|
|
3150
|
-
``,
|
|
3151
|
-
fileList,
|
|
3152
|
-
``,
|
|
3153
|
-
`## Instructions`,
|
|
3154
|
-
``,
|
|
3155
|
-
`1. Read each conflicted file listed above`,
|
|
3156
|
-
`2. Resolve all conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) by choosing the correct content`,
|
|
3157
|
-
`3. Stage the resolved files with \`git add <file>\``,
|
|
3158
|
-
`4. Commit the resolution:`,
|
|
3159
|
-
err.strategy === "squash"
|
|
3160
|
-
? ` - This is a squash merge, so run: \`git commit --no-edit\` (the squash message is already prepared)`
|
|
3161
|
-
: ` - This is a --no-ff merge, so run: \`git commit --no-edit\` (the merge message is already prepared)`,
|
|
3162
|
-
``,
|
|
3163
|
-
`## Rules`,
|
|
3164
|
-
``,
|
|
3165
|
-
`- Do NOT run \`git merge --abort\` or \`git reset\``,
|
|
3166
|
-
`- Do NOT modify any files other than the conflicted ones listed above`,
|
|
3167
|
-
`- Preserve the intent of both sides of the conflict — prefer the slice branch changes when the intent is unclear`,
|
|
3168
|
-
``,
|
|
3169
|
-
`## Verification`,
|
|
3170
|
-
``,
|
|
3171
|
-
`After committing, verify:`,
|
|
3172
|
-
`1. \`git diff --name-only --diff-filter=U\` returns empty (no unmerged files)`,
|
|
3173
|
-
`2. The conflicted files no longer contain any \`<<<<<<<\`, \`=======\`, or \`>>>>>>>\` markers`,
|
|
3174
|
-
`3. \`git status\` shows a clean working tree`,
|
|
3175
|
-
].join("\n");
|
|
3176
|
-
}
|
|
3177
|
-
|
|
3178
3072
|
function extractSliceExecutionExcerpt(content: string | null, relPath: string): string {
|
|
3179
3073
|
if (!content) {
|
|
3180
3074
|
return [
|
|
@@ -3342,10 +3236,6 @@ function ensurePreconditions(
|
|
|
3342
3236
|
}
|
|
3343
3237
|
}
|
|
3344
3238
|
|
|
3345
|
-
if (["research-slice", "plan-slice", "execute-task", "complete-slice", "replan-slice"].includes(unitType) && parts.length >= 2) {
|
|
3346
|
-
const sid = parts[1]!;
|
|
3347
|
-
ensureSliceBranch(base, mid, sid);
|
|
3348
|
-
}
|
|
3349
3239
|
}
|
|
3350
3240
|
|
|
3351
3241
|
// ─── Diagnostics ──────────────────────────────────────────────────────────────
|
|
@@ -3752,8 +3642,6 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
|
|
|
3752
3642
|
const dir = resolveMilestonePath(base, mid);
|
|
3753
3643
|
return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
|
|
3754
3644
|
}
|
|
3755
|
-
case "fix-merge":
|
|
3756
|
-
return null;
|
|
3757
3645
|
default:
|
|
3758
3646
|
return null;
|
|
3759
3647
|
}
|
|
@@ -3772,14 +3660,10 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
|
|
|
3772
3660
|
// Clear stale directory listing cache so artifact checks see fresh disk state (#431)
|
|
3773
3661
|
clearPathCache();
|
|
3774
3662
|
|
|
3775
|
-
//
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
if (existsSync(join(base, ".git", "MERGE_HEAD"))) return false;
|
|
3780
|
-
if (existsSync(join(base, ".git", "SQUASH_MSG"))) return false;
|
|
3781
|
-
return true;
|
|
3782
|
-
}
|
|
3663
|
+
// Hook units have no standard artifact — always pass. Their lifecycle
|
|
3664
|
+
// is managed by the hook engine, not the artifact verification system.
|
|
3665
|
+
if (unitType.startsWith("hook/")) return true;
|
|
3666
|
+
|
|
3783
3667
|
|
|
3784
3668
|
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
3785
3669
|
// Unit types with no verifiable artifact always pass (e.g. replan-slice).
|
|
@@ -3887,8 +3771,6 @@ function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string
|
|
|
3887
3771
|
return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`;
|
|
3888
3772
|
case "complete-milestone":
|
|
3889
3773
|
return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`;
|
|
3890
|
-
case "fix-merge":
|
|
3891
|
-
return "Clean working tree with no unmerged files, no MERGE_HEAD, no SQUASH_MSG (merge conflict resolution)";
|
|
3892
3774
|
default:
|
|
3893
3775
|
return null;
|
|
3894
3776
|
}
|