fix(gsd): add worktree lifecycle events to journal (#2486)

* fix(gsd): add worktree lifecycle events to journal

* fix(gsd): widen source scan window in merge-conflict test

The journal event additions in _mergeWorktreeMode pushed the
MergeConflictError re-throw past the 5000-char scan window used
by merge-conflict-stops-loop.test.ts. Increase to 6000 to
accommodate the added emitJournalEvent calls.

* fix(gsd): restore cwd before temp dir cleanup in journal test

On Windows, rmSync fails with EPERM when the process cwd is inside
the directory being deleted. Save and restore the original cwd in
afterEach before cleanup.
This commit is contained in:
Jeremy McSpadden 2026-03-25 09:43:39 -05:00 committed by GitHub
parent bc7669bf0f
commit 43aca75b98
4 changed files with 264 additions and 2 deletions

View file

@ -32,7 +32,12 @@ export type JournalEventType =
| "milestone-transition"
| "stuck-detected"
| "sidecar-dequeue"
| "iteration-end";
| "iteration-end"
| "worktree-enter"
| "worktree-create-failed"
| "worktree-skip"
| "worktree-merge-start"
| "worktree-merge-failed";
/** A single structured event in the journal. */
export interface JournalEntry {

View file

@ -27,7 +27,7 @@ console.log("\n=== #2330: Merge conflict stops auto loop ===");
const methodStart = resolverSrc.indexOf("Worktree-mode merge:");
assertTrue(methodStart > 0, "worktree-resolver has _mergeWorktreeMode method");
const methodBody = resolverSrc.slice(methodStart, methodStart + 5000);
const methodBody = resolverSrc.slice(methodStart, methodStart + 6000);
const rethrowsConflict =
methodBody.includes("MergeConflictError") &&
methodBody.includes("throw err");

View file

@ -0,0 +1,220 @@
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
WorktreeResolver,
type WorktreeResolverDeps,
type NotifyCtx,
} from "../worktree-resolver.js";
import { AutoSession } from "../auto/session.js";
import type { JournalEntry } from "../journal.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeSession(
overrides?: Partial<{ basePath: string; originalBasePath: string }>,
): AutoSession {
const s = new AutoSession();
s.basePath = overrides?.basePath ?? "/project";
s.originalBasePath = overrides?.originalBasePath ?? "/project";
return s;
}
function makeDeps(
overrides?: Partial<WorktreeResolverDeps>,
): WorktreeResolverDeps {
const deps: WorktreeResolverDeps = {
isInAutoWorktree: () => false,
shouldUseWorktreeIsolation: () => true,
getIsolationMode: () => "worktree",
mergeMilestoneToMain: () => ({ pushed: false, codeFilesChanged: true }),
syncWorktreeStateBack: () => ({ synced: [] }),
teardownAutoWorktree: () => {},
createAutoWorktree: (_basePath: string, milestoneId: string) =>
`/project/.gsd/worktrees/${milestoneId}`,
enterAutoWorktree: (_basePath: string, milestoneId: string) =>
`/project/.gsd/worktrees/${milestoneId}`,
getAutoWorktreePath: () => null,
autoCommitCurrentBranch: () => {},
getCurrentBranch: () => "main",
autoWorktreeBranch: (milestoneId: string) => `milestone/${milestoneId}`,
resolveMilestoneFile: (_basePath: string, milestoneId: string) =>
`/project/.gsd/milestones/${milestoneId}/${milestoneId}-ROADMAP.md`,
readFileSync: () => "# Roadmap\n- [x] S01: Slice one\n",
GitServiceImpl: class {
constructor() {}
} as unknown as WorktreeResolverDeps["GitServiceImpl"],
loadEffectiveGSDPreferences: () => ({ preferences: { git: {} } }),
invalidateAllCaches: () => {},
captureIntegrationBranch: () => {},
...overrides,
};
return deps;
}
function makeNotifyCtx(): NotifyCtx {
return {
notify: () => {},
};
}
/** Read all journal entries from a temp .gsd/journal directory. */
function readJournalEntries(basePath: string): JournalEntry[] {
const journalDir = join(basePath, ".gsd", "journal");
try {
const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort();
const entries: JournalEntry[] = [];
for (const file of files) {
const raw = readFileSync(join(journalDir, file), "utf-8");
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
entries.push(JSON.parse(line) as JournalEntry);
}
}
return entries;
} catch {
return [];
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("worktree journal events", () => {
let tmp: string;
const originalCwd = process.cwd();
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "wt-journal-"));
});
afterEach(() => {
// Restore cwd before cleanup — on Windows, rmSync fails with EPERM
// if the process cwd is inside the directory being deleted.
try { process.chdir(originalCwd); } catch { /* best-effort */ }
rmSync(tmp, { recursive: true, force: true });
});
test("enterMilestone emits worktree-enter on success (new worktree)", () => {
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
const deps = makeDeps({ getAutoWorktreePath: () => null });
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", makeNotifyCtx());
const entries = readJournalEntries(tmp);
const enter = entries.find(e => e.eventType === "worktree-enter");
assert.ok(enter, "worktree-enter event should be emitted");
assert.equal(enter!.data?.milestoneId, "M001");
assert.equal(enter!.data?.created, true);
assert.ok(enter!.data?.wtPath);
});
test("enterMilestone emits worktree-enter with created=false for existing worktree", () => {
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
const deps = makeDeps({
getAutoWorktreePath: () => "/project/.gsd/worktrees/M001",
});
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", makeNotifyCtx());
const entries = readJournalEntries(tmp);
const enter = entries.find(e => e.eventType === "worktree-enter");
assert.ok(enter, "worktree-enter event should be emitted");
assert.equal(enter!.data?.created, false);
});
test("enterMilestone emits worktree-skip when isolation disabled", () => {
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
const deps = makeDeps({ shouldUseWorktreeIsolation: () => false });
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", makeNotifyCtx());
const entries = readJournalEntries(tmp);
const skip = entries.find(e => e.eventType === "worktree-skip");
assert.ok(skip, "worktree-skip event should be emitted");
assert.equal(skip!.data?.milestoneId, "M001");
assert.equal(skip!.data?.reason, "isolation-disabled");
});
test("enterMilestone emits worktree-create-failed on error", () => {
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
const deps = makeDeps({
getAutoWorktreePath: () => null,
createAutoWorktree: () => { throw new Error("disk full"); },
});
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", makeNotifyCtx());
const entries = readJournalEntries(tmp);
const failed = entries.find(e => e.eventType === "worktree-create-failed");
assert.ok(failed, "worktree-create-failed event should be emitted");
assert.equal(failed!.data?.milestoneId, "M001");
assert.equal(failed!.data?.error, "disk full");
assert.equal(failed!.data?.fallback, "project-root");
});
test("mergeAndExit emits worktree-merge-start", () => {
const s = makeSession({
basePath: join(tmp, "worktree"),
originalBasePath: tmp,
});
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
});
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", makeNotifyCtx());
const entries = readJournalEntries(tmp);
const start = entries.find(e => e.eventType === "worktree-merge-start");
assert.ok(start, "worktree-merge-start event should be emitted");
assert.equal(start!.data?.milestoneId, "M001");
assert.equal(start!.data?.mode, "worktree");
});
test("mergeAndExit emits worktree-merge-failed on error", () => {
const s = makeSession({
basePath: join(tmp, "worktree"),
originalBasePath: tmp,
});
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
mergeMilestoneToMain: () => { throw new Error("conflict in main"); },
});
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", makeNotifyCtx());
const entries = readJournalEntries(tmp);
const failed = entries.find(e => e.eventType === "worktree-merge-failed");
assert.ok(failed, "worktree-merge-failed event should be emitted");
assert.equal(failed!.data?.milestoneId, "M001");
assert.equal(failed!.data?.error, "conflict in main");
});
test("journal entries have valid flowId, seq, and ts fields", () => {
const s = makeSession({ basePath: tmp, originalBasePath: tmp });
const deps = makeDeps({ shouldUseWorktreeIsolation: () => false });
const resolver = new WorktreeResolver(s, deps);
resolver.enterMilestone("M001", makeNotifyCtx());
const entries = readJournalEntries(tmp);
assert.ok(entries.length > 0, "at least one entry should exist");
const entry = entries[0];
assert.ok(entry.flowId, "flowId should be set");
assert.ok(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(entry.flowId),
"flowId should be a valid UUID",
);
assert.equal(entry.seq, 0);
assert.ok(entry.ts, "ts should be set");
assert.ok(!isNaN(Date.parse(entry.ts)), "ts should be a valid ISO date");
});
});

