gsd-pi 2.76.0-dev.355e107a0 → 2.76.0-dev.479ad0e78
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/extensions/gsd/auto/loop.js +9 -0
- package/dist/resources/extensions/gsd/auto/phases.js +11 -2
- package/dist/resources/extensions/gsd/auto/run-unit.js +11 -2
- package/dist/resources/extensions/gsd/auto/session.js +6 -1
- package/dist/resources/extensions/gsd/auto-recovery.js +19 -1
- package/dist/resources/extensions/gsd/auto.js +11 -0
- package/dist/resources/extensions/gsd/gitignore.js +1 -0
- package/dist/resources/extensions/gsd/gsd-db.js +23 -6
- package/dist/resources/extensions/gsd/worktree-resolver.js +50 -10
- 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 +16 -16
- 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 +16 -16
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
- 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/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +29 -4
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/dist/session-manager.d.ts +14 -0
- package/packages/mcp-server/dist/session-manager.d.ts.map +1 -1
- package/packages/mcp-server/dist/session-manager.js +49 -1
- package/packages/mcp-server/dist/session-manager.js.map +1 -1
- package/packages/mcp-server/src/mcp-server.test.ts +37 -0
- package/packages/mcp-server/src/server.ts +27 -4
- package/packages/mcp-server/src/session-manager.ts +43 -1
- package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +3 -2
- package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +7 -0
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -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 +7 -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/src/core/agent-session-abort-order.test.ts +3 -2
- package/packages/pi-coding-agent/src/core/agent-session.ts +11 -0
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -0
- package/packages/pi-coding-agent/src/core/extensions/types.ts +7 -0
- package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
- package/src/resources/extensions/gsd/auto/loop.ts +9 -0
- package/src/resources/extensions/gsd/auto/phases.ts +11 -2
- package/src/resources/extensions/gsd/auto/run-unit.ts +11 -2
- package/src/resources/extensions/gsd/auto/session.ts +6 -1
- package/src/resources/extensions/gsd/auto-recovery.ts +11 -1
- package/src/resources/extensions/gsd/auto.ts +11 -0
- package/src/resources/extensions/gsd/gitignore.ts +1 -1
- package/src/resources/extensions/gsd/gsd-db.ts +23 -6
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +69 -1
- package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/integration/gitignore-tracked-gsd.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/integration/idle-recovery.test.ts +30 -0
- package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +12 -0
- package/src/resources/extensions/gsd/tests/resume-dispatch-worktree.test.ts +230 -0
- package/src/resources/extensions/gsd/tests/worktree-db.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +6 -1
- package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +78 -5
- package/src/resources/extensions/gsd/worktree-resolver.ts +54 -9
- /package/dist/web/standalone/.next/static/{TKKbktue8R7x5oPjkT9j1 → JgU2F-5N9mTyB7kUSSk9A}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{TKKbktue8R7x5oPjkT9j1 → JgU2F-5N9mTyB7kUSSk9A}/_ssgManifest.js +0 -0
|
@@ -50,6 +50,13 @@ function stuckStatePath(basePath: string): string {
|
|
|
50
50
|
function loadStuckState(basePath: string): { recentUnits: Array<{ key: string }>; stuckRecoveryAttempts: number } {
|
|
51
51
|
try {
|
|
52
52
|
const data = JSON.parse(readFileSync(stuckStatePath(basePath), "utf-8"));
|
|
53
|
+
// Only load state written by a DIFFERENT process (real session restart).
|
|
54
|
+
// If the PID matches the current process, this state was written by an earlier
|
|
55
|
+
// autoLoop call in the same process (e.g., a test that completed before this
|
|
56
|
+
// one), not by a crashed session — skip it to prevent test state pollution.
|
|
57
|
+
if (data.pid === process.pid) {
|
|
58
|
+
return { recentUnits: [], stuckRecoveryAttempts: 0 };
|
|
59
|
+
}
|
|
53
60
|
return {
|
|
54
61
|
recentUnits: Array.isArray(data.recentUnits) ? data.recentUnits : [],
|
|
55
62
|
stuckRecoveryAttempts: typeof data.stuckRecoveryAttempts === "number" ? data.stuckRecoveryAttempts : 0,
|
|
@@ -65,6 +72,7 @@ function saveStuckState(basePath: string, state: LoopState): void {
|
|
|
65
72
|
const filePath = stuckStatePath(basePath);
|
|
66
73
|
mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true });
|
|
67
74
|
writeFileSync(filePath, JSON.stringify({
|
|
75
|
+
pid: process.pid,
|
|
68
76
|
recentUnits: state.recentUnits.slice(-20), // keep last 20 entries
|
|
69
77
|
stuckRecoveryAttempts: state.stuckRecoveryAttempts,
|
|
70
78
|
updatedAt: new Date().toISOString(),
|
|
@@ -590,6 +598,7 @@ export async function autoLoop(
|
|
|
590
598
|
consecutiveCooldowns = 0;
|
|
591
599
|
recentErrorMessages.length = 0;
|
|
592
600
|
deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } });
|
|
601
|
+
saveStuckState(s.basePath, loopState); // persist across session restarts (#4382)
|
|
593
602
|
debugLog("autoLoop", { phase: "iteration-complete", iteration });
|
|
594
603
|
finishTurn("completed");
|
|
595
604
|
} catch (loopErr) {
|
|
@@ -81,7 +81,11 @@ export function resetSessionTimeoutState(): void {
|
|
|
81
81
|
* Exported for testing as _resolveReportBasePath.
|
|
82
82
|
*/
|
|
83
83
|
export function _resolveReportBasePath(s: Pick<AutoSession, "originalBasePath" | "basePath">): string {
|
|
84
|
-
|
|
84
|
+
// Strip /.gsd/worktrees/ suffix when basePath is itself a worktree path and
|
|
85
|
+
// originalBasePath is falsy — prevents reports landing in the worktree (#3729).
|
|
86
|
+
const resolved = s.originalBasePath || s.basePath;
|
|
87
|
+
const markerIdx = resolved.indexOf("/.gsd/worktrees/");
|
|
88
|
+
return markerIdx !== -1 ? resolved.slice(0, markerIdx) : resolved;
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
/**
|
|
@@ -92,7 +96,12 @@ export function _resolveReportBasePath(s: Pick<AutoSession, "originalBasePath" |
|
|
|
92
96
|
export function _resolveDispatchGuardBasePath(
|
|
93
97
|
s: Pick<AutoSession, "originalBasePath" | "basePath">,
|
|
94
98
|
): string {
|
|
95
|
-
|
|
99
|
+
// Strip /.gsd/worktrees/ suffix when basePath is itself a worktree path and
|
|
100
|
+
// originalBasePath is falsy — prevents guard checks running against the
|
|
101
|
+
// worktree instead of the project root (#3729).
|
|
102
|
+
const resolved = s.originalBasePath || s.basePath;
|
|
103
|
+
const markerIdx = resolved.indexOf("/.gsd/worktrees/");
|
|
104
|
+
return markerIdx !== -1 ? resolved.slice(0, markerIdx) : resolved;
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
const PLAN_V2_GATE_PHASES: ReadonlySet<Phase> = new Set([
|
|
@@ -42,16 +42,25 @@ export async function runUnit(
|
|
|
42
42
|
let sessionResult: { cancelled: boolean };
|
|
43
43
|
let sessionTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
44
44
|
const mySessionSwitchGeneration = ++sessionSwitchGeneration;
|
|
45
|
+
// #3731: Cancellation controller for newSession(). When the session-creation
|
|
46
|
+
// timeout fires, we abort this controller so that the still-in-flight
|
|
47
|
+
// newSession() discards itself after await this.abort() completes, preventing
|
|
48
|
+
// it from capturing the (now-root) process.cwd() and rebuilding the tool
|
|
49
|
+
// runtime with the wrong cwd.
|
|
50
|
+
const sessionAbortController = new AbortController();
|
|
45
51
|
_setSessionSwitchInFlight(true);
|
|
46
52
|
try {
|
|
47
|
-
const sessionPromise = s.cmdCtx!.newSession().finally(() => {
|
|
53
|
+
const sessionPromise = s.cmdCtx!.newSession({ abortSignal: sessionAbortController.signal }).finally(() => {
|
|
48
54
|
if (sessionSwitchGeneration === mySessionSwitchGeneration) {
|
|
49
55
|
_setSessionSwitchInFlight(false);
|
|
50
56
|
}
|
|
51
57
|
});
|
|
52
58
|
const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => {
|
|
53
59
|
sessionTimeoutHandle = setTimeout(
|
|
54
|
-
() =>
|
|
60
|
+
() => {
|
|
61
|
+
sessionAbortController.abort();
|
|
62
|
+
resolve({ cancelled: true });
|
|
63
|
+
},
|
|
55
64
|
NEW_SESSION_TIMEOUT_MS,
|
|
56
65
|
);
|
|
57
66
|
});
|
|
@@ -220,7 +220,12 @@ export class AutoSession {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
get lockBasePath(): string {
|
|
223
|
-
|
|
223
|
+
// Prefer originalBasePath (project root); fall back to basePath.
|
|
224
|
+
// Strip /.gsd/worktrees/ suffix if basePath is itself a worktree path
|
|
225
|
+
// to avoid reading/writing the lock inside the worktree (#3729).
|
|
226
|
+
const resolved = this.originalBasePath || this.basePath;
|
|
227
|
+
const markerIdx = resolved.indexOf("/.gsd/worktrees/");
|
|
228
|
+
return markerIdx !== -1 ? resolved.slice(0, markerIdx) : resolved;
|
|
224
229
|
}
|
|
225
230
|
|
|
226
231
|
reset(): void {
|
|
@@ -13,7 +13,7 @@ import { appendEvent } from "./workflow-events.js";
|
|
|
13
13
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
14
14
|
import { clearParseCache } from "./files.js";
|
|
15
15
|
import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
|
|
16
|
-
import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus } from "./gsd-db.js";
|
|
16
|
+
import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus, insertSlice } from "./gsd-db.js";
|
|
17
17
|
import { isValidationTerminal } from "./state.js";
|
|
18
18
|
import { getErrorMessage } from "./error-utils.js";
|
|
19
19
|
import { logWarning, logError } from "./workflow-logger.js";
|
|
@@ -547,6 +547,16 @@ export function writeBlockerPlaceholder(
|
|
|
547
547
|
try { updateSliceStatus(mid, sid, "complete", ts); } catch (e) { logWarning("recovery", `updateSliceStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`); }
|
|
548
548
|
try { appendEvent(base, { cmd: "complete-slice", params: { milestoneId: mid, sliceId: sid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" }); } catch (e) { logWarning("recovery", `appendEvent failed for slice recovery: ${e instanceof Error ? e.message : String(e)}`); }
|
|
549
549
|
}
|
|
550
|
+
// Insert a placeholder complete slice so deriveState sees activeMilestoneSlices.length > 0
|
|
551
|
+
// and exits the pre-planning phase. Without this, activeMilestoneSlices stays empty
|
|
552
|
+
// after the blocker ROADMAP.md is written, causing deriveState to return phase:'pre-planning'
|
|
553
|
+
// indefinitely and re-dispatching plan-milestone in an infinite loop (#4378).
|
|
554
|
+
if (unitType === "plan-milestone" && mid) {
|
|
555
|
+
try {
|
|
556
|
+
insertSlice({ id: "S00-blocker", milestoneId: mid, title: "Blocker placeholder — planning failed", status: "complete", sequence: 0 });
|
|
557
|
+
} catch (e) { logWarning("recovery", `insertSlice placeholder failed for plan-milestone recovery: ${e instanceof Error ? e.message : String(e)}`); }
|
|
558
|
+
try { appendEvent(base, { cmd: "plan-milestone", params: { milestoneId: mid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" }); } catch (e) { logWarning("recovery", `appendEvent failed for plan-milestone recovery: ${e instanceof Error ? e.message : String(e)}`); }
|
|
559
|
+
}
|
|
550
560
|
}
|
|
551
561
|
|
|
552
562
|
return diagnoseExpectedArtifact(unitType, unitId, base);
|
|
@@ -1507,6 +1507,17 @@ export async function startAuto(
|
|
|
1507
1507
|
s.stepMode = requestedStepMode;
|
|
1508
1508
|
s.cmdCtx = ctx;
|
|
1509
1509
|
s.basePath = base;
|
|
1510
|
+
// ── Resume worktree: if the paused session was inside a milestone worktree,
|
|
1511
|
+
// apply that path as the dispatch basePath immediately (#3723).
|
|
1512
|
+
// This ensures the dispatch loop runs from the worktree directory even when
|
|
1513
|
+
// enterMilestone guard conditions differ between the original and resumed
|
|
1514
|
+
// session (e.g. isolation mode changed, detectWorktreeName differs across
|
|
1515
|
+
// process restarts). We guard with existsSync so a stale or deleted
|
|
1516
|
+
// worktree directory safely falls back to the project root.
|
|
1517
|
+
const resumeWorktreePath = freshStartAssessment.pausedSession?.worktreePath;
|
|
1518
|
+
if (resumeWorktreePath && existsSync(resumeWorktreePath)) {
|
|
1519
|
+
s.basePath = resumeWorktreePath;
|
|
1520
|
+
}
|
|
1510
1521
|
// Ensure the workflow-logger audit log is pinned to the project root
|
|
1511
1522
|
// even when auto-mode is entered via a path that bypasses the
|
|
1512
1523
|
// bootstrap/dynamic-tools ensureDbOpen() → setLogBasePath() chain
|
|
@@ -48,6 +48,7 @@ const BASELINE_PATTERNS = [
|
|
|
48
48
|
// ── GSD state directory (symlink to external storage) ──
|
|
49
49
|
".gsd",
|
|
50
50
|
".gsd-id",
|
|
51
|
+
".mcp.json",
|
|
51
52
|
".bg-shell/",
|
|
52
53
|
|
|
53
54
|
// ── OS junk ──
|
|
@@ -319,4 +320,3 @@ custom_instructions:
|
|
|
319
320
|
writeFileSync(preferencesPath, template, "utf-8");
|
|
320
321
|
return true;
|
|
321
322
|
}
|
|
322
|
-
|
|
@@ -2776,7 +2776,10 @@ export function reconcileWorktreeDb(
|
|
|
2776
2776
|
FROM wt.artifacts
|
|
2777
2777
|
`).run());
|
|
2778
2778
|
|
|
2779
|
-
// Merge milestones — worktree may have updated status/planning fields
|
|
2779
|
+
// Merge milestones — worktree may have updated status/planning fields.
|
|
2780
|
+
// Never downgrade status: complete > active > pre-planning (#4372).
|
|
2781
|
+
// A stale worktree may carry an older 'active' status for a milestone
|
|
2782
|
+
// that the main DB has already marked 'complete'; preserve the higher status.
|
|
2780
2783
|
merged.milestones = countChanges(adapter.prepare(`
|
|
2781
2784
|
INSERT OR REPLACE INTO milestones (
|
|
2782
2785
|
id, title, status, depends_on, created_at, completed_at,
|
|
@@ -2784,11 +2787,25 @@ export function reconcileWorktreeDb(
|
|
|
2784
2787
|
verification_contract, verification_integration, verification_operational, verification_uat,
|
|
2785
2788
|
definition_of_done, requirement_coverage, boundary_map_markdown
|
|
2786
2789
|
)
|
|
2787
|
-
SELECT id, title,
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2790
|
+
SELECT w.id, w.title,
|
|
2791
|
+
CASE
|
|
2792
|
+
WHEN m.status IN ('complete', 'done') AND w.status NOT IN ('complete', 'done')
|
|
2793
|
+
THEN m.status ELSE w.status
|
|
2794
|
+
END,
|
|
2795
|
+
w.depends_on,
|
|
2796
|
+
CASE
|
|
2797
|
+
WHEN m.status IN ('complete', 'done') AND w.status NOT IN ('complete', 'done')
|
|
2798
|
+
THEN m.created_at ELSE w.created_at
|
|
2799
|
+
END,
|
|
2800
|
+
CASE
|
|
2801
|
+
WHEN m.status IN ('complete', 'done') AND w.status NOT IN ('complete', 'done')
|
|
2802
|
+
THEN m.completed_at ELSE w.completed_at
|
|
2803
|
+
END,
|
|
2804
|
+
w.vision, w.success_criteria, w.key_risks, w.proof_strategy,
|
|
2805
|
+
w.verification_contract, w.verification_integration, w.verification_operational, w.verification_uat,
|
|
2806
|
+
w.definition_of_done, w.requirement_coverage, w.boundary_map_markdown
|
|
2807
|
+
FROM wt.milestones w
|
|
2808
|
+
LEFT JOIN milestones m ON m.id = w.id
|
|
2792
2809
|
`).run());
|
|
2793
2810
|
|
|
2794
2811
|
// Merge slices — preserve worktree progress but never downgrade completed status (#2558).
|
|
@@ -35,12 +35,15 @@ function makeMockSession(opts?: {
|
|
|
35
35
|
newSessionDelayMs?: number;
|
|
36
36
|
onNewSessionStart?: (session: any) => void;
|
|
37
37
|
onNewSessionSettle?: (session: any) => void;
|
|
38
|
+
/** Called after the delay with the aborted state of any passed abortSignal.
|
|
39
|
+
* Used to verify that runUnit passes an aborted signal on late resolution (#3731). */
|
|
40
|
+
onSignalCheck?: (aborted: boolean) => void;
|
|
38
41
|
}) {
|
|
39
42
|
const session = {
|
|
40
43
|
active: true,
|
|
41
44
|
verbose: false,
|
|
42
45
|
cmdCtx: {
|
|
43
|
-
newSession: () => {
|
|
46
|
+
newSession: (options?: { abortSignal?: AbortSignal }) => {
|
|
44
47
|
opts?.onNewSessionStart?.(session);
|
|
45
48
|
if (opts?.newSessionThrows) {
|
|
46
49
|
return Promise.reject(new Error(opts.newSessionThrows));
|
|
@@ -50,11 +53,17 @@ function makeMockSession(opts?: {
|
|
|
50
53
|
if (delay > 0) {
|
|
51
54
|
return new Promise<{ cancelled: boolean }>((res) =>
|
|
52
55
|
setTimeout(() => {
|
|
56
|
+
// Simulate AgentSession.newSession() checking abortSignal after
|
|
57
|
+
// its internal async work (abort()) completes — this is where the
|
|
58
|
+
// real code captures process.cwd() and rebuilds the tool runtime.
|
|
59
|
+
// If the signal is aborted, the real code discards the session.
|
|
60
|
+
opts?.onSignalCheck?.(options?.abortSignal?.aborted ?? false);
|
|
53
61
|
opts?.onNewSessionSettle?.(session);
|
|
54
62
|
res(result);
|
|
55
63
|
}, delay),
|
|
56
64
|
);
|
|
57
65
|
}
|
|
66
|
+
opts?.onSignalCheck?.(options?.abortSignal?.aborted ?? false);
|
|
58
67
|
opts?.onNewSessionSettle?.(session);
|
|
59
68
|
return Promise.resolve(result);
|
|
60
69
|
},
|
|
@@ -465,6 +474,65 @@ test("runUnit proceeds when isProviderRequestReady throws (defensive) (#4555)",
|
|
|
465
474
|
assert.equal(pi.calls.length, 0);
|
|
466
475
|
});
|
|
467
476
|
|
|
477
|
+
test("late-resolving newSession() after timeout receives aborted signal so tool runtime is not configured with root cwd (#3731)", async () => {
|
|
478
|
+
// When newSession() times out in runUnit(), auto-mode restores cwd to project
|
|
479
|
+
// root. If newSession() later resolves, it must NOT use process.cwd() to
|
|
480
|
+
// configure the tool runtime (which would give it root cwd, not worktree cwd).
|
|
481
|
+
//
|
|
482
|
+
// The fix: runUnit creates an AbortController, aborts it on timeout, and passes
|
|
483
|
+
// the signal to newSession(). AgentSession.newSession() checks the signal after
|
|
484
|
+
// its internal await this.abort() completes and returns early (discards) if aborted.
|
|
485
|
+
//
|
|
486
|
+
// This test uses mock.timers to control timing precisely.
|
|
487
|
+
_resetPendingResolve();
|
|
488
|
+
mock.timers.enable();
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
let abortedWhenLateSessionSettled: boolean | null = null;
|
|
492
|
+
|
|
493
|
+
// newSession mock simulates AgentSession.newSession() behavior:
|
|
494
|
+
// after an internal delay (representing await this.abort()), it checks the
|
|
495
|
+
// abortSignal — that's where the real code would capture process.cwd() and
|
|
496
|
+
// call _buildRuntime. If aborted, the real code must discard the session.
|
|
497
|
+
const s = makeMockSession({
|
|
498
|
+
newSessionDelayMs: 200_000, // longer than NEW_SESSION_TIMEOUT_MS (120s)
|
|
499
|
+
onSignalCheck: (aborted) => {
|
|
500
|
+
abortedWhenLateSessionSettled = aborted;
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const ctx = makeMockCtx();
|
|
505
|
+
const pi = makeMockPi();
|
|
506
|
+
|
|
507
|
+
const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt");
|
|
508
|
+
|
|
509
|
+
// Tick past the 120s NEW_SESSION_TIMEOUT_MS — runUnit returns cancelled
|
|
510
|
+
mock.timers.tick(121_000);
|
|
511
|
+
await Promise.resolve();
|
|
512
|
+
|
|
513
|
+
const result = await resultPromise;
|
|
514
|
+
assert.equal(result.status, "cancelled", "runUnit must return cancelled on session timeout");
|
|
515
|
+
|
|
516
|
+
// Tick past the delayed newSession (200s total) — the late newSession resolves
|
|
517
|
+
mock.timers.tick(80_000);
|
|
518
|
+
// Drain microtask queue so the .finally() and setTimeout callbacks run
|
|
519
|
+
await Promise.resolve();
|
|
520
|
+
await Promise.resolve();
|
|
521
|
+
|
|
522
|
+
// The key assertion: when the late newSession() resolves, runUnit must have
|
|
523
|
+
// passed an aborted AbortSignal. Without the fix, no signal is passed and
|
|
524
|
+
// abortedWhenLateSessionSettled would be false (or null, if signal not passed at all).
|
|
525
|
+
assert.equal(
|
|
526
|
+
abortedWhenLateSessionSettled,
|
|
527
|
+
true,
|
|
528
|
+
"runUnit must pass an aborted AbortSignal to newSession() when it resolves after the session-creation timeout (#3731). " +
|
|
529
|
+
"Without this, AgentSession.newSession() captures root process.cwd() and rebuilds the tool runtime with wrong cwd.",
|
|
530
|
+
);
|
|
531
|
+
} finally {
|
|
532
|
+
mock.timers.reset();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
468
536
|
// ─── Structural assertions ───────────────────────────────────────────────────
|
|
469
537
|
|
|
470
538
|
test("auto-loop.ts exports autoLoop, runUnit, resolveAgentEnd", async () => {
|
|
@@ -1235,6 +1235,7 @@ describe('git-service', async () => {
|
|
|
1235
1235
|
const content = readFileSync(join(repo, ".gitignore"), "utf-8");
|
|
1236
1236
|
const lines = content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
|
|
1237
1237
|
assert.ok(lines.includes(".gsd"), "ensureGitignore: .gitignore contains .gsd");
|
|
1238
|
+
assert.ok(lines.includes(".mcp.json"), "ensureGitignore: .gitignore contains .mcp.json");
|
|
1238
1239
|
|
|
1239
1240
|
// Idempotent — calling again doesn't add duplicates
|
|
1240
1241
|
const modified2 = ensureGitignore(repo);
|
|
@@ -107,6 +107,7 @@ test("ensureGitignore does NOT add .gsd when .gsd/ has tracked files (#1364)", (
|
|
|
107
107
|
// Other baseline patterns should still be present
|
|
108
108
|
assert.ok(lines.includes(".DS_Store"), "Expected .DS_Store in .gitignore");
|
|
109
109
|
assert.ok(lines.includes("node_modules/"), "Expected node_modules/ in .gitignore");
|
|
110
|
+
assert.ok(lines.includes(".mcp.json"), "Expected .mcp.json in .gitignore");
|
|
110
111
|
} finally {
|
|
111
112
|
cleanup(dir);
|
|
112
113
|
}
|
|
@@ -442,3 +442,33 @@ test('writeBlockerPlaceholder: updates DB slice status for complete-slice (#2653
|
|
|
442
442
|
cleanup(base);
|
|
443
443
|
}
|
|
444
444
|
});
|
|
445
|
+
|
|
446
|
+
test('writeBlockerPlaceholder: inserts placeholder slice for plan-milestone so deriveState exits pre-planning (#4378)', async () => {
|
|
447
|
+
const base = createFixtureBase();
|
|
448
|
+
try {
|
|
449
|
+
const { openDatabase, closeDatabase, insertMilestone, getMilestoneSlices, isDbAvailable } =
|
|
450
|
+
await import("../../gsd-db.ts");
|
|
451
|
+
|
|
452
|
+
const dbPath = join(base, ".gsd", "gsd.db");
|
|
453
|
+
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
|
|
454
|
+
|
|
455
|
+
openDatabase(dbPath);
|
|
456
|
+
try {
|
|
457
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
458
|
+
|
|
459
|
+
// Before fix: writeBlockerPlaceholder wrote the placeholder ROADMAP.md but
|
|
460
|
+
// never updated the DB, so activeMilestoneSlices.length === 0 on next deriveState
|
|
461
|
+
// call → state.phase stays 'pre-planning' → plan-milestone dispatches again → infinite loop
|
|
462
|
+
writeBlockerPlaceholder("plan-milestone", "M001", base, "idle recovery exhausted");
|
|
463
|
+
|
|
464
|
+
const slices = getMilestoneSlices("M001");
|
|
465
|
+
assert.ok(slices.length > 0,
|
|
466
|
+
"writeBlockerPlaceholder must insert a placeholder slice for plan-milestone so " +
|
|
467
|
+
"deriveState sees activeMilestoneSlices.length > 0 and exits pre-planning phase (#4378)");
|
|
468
|
+
} finally {
|
|
469
|
+
if (isDbAvailable()) closeDatabase();
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
cleanup(base);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
@@ -51,4 +51,16 @@ describe("stuck detection persistence (#3704)", () => {
|
|
|
51
51
|
test("stuck state file path uses runtime directory", () => {
|
|
52
52
|
assert.match(loopSource, /stuck-state\.json/);
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
test("saveStuckState called in standard dev path as well as custom engine path (#4382)", () => {
|
|
56
|
+
// Count all call-sites of saveStuckState (excluding the function definition itself).
|
|
57
|
+
// After the fix, both the custom-engine path and the standard dev path must each
|
|
58
|
+
// call saveStuckState so stuckRecoveryAttempts survives session restarts.
|
|
59
|
+
const callMatches = loopSource.match(/saveStuckState\(s\.basePath,\s*loopState\)/g) ?? [];
|
|
60
|
+
assert.ok(
|
|
61
|
+
callMatches.length >= 2,
|
|
62
|
+
`saveStuckState must be called in both the custom-engine path and the standard dev path ` +
|
|
63
|
+
`(found ${callMatches.length} call(s) — standard path is missing its call, #4382)`,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
54
66
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for fix of #3723: auto-mode resume/crash-recovery dispatches
|
|
3
|
+
* from project root instead of milestone worktree.
|
|
4
|
+
*
|
|
5
|
+
* During resume, the paused-session metadata may record `worktreePath` that
|
|
6
|
+
* was active when the session paused. The resume path must use that path (or
|
|
7
|
+
* derive the worktree path via filesystem lookup) to set the dispatch context
|
|
8
|
+
* (`s.basePath`), rather than defaulting to the project root.
|
|
9
|
+
*
|
|
10
|
+
* The fix adds an early worktree-path resolution step in the paused-session
|
|
11
|
+
* resume block of auto.ts — immediately after `s.basePath = base` — so that
|
|
12
|
+
* the correct dispatch directory is used before the dispatch loop runs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const autoSrc = readFileSync(join(__dirname, "..", "auto.ts"), "utf-8");
|
|
26
|
+
|
|
27
|
+
// ── Source-structure tests ────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Extract the paused-session resume block from auto.ts.
|
|
31
|
+
*
|
|
32
|
+
* The block we care about is the `if (s.paused) { ... }` section inside
|
|
33
|
+
* startAuto, which contains `s.basePath = base` (line ~1473) followed by the
|
|
34
|
+
* `enterMilestone` call.
|
|
35
|
+
*
|
|
36
|
+
* We find it by locating the `s.basePath = base` assignment that appears
|
|
37
|
+
* WITHIN the s.paused branch (there's only one: all other basePath assignments
|
|
38
|
+
* use originalBasePath or a different value). We extract from that assignment
|
|
39
|
+
* up to just before `enterMilestone(`.
|
|
40
|
+
*/
|
|
41
|
+
function getBasepathToEnterMilestoneSegment(): string {
|
|
42
|
+
// Find `s.basePath = base;` in the s.paused branch
|
|
43
|
+
// This assignment appears uniquely inside the resume block
|
|
44
|
+
const assignPattern = "s.basePath = base;";
|
|
45
|
+
const assignIdx = autoSrc.indexOf(assignPattern);
|
|
46
|
+
assert.ok(
|
|
47
|
+
assignIdx > -1,
|
|
48
|
+
`auto.ts must contain '${assignPattern}' in the resume block`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Find the next enterMilestone call after this assignment
|
|
52
|
+
const enterMilestoneIdx = autoSrc.indexOf("enterMilestone(", assignIdx);
|
|
53
|
+
assert.ok(
|
|
54
|
+
enterMilestoneIdx > assignIdx,
|
|
55
|
+
"auto.ts must call enterMilestone after the s.basePath = base assignment",
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Return the code between the assignment and enterMilestone
|
|
59
|
+
return autoSrc.slice(assignIdx, enterMilestoneIdx);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("auto.ts resume block resolves paused-session worktreePath and applies it to s.basePath before entering worktree (fixes #3723)", () => {
|
|
63
|
+
// The segment between `s.basePath = base` and `enterMilestone(` must
|
|
64
|
+
// contain logic that resolves the paused-session worktreePath and applies
|
|
65
|
+
// it to s.basePath when the worktree exists on disk.
|
|
66
|
+
//
|
|
67
|
+
// The fix reads the worktree path from freshStartAssessment.pausedSession
|
|
68
|
+
// (since `meta` is out of scope at this point in startAuto) and assigns
|
|
69
|
+
// it to s.basePath, guarded by existsSync.
|
|
70
|
+
//
|
|
71
|
+
// Without this fix, the dispatch loop runs from `base` (project root)
|
|
72
|
+
// instead of the worktree, causing split-brain execution (#3723).
|
|
73
|
+
const segment = getBasepathToEnterMilestoneSegment();
|
|
74
|
+
|
|
75
|
+
// The fix uses freshStartAssessment.pausedSession?.worktreePath via a
|
|
76
|
+
// local variable (resumeWorktreePath) and assigns to s.basePath.
|
|
77
|
+
const hasWorktreePathResolution =
|
|
78
|
+
segment.includes("worktreePath") &&
|
|
79
|
+
segment.includes("existsSync") &&
|
|
80
|
+
segment.includes("s.basePath =");
|
|
81
|
+
|
|
82
|
+
assert.ok(
|
|
83
|
+
hasWorktreePathResolution,
|
|
84
|
+
"auto.ts must resolve the paused-session worktreePath, check existsSync, and assign " +
|
|
85
|
+
"s.basePath before enterMilestone — crash-recovery currently dispatches from project root " +
|
|
86
|
+
"instead of milestone worktree (issue #3723). The fix belongs between `s.basePath = base` " +
|
|
87
|
+
"and the enterMilestone call.",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("auto.ts worktreePath assignment in resume block guards against non-existent path (fixes #3723)", () => {
|
|
92
|
+
// The assignment to s.basePath from the paused-session worktreePath must
|
|
93
|
+
// be guarded by existsSync to avoid setting an invalid basePath if the
|
|
94
|
+
// worktree was cleaned up between pause and resume.
|
|
95
|
+
const segment = getBasepathToEnterMilestoneSegment();
|
|
96
|
+
|
|
97
|
+
// Must have existsSync guard AND a worktreePath reference AND s.basePath assignment
|
|
98
|
+
const hasGuardedAssignment =
|
|
99
|
+
segment.includes("existsSync") &&
|
|
100
|
+
segment.includes("worktreePath") &&
|
|
101
|
+
segment.includes("s.basePath =");
|
|
102
|
+
|
|
103
|
+
assert.ok(
|
|
104
|
+
hasGuardedAssignment,
|
|
105
|
+
"auto.ts must guard the s.basePath = worktreePath assignment with existsSync (fixes #3723)",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── Functional tests ──────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function makeTmpBase(): string {
|
|
112
|
+
const base = join(tmpdir(), `gsd-resume-wt-${randomUUID()}`);
|
|
113
|
+
mkdirSync(join(base, ".gsd", "runtime"), { recursive: true });
|
|
114
|
+
return base;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function cleanup(base: string): void {
|
|
118
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writePausedSession(
|
|
122
|
+
base: string,
|
|
123
|
+
milestoneId: string,
|
|
124
|
+
worktreePath: string | null,
|
|
125
|
+
): void {
|
|
126
|
+
writeFileSync(
|
|
127
|
+
join(base, ".gsd", "runtime", "paused-session.json"),
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
milestoneId,
|
|
130
|
+
originalBasePath: base,
|
|
131
|
+
stepMode: false,
|
|
132
|
+
worktreePath,
|
|
133
|
+
pausedAt: new Date().toISOString(),
|
|
134
|
+
}, null, 2),
|
|
135
|
+
"utf-8",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function makeWorktreePath(base: string, milestoneId: string): string {
|
|
140
|
+
return join(base, ".gsd", "worktrees", milestoneId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function setupWorktreeOnDisk(wt: string): void {
|
|
144
|
+
mkdirSync(wt, { recursive: true });
|
|
145
|
+
// Simulate a git worktree: .git file with gitdir pointer
|
|
146
|
+
writeFileSync(
|
|
147
|
+
join(wt, ".git"),
|
|
148
|
+
"gitdir: /project/.git/worktrees/M001-test\n",
|
|
149
|
+
"utf-8",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeRoadmap(base: string, milestoneId = "M001-test"): void {
|
|
154
|
+
const milestoneDir = join(base, ".gsd", "milestones", milestoneId);
|
|
155
|
+
mkdirSync(join(milestoneDir, "slices", "S01", "tasks"), { recursive: true });
|
|
156
|
+
writeFileSync(
|
|
157
|
+
join(milestoneDir, `${milestoneId}-ROADMAP.md`),
|
|
158
|
+
[
|
|
159
|
+
`# ${milestoneId}: Test Milestone`,
|
|
160
|
+
"",
|
|
161
|
+
"## Slices",
|
|
162
|
+
"",
|
|
163
|
+
"- [ ] **S01: Test slice** `risk:low`",
|
|
164
|
+
" After this: Demo",
|
|
165
|
+
].join("\n"),
|
|
166
|
+
"utf-8",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test("readPausedSessionMetadata round-trips worktreePath from paused-session.json", () => {
|
|
171
|
+
// Verify that the paused-session metadata correctly stores and reads back
|
|
172
|
+
// the worktreePath field — this is what the resume path in auto.ts uses
|
|
173
|
+
// to determine the dispatch basePath (#3723).
|
|
174
|
+
//
|
|
175
|
+
// Implemented inline to avoid slow import chain from interrupted-session.ts.
|
|
176
|
+
const base = makeTmpBase();
|
|
177
|
+
const wt = makeWorktreePath(base, "M001-test");
|
|
178
|
+
try {
|
|
179
|
+
setupWorktreeOnDisk(wt);
|
|
180
|
+
writePausedSession(base, "M001-test", wt);
|
|
181
|
+
|
|
182
|
+
// Simulate readPausedSessionMetadata without importing the full module
|
|
183
|
+
const pausedPath = join(base, ".gsd", "runtime", "paused-session.json");
|
|
184
|
+
const meta = JSON.parse(readFileSync(pausedPath, "utf-8"));
|
|
185
|
+
|
|
186
|
+
assert.ok(meta, "paused-session metadata must be readable");
|
|
187
|
+
assert.equal(meta.milestoneId, "M001-test");
|
|
188
|
+
assert.equal(meta.worktreePath, wt, "worktreePath must round-trip through paused-session.json");
|
|
189
|
+
} finally {
|
|
190
|
+
cleanup(base);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("auto.ts resume block uses worktreePath from freshStartAssessment.pausedSession as dispatch basePath when worktree exists (#3723)", () => {
|
|
195
|
+
// End-to-end structural verification: the auto.ts source must contain code
|
|
196
|
+
// that reads the worktreePath from freshStartAssessment.pausedSession AND
|
|
197
|
+
// applies it to s.basePath (guarded by existsSync) between the
|
|
198
|
+
// `s.basePath = base` assignment and `enterMilestone`.
|
|
199
|
+
//
|
|
200
|
+
// This is the core of the #3723 fix. Without this check, a session that
|
|
201
|
+
// paused while operating inside a worktree will resume dispatching from the
|
|
202
|
+
// project root, not the worktree — causing split-brain execution where some
|
|
203
|
+
// operations target the worktree and others target the project root.
|
|
204
|
+
const segment = getBasepathToEnterMilestoneSegment();
|
|
205
|
+
|
|
206
|
+
// Must reference freshStartAssessment.pausedSession?.worktreePath or
|
|
207
|
+
// equivalent local variable, AND contain an existsSync guard
|
|
208
|
+
const hasAssessmentWorktreePath =
|
|
209
|
+
segment.includes("freshStartAssessment.pausedSession") &&
|
|
210
|
+
segment.includes("worktreePath");
|
|
211
|
+
|
|
212
|
+
assert.ok(
|
|
213
|
+
hasAssessmentWorktreePath,
|
|
214
|
+
"auto.ts must read worktreePath from freshStartAssessment.pausedSession between " +
|
|
215
|
+
"s.basePath=base and enterMilestone",
|
|
216
|
+
);
|
|
217
|
+
assert.ok(
|
|
218
|
+
segment.includes("existsSync"),
|
|
219
|
+
"auto.ts must guard worktreePath usage with existsSync",
|
|
220
|
+
);
|
|
221
|
+
// The actual basePath re-assignment must be present in the segment
|
|
222
|
+
// (note: there are multiple `s.basePath =` assignments — we need one after
|
|
223
|
+
// the initial `s.basePath = base` assignment within this segment)
|
|
224
|
+
const worktreeAssignIdx = segment.lastIndexOf("s.basePath =");
|
|
225
|
+
const baseAssignIdx = segment.indexOf("s.basePath = base");
|
|
226
|
+
assert.ok(
|
|
227
|
+
worktreeAssignIdx > baseAssignIdx,
|
|
228
|
+
"auto.ts must assign s.basePath to the worktree path after `s.basePath = base` in the resume block",
|
|
229
|
+
);
|
|
230
|
+
});
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
insertDecision,
|
|
11
11
|
insertRequirement,
|
|
12
12
|
insertArtifact,
|
|
13
|
+
insertMilestone,
|
|
14
|
+
getMilestone,
|
|
13
15
|
getDecisionById,
|
|
14
16
|
getRequirementById,
|
|
15
17
|
_getAdapter,
|
|
@@ -442,4 +444,37 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|
|
442
444
|
cleanup(mainDir, wtDir);
|
|
443
445
|
}
|
|
444
446
|
|
|
447
|
+
// Test: reconcileWorktreeDb must NOT downgrade milestone status complete→active (#4372)
|
|
448
|
+
{
|
|
449
|
+
const mainDir = tempDir();
|
|
450
|
+
const wtDir = tempDir();
|
|
451
|
+
const mainDb = path.join(mainDir, 'gsd.db');
|
|
452
|
+
const wtDb = path.join(wtDir, 'gsd.db');
|
|
453
|
+
|
|
454
|
+
// Seed main with a milestone already marked complete
|
|
455
|
+
seedMainDb(mainDb);
|
|
456
|
+
const mainAdapter = _getAdapter()!;
|
|
457
|
+
insertMilestone({ id: 'M-COMP', title: 'Completed Milestone', status: 'complete' });
|
|
458
|
+
// Manually mark completed_at so it's a realistic complete record
|
|
459
|
+
mainAdapter.prepare(`UPDATE milestones SET completed_at = '2025-06-01T00:00:00.000Z' WHERE id = 'M-COMP'`).run();
|
|
460
|
+
closeDatabase();
|
|
461
|
+
|
|
462
|
+
// Copy to worktree — the worktree has the milestone as 'active' (stale / older snapshot)
|
|
463
|
+
copyWorktreeDb(mainDb, wtDb);
|
|
464
|
+
openDatabase(wtDb);
|
|
465
|
+
const wtAdapter = _getAdapter()!;
|
|
466
|
+
wtAdapter.prepare(`UPDATE milestones SET status = 'active', completed_at = NULL WHERE id = 'M-COMP'`).run();
|
|
467
|
+
closeDatabase();
|
|
468
|
+
|
|
469
|
+
// Reconcile: main should win and keep 'complete'
|
|
470
|
+
openDatabase(mainDb);
|
|
471
|
+
reconcileWorktreeDb(mainDb, wtDb);
|
|
472
|
+
|
|
473
|
+
const m = getMilestone('M-COMP');
|
|
474
|
+
assert.ok(m !== null, 'milestone M-COMP still exists after reconcile');
|
|
475
|
+
assert.strictEqual(m!.status, 'complete', 'complete milestone must not be downgraded to active by stale worktree');
|
|
476
|
+
|
|
477
|
+
cleanup(mainDir, wtDir);
|
|
478
|
+
}
|
|
479
|
+
|
|
445
480
|
// ─── Final Report ──────────────────────────────────────────────────────────
|