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:
parent
bc7669bf0f
commit
43aca75b98
4 changed files with 264 additions and 2 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue