gsd-pi 2.81.0-dev.3cddbbba2 → 2.81.0-dev.72a81bdf3
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/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/phases.js +100 -95
- package/dist/resources/extensions/gsd/auto-recovery.js +6 -181
- package/dist/resources/extensions/gsd/auto.js +6 -3
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +2 -5
- package/dist/resources/extensions/gsd/commands/handlers/parallel.js +9 -0
- package/dist/resources/extensions/gsd/gsd-db.js +7 -23
- package/dist/resources/extensions/gsd/markdown-renderer.js +0 -95
- package/dist/resources/extensions/gsd/recovery-classification.js +15 -1
- package/dist/resources/extensions/gsd/session-lock.js +40 -0
- package/dist/resources/extensions/gsd/state-reconciliation/drift/completion.js +131 -0
- package/dist/resources/extensions/gsd/state-reconciliation/drift/merge-state.js +247 -0
- package/dist/resources/extensions/gsd/state-reconciliation/drift/project-md.js +50 -0
- package/dist/resources/extensions/gsd/state-reconciliation/drift/roadmap.js +87 -0
- package/dist/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.js +50 -0
- package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-render.js +124 -0
- package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-worker.js +32 -0
- package/dist/resources/extensions/gsd/state-reconciliation/errors.js +41 -0
- package/dist/resources/extensions/gsd/state-reconciliation/index.js +99 -0
- package/dist/resources/extensions/gsd/state-reconciliation/registry.js +24 -0
- package/dist/resources/extensions/gsd/state-reconciliation/spawn-gate.js +43 -0
- package/dist/resources/extensions/gsd/state-reconciliation/types.js +3 -0
- package/dist/resources/extensions/gsd/state-reconciliation.js +5 -26
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto/phases.ts +25 -17
- package/src/resources/extensions/gsd/auto-recovery.ts +7 -209
- package/src/resources/extensions/gsd/auto.ts +7 -3
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +2 -5
- package/src/resources/extensions/gsd/commands/handlers/parallel.ts +12 -0
- package/src/resources/extensions/gsd/gsd-db.ts +7 -23
- package/src/resources/extensions/gsd/markdown-renderer.ts +4 -95
- package/src/resources/extensions/gsd/recovery-classification.ts +18 -1
- package/src/resources/extensions/gsd/session-lock.ts +41 -0
- package/src/resources/extensions/gsd/state-reconciliation/drift/completion.ts +172 -0
- package/src/resources/extensions/gsd/state-reconciliation/drift/merge-state.ts +337 -0
- package/src/resources/extensions/gsd/state-reconciliation/drift/project-md.ts +69 -0
- package/src/resources/extensions/gsd/state-reconciliation/drift/roadmap.ts +109 -0
- package/src/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.ts +68 -0
- package/src/resources/extensions/gsd/state-reconciliation/drift/stale-render.ts +185 -0
- package/src/resources/extensions/gsd/state-reconciliation/drift/stale-worker.ts +46 -0
- package/src/resources/extensions/gsd/state-reconciliation/errors.ts +67 -0
- package/src/resources/extensions/gsd/state-reconciliation/index.ts +142 -0
- package/src/resources/extensions/gsd/state-reconciliation/registry.ts +27 -0
- package/src/resources/extensions/gsd/state-reconciliation/spawn-gate.ts +60 -0
- package/src/resources/extensions/gsd/state-reconciliation/types.ts +83 -0
- package/src/resources/extensions/gsd/state-reconciliation.ts +21 -53
- package/src/resources/extensions/gsd/tests/artifact-retry-cap.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +81 -10
- package/src/resources/extensions/gsd/tests/integration/integration-proof.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/markdown-renderer.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/progressive-planning.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +6 -3
- package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +24 -0
- package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +952 -0
- /package/dist/web/standalone/.next/static/{F5x9E6H9k_52fjqyql93y → rIkMv4YSNlfSeqmGqWVns}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{F5x9E6H9k_52fjqyql93y → rIkMv4YSNlfSeqmGqWVns}/_ssgManifest.js +0 -0
|
@@ -734,101 +734,6 @@ export function detectStaleRenders(basePath) {
|
|
|
734
734
|
}
|
|
735
735
|
return stale;
|
|
736
736
|
}
|
|
737
|
-
// ─── Stale Repair ─────────────────────────────────────────────────────────
|
|
738
|
-
/**
|
|
739
|
-
* Repair all stale renders detected by `detectStaleRenders()`.
|
|
740
|
-
*
|
|
741
|
-
* For each stale entry, calls the appropriate render function:
|
|
742
|
-
* - Roadmap checkbox mismatches → renderRoadmapCheckboxes()
|
|
743
|
-
* - Plan checkbox mismatches → renderPlanCheckboxes()
|
|
744
|
-
* - Missing task summaries → renderTaskSummary()
|
|
745
|
-
* - Missing slice summaries/UATs → renderSliceSummary()
|
|
746
|
-
*
|
|
747
|
-
* Idempotent: calling twice with no DB changes produces zero repairs on the second call.
|
|
748
|
-
*
|
|
749
|
-
* @returns the number of files repaired
|
|
750
|
-
*/
|
|
751
|
-
export async function repairStaleRenders(basePath) {
|
|
752
|
-
const staleEntries = detectStaleRenders(basePath);
|
|
753
|
-
if (staleEntries.length === 0)
|
|
754
|
-
return 0;
|
|
755
|
-
// Deduplicate: a single roadmap/plan file might appear multiple times
|
|
756
|
-
// (once per mismatched checkbox). We only need to re-render it once.
|
|
757
|
-
const repairedPaths = new Set();
|
|
758
|
-
let repairCount = 0;
|
|
759
|
-
for (const entry of staleEntries) {
|
|
760
|
-
if (repairedPaths.has(entry.path))
|
|
761
|
-
continue;
|
|
762
|
-
// Normalize path separators for cross-platform regex matching
|
|
763
|
-
const normPath = entry.path.replace(/\\/g, "/");
|
|
764
|
-
try {
|
|
765
|
-
// Determine repair action from the reason
|
|
766
|
-
if (entry.reason.includes("in roadmap")) {
|
|
767
|
-
// Roadmap checkbox mismatch — extract milestone ID from path
|
|
768
|
-
const milestoneMatch = normPath.match(/milestones\/([^/]+)\//);
|
|
769
|
-
if (milestoneMatch) {
|
|
770
|
-
const ok = await renderRoadmapCheckboxes(basePath, milestoneMatch[1]);
|
|
771
|
-
if (ok) {
|
|
772
|
-
repairedPaths.add(entry.path);
|
|
773
|
-
repairCount++;
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
else if (entry.reason.includes("in plan")) {
|
|
778
|
-
// Plan checkbox mismatch — extract milestone + slice IDs from path
|
|
779
|
-
const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
|
|
780
|
-
if (pathMatch) {
|
|
781
|
-
const ok = await renderPlanCheckboxes(basePath, pathMatch[1], pathMatch[2]);
|
|
782
|
-
if (ok) {
|
|
783
|
-
repairedPaths.add(entry.path);
|
|
784
|
-
repairCount++;
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^T\d+/)) {
|
|
789
|
-
// Missing task summary — extract IDs from path
|
|
790
|
-
const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\/tasks\//);
|
|
791
|
-
const taskMatch = entry.reason.match(/^(T\d+)/);
|
|
792
|
-
if (pathMatch && taskMatch) {
|
|
793
|
-
const ok = await renderTaskSummary(basePath, pathMatch[1], pathMatch[2], taskMatch[1]);
|
|
794
|
-
if (ok) {
|
|
795
|
-
repairedPaths.add(entry.path);
|
|
796
|
-
repairCount++;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^S\d+/)) {
|
|
801
|
-
// Missing slice summary — extract IDs from path
|
|
802
|
-
const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
|
|
803
|
-
if (pathMatch) {
|
|
804
|
-
const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]);
|
|
805
|
-
if (ok) {
|
|
806
|
-
repairedPaths.add(entry.path);
|
|
807
|
-
repairCount++;
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
else if (entry.reason.includes("UAT.md missing")) {
|
|
812
|
-
// Missing slice UAT — renderSliceSummary handles both SUMMARY + UAT
|
|
813
|
-
const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
|
|
814
|
-
if (pathMatch) {
|
|
815
|
-
const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]);
|
|
816
|
-
if (ok) {
|
|
817
|
-
repairedPaths.add(entry.path);
|
|
818
|
-
repairCount++;
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
catch (err) {
|
|
824
|
-
logWarning("renderer", `repair failed for ${entry.path}: ${err.message}`);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
if (repairCount > 0) {
|
|
828
|
-
process.stderr.write(`markdown-renderer: repaired ${repairCount} stale render(s)\n`);
|
|
829
|
-
}
|
|
830
|
-
return repairCount;
|
|
831
|
-
}
|
|
832
737
|
export async function renderReplanFromDb(basePath, milestoneId, sliceId, replanData) {
|
|
833
738
|
const slicePath = resolveSlicePath(basePath, milestoneId, sliceId)
|
|
834
739
|
?? join(gsdRoot(basePath), "milestones", milestoneId, "slices", sliceId);
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// Project/App: GSD-2
|
|
2
2
|
// File Purpose: ADR-015 Recovery Classification module for runtime failure taxonomy.
|
|
3
3
|
import { classifyError, isTransient } from "./error-classifier.js";
|
|
4
|
+
import { ReconciliationFailedError } from "./state-reconciliation.js";
|
|
4
5
|
export function classifyFailure(input) {
|
|
5
6
|
const message = errorMessage(input.error);
|
|
6
|
-
|
|
7
|
+
// ADR-017: ReconciliationFailedError is a typed throw from the State
|
|
8
|
+
// Reconciliation Module. Recognize it by class regardless of caller-supplied
|
|
9
|
+
// failureKind so the taxonomy stays consistent.
|
|
10
|
+
const failureKind = input.error instanceof ReconciliationFailedError
|
|
11
|
+
? "reconciliation-drift"
|
|
12
|
+
: input.failureKind ?? inferFailureKind(message);
|
|
7
13
|
switch (failureKind) {
|
|
8
14
|
case "tool-schema":
|
|
9
15
|
return {
|
|
@@ -45,6 +51,14 @@ export function classifyFailure(input) {
|
|
|
45
51
|
exitReason: "verification-drift",
|
|
46
52
|
remediation: "Inspect the verification artifact and reconcile the state snapshot before resuming.",
|
|
47
53
|
};
|
|
54
|
+
case "reconciliation-drift":
|
|
55
|
+
return {
|
|
56
|
+
failureKind,
|
|
57
|
+
action: "escalate",
|
|
58
|
+
reason: `Reconciliation drift${unitSuffix(input)}: ${message}`,
|
|
59
|
+
exitReason: "reconciliation-drift",
|
|
60
|
+
remediation: "Inspect the persistent or repair-failed drift kinds reported by the State Reconciliation Module before resuming.",
|
|
61
|
+
};
|
|
48
62
|
case "provider": {
|
|
49
63
|
const providerClass = classifyError(message, input.retryAfterMs);
|
|
50
64
|
return {
|
|
@@ -521,6 +521,46 @@ export function readSessionLockData(basePath) {
|
|
|
521
521
|
export function isSessionLockProcessAlive(data) {
|
|
522
522
|
return isPidAlive(data.pid);
|
|
523
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* ADR-017 raw primitive: remove orphaned lock artifacts (lock dir + lock file)
|
|
526
|
+
* when the recorded PID is dead or no metadata is present. Mirrors the
|
|
527
|
+
* pre-flight cleanup logic in acquireSessionLock so the stale-worker drift
|
|
528
|
+
* handler can clear the orphan proactively without going through the full
|
|
529
|
+
* acquire path. No-op when the lock is held by an alive process.
|
|
530
|
+
*
|
|
531
|
+
* Returns true when artifacts were removed (drift was present).
|
|
532
|
+
*/
|
|
533
|
+
export function removeStaleSessionLock(basePath) {
|
|
534
|
+
const lp = lockPath(basePath);
|
|
535
|
+
const gsdDir = gsdRoot(basePath);
|
|
536
|
+
const lockTarget = effectiveLockTarget(gsdDir);
|
|
537
|
+
const lockDir = lockTarget + ".lock";
|
|
538
|
+
const existingData = readExistingLockData(lp);
|
|
539
|
+
const isOrphan = !existingData ||
|
|
540
|
+
(typeof existingData.pid === "number" && !isPidAlive(existingData.pid));
|
|
541
|
+
if (!isOrphan)
|
|
542
|
+
return false;
|
|
543
|
+
let removed = false;
|
|
544
|
+
if (existsSync(lockDir)) {
|
|
545
|
+
try {
|
|
546
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
547
|
+
removed = true;
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
/* best-effort */
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (existsSync(lp)) {
|
|
554
|
+
try {
|
|
555
|
+
unlinkSync(lp);
|
|
556
|
+
removed = true;
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
/* best-effort */
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return removed;
|
|
563
|
+
}
|
|
524
564
|
/**
|
|
525
565
|
* Returns true if we currently hold a session lock for the given path.
|
|
526
566
|
*/
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Project/App: GSD-2
|
|
2
|
+
// File Purpose: ADR-017 missing-completion-timestamp drift handler. Detects
|
|
3
|
+
// tasks/slices/milestones marked complete (status = 'complete' | 'done') in
|
|
4
|
+
// the DB but whose `completed_at` column is null, and where the on-disk
|
|
5
|
+
// SUMMARY.md attests to completion. Backfills `completed_at` from the
|
|
6
|
+
// SUMMARY.md mtime — deterministic and idempotent (re-running yields the
|
|
7
|
+
// same value).
|
|
8
|
+
import { existsSync, statSync } from "node:fs";
|
|
9
|
+
import { getMilestone, getMilestoneSlices, getSliceTasks, isDbAvailable, updateMilestoneStatus, updateSliceStatus, updateTaskStatus, } from "../../gsd-db.js";
|
|
10
|
+
import { resolveMilestoneFile, resolveSliceFile, resolveTaskFile, } from "../../paths.js";
|
|
11
|
+
const COMPLETE_STATUSES = new Set(["complete", "done"]);
|
|
12
|
+
function summaryMtimeIso(path) {
|
|
13
|
+
try {
|
|
14
|
+
return statSync(path).mtime.toISOString();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function detectMissingCompletionTimestampDrift(state, ctx) {
|
|
21
|
+
if (!isDbAvailable())
|
|
22
|
+
return [];
|
|
23
|
+
const mid = state.activeMilestone?.id;
|
|
24
|
+
if (!mid)
|
|
25
|
+
return [];
|
|
26
|
+
const milestone = getMilestone(mid);
|
|
27
|
+
if (!milestone)
|
|
28
|
+
return [];
|
|
29
|
+
const drifts = [];
|
|
30
|
+
// Milestone-level
|
|
31
|
+
if (COMPLETE_STATUSES.has(milestone.status) &&
|
|
32
|
+
milestone.completed_at === null) {
|
|
33
|
+
const summary = resolveMilestoneFile(ctx.basePath, mid, "SUMMARY");
|
|
34
|
+
if (summary && existsSync(summary)) {
|
|
35
|
+
drifts.push({
|
|
36
|
+
kind: "missing-completion-timestamp",
|
|
37
|
+
entity: "milestone",
|
|
38
|
+
ids: [mid],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Slice and task levels iterate independently — tasks can complete before
|
|
43
|
+
// the parent slice closes, so task drift must be checked even when the
|
|
44
|
+
// slice is still pending.
|
|
45
|
+
for (const slice of getMilestoneSlices(mid)) {
|
|
46
|
+
if (COMPLETE_STATUSES.has(slice.status) &&
|
|
47
|
+
slice.completed_at === null) {
|
|
48
|
+
const summary = resolveSliceFile(ctx.basePath, mid, slice.id, "SUMMARY");
|
|
49
|
+
if (summary && existsSync(summary)) {
|
|
50
|
+
drifts.push({
|
|
51
|
+
kind: "missing-completion-timestamp",
|
|
52
|
+
entity: "slice",
|
|
53
|
+
ids: [`${mid}/${slice.id}`],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const task of getSliceTasks(mid, slice.id)) {
|
|
58
|
+
if (!COMPLETE_STATUSES.has(task.status))
|
|
59
|
+
continue;
|
|
60
|
+
if (task.completed_at !== null)
|
|
61
|
+
continue;
|
|
62
|
+
const taskSummary = resolveTaskFile(ctx.basePath, mid, slice.id, task.id, "SUMMARY");
|
|
63
|
+
if (taskSummary && existsSync(taskSummary)) {
|
|
64
|
+
drifts.push({
|
|
65
|
+
kind: "missing-completion-timestamp",
|
|
66
|
+
entity: "task",
|
|
67
|
+
ids: [`${mid}/${slice.id}/${task.id}`],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return drifts;
|
|
73
|
+
}
|
|
74
|
+
export function repairMissingCompletionTimestamp(record, ctx) {
|
|
75
|
+
const composite = record.ids[0];
|
|
76
|
+
if (!composite)
|
|
77
|
+
return;
|
|
78
|
+
const parts = composite.split("/");
|
|
79
|
+
if (record.entity === "milestone") {
|
|
80
|
+
const [mid] = parts;
|
|
81
|
+
if (!mid)
|
|
82
|
+
return;
|
|
83
|
+
const milestone = getMilestone(mid);
|
|
84
|
+
if (!milestone ||
|
|
85
|
+
milestone.completed_at !== null ||
|
|
86
|
+
!COMPLETE_STATUSES.has(milestone.status))
|
|
87
|
+
return;
|
|
88
|
+
const summary = resolveMilestoneFile(ctx.basePath, mid, "SUMMARY");
|
|
89
|
+
const ts = summary ? summaryMtimeIso(summary) : null;
|
|
90
|
+
if (!ts)
|
|
91
|
+
return;
|
|
92
|
+
updateMilestoneStatus(mid, milestone.status, ts);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (record.entity === "slice") {
|
|
96
|
+
const [mid, sid] = parts;
|
|
97
|
+
if (!mid || !sid)
|
|
98
|
+
return;
|
|
99
|
+
const slice = getMilestoneSlices(mid).find((s) => s.id === sid);
|
|
100
|
+
if (!slice ||
|
|
101
|
+
slice.completed_at !== null ||
|
|
102
|
+
!COMPLETE_STATUSES.has(slice.status))
|
|
103
|
+
return;
|
|
104
|
+
const summary = resolveSliceFile(ctx.basePath, mid, sid, "SUMMARY");
|
|
105
|
+
const ts = summary ? summaryMtimeIso(summary) : null;
|
|
106
|
+
if (!ts)
|
|
107
|
+
return;
|
|
108
|
+
updateSliceStatus(mid, sid, slice.status, ts);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (record.entity === "task") {
|
|
112
|
+
const [mid, sid, tid] = parts;
|
|
113
|
+
if (!mid || !sid || !tid)
|
|
114
|
+
return;
|
|
115
|
+
const task = getSliceTasks(mid, sid).find((t) => t.id === tid);
|
|
116
|
+
if (!task ||
|
|
117
|
+
task.completed_at !== null ||
|
|
118
|
+
!COMPLETE_STATUSES.has(task.status))
|
|
119
|
+
return;
|
|
120
|
+
const summary = resolveTaskFile(ctx.basePath, mid, sid, tid, "SUMMARY");
|
|
121
|
+
const ts = summary ? summaryMtimeIso(summary) : null;
|
|
122
|
+
if (!ts)
|
|
123
|
+
return;
|
|
124
|
+
updateTaskStatus(mid, sid, tid, task.status, ts);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export const completionTimestampHandler = {
|
|
128
|
+
kind: "missing-completion-timestamp",
|
|
129
|
+
detect: detectMissingCompletionTimestampDrift,
|
|
130
|
+
repair: repairMissingCompletionTimestamp,
|
|
131
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Project/App: GSD-2
|
|
2
|
+
// File Purpose: ADR-017 unmerged-merge-state drift handler. Relocated from
|
|
3
|
+
// auto-recovery.ts as part of issue #5701. Owns:
|
|
4
|
+
// - rebase/cherry-pick/revert leftover cleanup (#4980 HIGH-7)
|
|
5
|
+
// - MERGE_HEAD / SQUASH_MSG reconciliation with auto-resolve of .gsd/
|
|
6
|
+
// conflicts (#530, #2542)
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
10
|
+
import { getErrorMessage } from "../../error-utils.js";
|
|
11
|
+
import { nativeAddPaths, nativeCheckoutTheirs, nativeCommit, nativeConflictFiles, nativeMergeAbort, nativeRebaseAbort, nativeResetHard, } from "../../native-git-bridge.js";
|
|
12
|
+
import { logError, logWarning } from "../../workflow-logger.js";
|
|
13
|
+
const SILENT_NOTIFY = () => { };
|
|
14
|
+
function resolveGitDir(basePath) {
|
|
15
|
+
try {
|
|
16
|
+
const gitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
17
|
+
cwd: basePath,
|
|
18
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
}).trim();
|
|
21
|
+
if (gitDir.length > 0) {
|
|
22
|
+
return isAbsolute(gitDir) ? gitDir : resolve(basePath, gitDir);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
logWarning("recovery", `gitdir resolution failed: ${getErrorMessage(err)}`);
|
|
27
|
+
}
|
|
28
|
+
return join(basePath, ".git");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort abort of a pending merge/squash and hard-reset to HEAD.
|
|
32
|
+
* Handles both real merges (MERGE_HEAD) and squash merges (SQUASH_MSG).
|
|
33
|
+
*/
|
|
34
|
+
function abortAndResetMerge(basePath, hasMergeHead, squashMsgPath) {
|
|
35
|
+
if (hasMergeHead) {
|
|
36
|
+
try {
|
|
37
|
+
nativeMergeAbort(basePath);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
/* best-effort */
|
|
41
|
+
logWarning("recovery", `git merge-abort failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (squashMsgPath) {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(squashMsgPath);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
/* best-effort */
|
|
50
|
+
logWarning("recovery", `file unlink failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
nativeResetHard(basePath);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
/* best-effort */
|
|
58
|
+
logError("recovery", `git reset failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Detect and abort other in-progress git operations left behind by a SIGKILL'd
|
|
63
|
+
* worker (rebase, cherry-pick, revert). Without this, a killed worker
|
|
64
|
+
* mid-rebase leaves `.git/rebase-merge/` or `.git/CHERRY_PICK_HEAD` and the
|
|
65
|
+
* worktree is wedged until the user manually runs the matching `--abort`.
|
|
66
|
+
*
|
|
67
|
+
* Called before merge-state reconciliation because these states block any
|
|
68
|
+
* subsequent merge/commit operation. (#4980 HIGH-7)
|
|
69
|
+
*/
|
|
70
|
+
function reconcileOtherInProgressGitOps(basePath, notify) {
|
|
71
|
+
const gitDir = resolveGitDir(basePath);
|
|
72
|
+
const states = [
|
|
73
|
+
{
|
|
74
|
+
label: "rebase",
|
|
75
|
+
indicators: [join(gitDir, "rebase-merge"), join(gitDir, "rebase-apply")],
|
|
76
|
+
abort: () => nativeRebaseAbort(basePath),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
label: "cherry-pick",
|
|
80
|
+
indicators: [join(gitDir, "CHERRY_PICK_HEAD")],
|
|
81
|
+
abort: () => {
|
|
82
|
+
try {
|
|
83
|
+
execFileSync("git", ["cherry-pick", "--abort"], {
|
|
84
|
+
cwd: basePath,
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
encoding: "utf-8",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
throw new Error(`cherry-pick --abort failed: ${getErrorMessage(err)}`);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: "revert",
|
|
96
|
+
indicators: [join(gitDir, "REVERT_HEAD")],
|
|
97
|
+
abort: () => {
|
|
98
|
+
try {
|
|
99
|
+
execFileSync("git", ["revert", "--abort"], {
|
|
100
|
+
cwd: basePath,
|
|
101
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
102
|
+
encoding: "utf-8",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
throw new Error(`revert --abort failed: ${getErrorMessage(err)}`);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
let reconciled = false;
|
|
112
|
+
for (const s of states) {
|
|
113
|
+
const present = s.indicators.some((p) => existsSync(p));
|
|
114
|
+
if (!present)
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
s.abort();
|
|
118
|
+
notify(`Detected leftover ${s.label} state from prior session — aborted.`, "warning");
|
|
119
|
+
reconciled = true;
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
logError("recovery", `${s.label} abort failed: ${getErrorMessage(err)}`);
|
|
123
|
+
notify(`Detected leftover ${s.label} state but auto-abort failed. ` +
|
|
124
|
+
`Run \`git ${s.label} --abort\` manually before retrying.`, "error");
|
|
125
|
+
return "blocked";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return reconciled ? "reconciled" : "clean";
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Core: detect leftover merge state and reconcile it. Takes a NotifyFn so the
|
|
132
|
+
* legacy reconcileMergeState(basePath, ctx) wrapper and the drift handler can
|
|
133
|
+
* both call it — the drift handler uses SILENT_NOTIFY.
|
|
134
|
+
*/
|
|
135
|
+
function reconcileMergeStateCore(basePath, notify) {
|
|
136
|
+
// First, abort any rebase/cherry-pick/revert left over from a SIGKILL'd
|
|
137
|
+
// worker. Doing this before the merge-state check unblocks any merge that
|
|
138
|
+
// would otherwise refuse with "you have unfinished operation". (HIGH-7)
|
|
139
|
+
const otherOpsResult = reconcileOtherInProgressGitOps(basePath, notify);
|
|
140
|
+
if (otherOpsResult === "blocked")
|
|
141
|
+
return "blocked";
|
|
142
|
+
const gitDir = resolveGitDir(basePath);
|
|
143
|
+
const mergeHeadPath = join(gitDir, "MERGE_HEAD");
|
|
144
|
+
const squashMsgPath = join(gitDir, "SQUASH_MSG");
|
|
145
|
+
const hasMergeHead = existsSync(mergeHeadPath);
|
|
146
|
+
const hasSquashMsg = existsSync(squashMsgPath);
|
|
147
|
+
if (!hasMergeHead && !hasSquashMsg) {
|
|
148
|
+
return otherOpsResult === "reconciled" ? "reconciled" : "clean";
|
|
149
|
+
}
|
|
150
|
+
const conflictedFiles = nativeConflictFiles(basePath);
|
|
151
|
+
if (conflictedFiles.length === 0) {
|
|
152
|
+
// All conflicts resolved — finalize the merge/squash commit
|
|
153
|
+
try {
|
|
154
|
+
const commitSha = nativeCommit(basePath, "chore(gsd): reconcile merge state");
|
|
155
|
+
if (commitSha) {
|
|
156
|
+
const mode = hasMergeHead ? "merge" : "squash commit";
|
|
157
|
+
notify(`Finalized leftover ${mode} from prior session.`, "info");
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
notify("No new commit needed for leftover merge/squash state — already committed.", "info");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const errorMessage = getErrorMessage(err);
|
|
165
|
+
notify(`Failed to finalize leftover merge/squash commit: ${errorMessage}`, "error");
|
|
166
|
+
return "blocked";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Still conflicted — try auto-resolving .gsd/ state file conflicts (#530)
|
|
171
|
+
const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/"));
|
|
172
|
+
const codeConflicts = conflictedFiles.filter((f) => !f.startsWith(".gsd/"));
|
|
173
|
+
if (gsdConflicts.length > 0 && codeConflicts.length === 0) {
|
|
174
|
+
let resolved = true;
|
|
175
|
+
try {
|
|
176
|
+
nativeCheckoutTheirs(basePath, gsdConflicts);
|
|
177
|
+
nativeAddPaths(basePath, gsdConflicts);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
logError("recovery", `auto-resolve .gsd/ conflicts failed: ${e.message}`);
|
|
181
|
+
resolved = false;
|
|
182
|
+
}
|
|
183
|
+
if (resolved) {
|
|
184
|
+
try {
|
|
185
|
+
nativeCommit(basePath, "chore: auto-resolve .gsd/ state file conflicts");
|
|
186
|
+
notify(`Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`, "info");
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
logError("recovery", `auto-commit .gsd/ conflict resolution failed: ${e.message}`);
|
|
190
|
+
resolved = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!resolved) {
|
|
194
|
+
abortAndResetMerge(basePath, hasMergeHead, squashMsgPath);
|
|
195
|
+
notify("Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.", "warning");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
// Code conflicts present — fail safe and preserve any manual resolution
|
|
200
|
+
// work instead of discarding it with merge --abort/reset --hard.
|
|
201
|
+
notify("Detected leftover merge state with unresolved code conflicts. Auto-mode will pause without modifying the worktree so manual conflict resolution is preserved.", "error");
|
|
202
|
+
return "blocked";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return "reconciled";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Legacy entry point preserved for existing callers (auto.ts, auto/phases.ts
|
|
209
|
+
* via loop-deps, integration tests). New code prefers the drift handler.
|
|
210
|
+
*/
|
|
211
|
+
export function reconcileMergeState(basePath, ctx) {
|
|
212
|
+
return reconcileMergeStateCore(basePath, (message, severity) => ctx.ui.notify(message, severity));
|
|
213
|
+
}
|
|
214
|
+
function hasMergeStateLeftovers(basePath) {
|
|
215
|
+
const gitDir = resolveGitDir(basePath);
|
|
216
|
+
return (existsSync(join(gitDir, "MERGE_HEAD")) ||
|
|
217
|
+
existsSync(join(gitDir, "SQUASH_MSG")) ||
|
|
218
|
+
existsSync(join(gitDir, "rebase-merge")) ||
|
|
219
|
+
existsSync(join(gitDir, "rebase-apply")) ||
|
|
220
|
+
existsSync(join(gitDir, "CHERRY_PICK_HEAD")) ||
|
|
221
|
+
existsSync(join(gitDir, "REVERT_HEAD")));
|
|
222
|
+
}
|
|
223
|
+
export function detectMergeStateDrift(_state, ctx) {
|
|
224
|
+
if (hasMergeStateLeftovers(ctx.basePath)) {
|
|
225
|
+
return [{ kind: "unmerged-merge-state", basePath: ctx.basePath }];
|
|
226
|
+
}
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Repair: invoke the reconciliation core with a silent notify. If the
|
|
231
|
+
* underlying reconciliation reports "blocked" (e.g., unresolved code
|
|
232
|
+
* conflicts present), throw so reconcileBeforeDispatch surfaces the drift
|
|
233
|
+
* via ReconciliationFailedError.
|
|
234
|
+
*/
|
|
235
|
+
export function repairMergeStateDrift(record) {
|
|
236
|
+
const result = reconcileMergeStateCore(record.basePath, SILENT_NOTIFY);
|
|
237
|
+
if (result === "blocked") {
|
|
238
|
+
throw new Error(`Merge state reconciliation blocked for ${record.basePath} — likely unresolved code conflicts. Manual intervention required.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
export const mergeStateHandler = {
|
|
242
|
+
kind: "unmerged-merge-state",
|
|
243
|
+
detect: detectMergeStateDrift,
|
|
244
|
+
repair: (record) => {
|
|
245
|
+
repairMergeStateDrift(record);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Project/App: GSD-2
|
|
2
|
+
// File Purpose: ADR-017 unregistered-milestone drift handler. Detects
|
|
3
|
+
// milestones whose on-disk directory has meaningful content (ROADMAP/
|
|
4
|
+
// CONTEXT/SUMMARY) but no DB row, then runs the markdown importer to
|
|
5
|
+
// reconcile. PROJECT.md is the human-facing index — the importer's source
|
|
6
|
+
// of truth is the .gsd/milestones/ directory tree.
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { getMilestone, isDbAvailable } from "../../gsd-db.js";
|
|
9
|
+
import { migrateHierarchyToDb } from "../../md-importer.js";
|
|
10
|
+
import { findMilestoneIds } from "../../milestone-ids.js";
|
|
11
|
+
import { resolveMilestoneFile } from "../../paths.js";
|
|
12
|
+
function milestoneHasContent(basePath, milestoneId) {
|
|
13
|
+
const roadmap = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
14
|
+
const context = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
|
|
15
|
+
const summary = resolveMilestoneFile(basePath, milestoneId, "SUMMARY");
|
|
16
|
+
return ((roadmap !== null && existsSync(roadmap)) ||
|
|
17
|
+
(context !== null && existsSync(context)) ||
|
|
18
|
+
(summary !== null && existsSync(summary)));
|
|
19
|
+
}
|
|
20
|
+
export function detectUnregisteredMilestoneDrift(_state, ctx) {
|
|
21
|
+
if (!isDbAvailable())
|
|
22
|
+
return [];
|
|
23
|
+
const drifts = [];
|
|
24
|
+
for (const milestoneId of findMilestoneIds(ctx.basePath)) {
|
|
25
|
+
if (getMilestone(milestoneId))
|
|
26
|
+
continue;
|
|
27
|
+
if (!milestoneHasContent(ctx.basePath, milestoneId))
|
|
28
|
+
continue;
|
|
29
|
+
drifts.push({ kind: "unregistered-milestone", milestoneId });
|
|
30
|
+
}
|
|
31
|
+
return drifts;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Repair: invoke the markdown importer. migrateHierarchyToDb walks the same
|
|
35
|
+
* findMilestoneIds list the detector uses and INSERTs OR IGNOREs every
|
|
36
|
+
* missing milestone (and its slices/tasks) — idempotent under cap=2 retry.
|
|
37
|
+
*
|
|
38
|
+
* Note: even though we receive one record at a time, the importer is a
|
|
39
|
+
* project-wide operation. Repeated invocation across multiple drift records
|
|
40
|
+
* in the same pass is wasteful but safe; a future optimization could
|
|
41
|
+
* coalesce by checking whether the importer has already run this pass.
|
|
42
|
+
*/
|
|
43
|
+
export function repairUnregisteredMilestone(_record, ctx) {
|
|
44
|
+
migrateHierarchyToDb(ctx.basePath);
|
|
45
|
+
}
|
|
46
|
+
export const unregisteredMilestoneHandler = {
|
|
47
|
+
kind: "unregistered-milestone",
|
|
48
|
+
detect: detectUnregisteredMilestoneDrift,
|
|
49
|
+
repair: repairUnregisteredMilestone,
|
|
50
|
+
};
|