View file

@ -14,10 +14,12 @@
*/
import { existsSync, unlinkSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { AutoSession } from "./auto/session.js";
import { debugLog } from "./debug-logger.js";
import { MergeConflictError } from "./git-service.js";
import { emitJournalEvent } from "./journal.js";
// ─── Dependency Interface ──────────────────────────────────────────────────
@ -155,6 +157,13 @@ export class WorktreeResolver {
skipped: true,
reason: "isolation-disabled",
});
emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
ts: new Date().toISOString(),
flowId: randomUUID(),
seq: 0,
eventType: "worktree-skip",
data: { milestoneId, reason: "isolation-disabled" },
});
return;
}
@ -184,6 +193,13 @@ export class WorktreeResolver {
result: "success",
wtPath,
});
emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
ts: new Date().toISOString(),
flowId: randomUUID(),
seq: 0,
eventType: "worktree-enter",
data: { milestoneId, wtPath, created: !existingPath },
});
ctx.notify(`Entered worktree for ${milestoneId} at ${wtPath}`, "info");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@ -193,6 +209,13 @@ export class WorktreeResolver {
result: "error",
error: msg,
});
emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
ts: new Date().toISOString(),
flowId: randomUUID(),
seq: 0,
eventType: "worktree-create-failed",
data: { milestoneId, error: msg, fallback: "project-root" },
});
ctx.notify(
`Auto-worktree creation for ${milestoneId} failed: ${msg}. Continuing in project root.`,
"warning",
@ -288,6 +311,13 @@ export class WorktreeResolver {
mode,
basePath: this.s.basePath,
});
emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
ts: new Date().toISOString(),
flowId: randomUUID(),
seq: 0,
eventType: "worktree-merge-start",
data: { milestoneId, mode },
});
if (mode === "none") {
debugLog("WorktreeResolver", {
@ -408,6 +438,13 @@ export class WorktreeResolver {
error: msg,
fallback: "chdir-to-project-root",
});
emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
ts: new Date().toISOString(),
flowId: randomUUID(),
seq: 0,
eventType: "worktree-merge-failed",
data: { milestoneId, error: msg },
});
// Surface a clear, actionable error. The worktree and milestone branch are
// intentionally preserved — nothing has been deleted. The user can retry
// /gsd dispatch complete-milestone or merge manually once the underlying issue is fixed