From 7988be71d46f189e9122c29efe27368b8d9f80f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:02:47 -0700 Subject: [PATCH 1/2] fix: never resume, displace or supersede a session of another project (GIT-86) One desktop process serves every chat, so hostname+pid does not name a session. session_start(Y) found the open X session by hostname+pid, replaced the caller's project with X's (stderr only) and carried on in X. - findSessionByHostPid and findResumableSessionOnDisk take the requested project and only return a session of that project. - params.project set and no match: a new session. The registry slot is now hostname+pid+project, so registerSession no longer displaces X, X is not marked superseded, and pruneOrphanedDirs no longer sees X as unregistered. - params.project absent: the most recent session is resumed, and the display names its project (project_from_resumed_session). - force:true carries startedAt, surfaced scars, confirmations, observations and children forward only from a session of the same project. Confirmations were never carried before, and the free tier dropped the carried startedAt. - session_close's registry fallback looks the session up by id. +9 unit tests (4 registry, 5 through session_start); 8 fail on origin/main. Co-Authored-By: Claude Opus 5 --- src/services/active-sessions.ts | 50 +++++-- src/services/session-state.ts | 4 +- src/tools/session-close.ts | 8 +- src/tools/session-start.ts | 64 +++++--- src/types/index.ts | 5 + tests/unit/services/active-sessions.test.ts | 35 +++++ .../tools/session-start-cross-project.test.ts | 141 ++++++++++++++++++ 7 files changed, 269 insertions(+), 38 deletions(-) create mode 100644 tests/unit/tools/session-start-cross-project.test.ts diff --git a/src/services/active-sessions.ts b/src/services/active-sessions.ts index 9be4ac0..6f3ad0b 100644 --- a/src/services/active-sessions.ts +++ b/src/services/active-sessions.ts @@ -126,25 +126,38 @@ function writeRegistry(registry: ActiveSessionsRegistry): void { // --- CRUD operations --- +/** + * GIT-86: the slot a session occupies in the registry is hostname+pid+project. + * + * One desktop process serves every chat, so hostname+pid alone does not name a + * session — two chats in two projects share it. Keying the slot on pid alone + * let session_start(Y) displace an open X session, mark it superseded, and + * leave its directory for pruneOrphanedDirs to delete. + */ +function sameSlot(a: ActiveSessionEntry, b: ActiveSessionEntry): boolean { + return a.hostname === b.hostname && a.pid === b.pid && a.project === b.project; +} + /** * Register a new session in the active-sessions registry. * Idempotent: re-registering the same session_id replaces the entry. - * Returns session IDs that were displaced (different session_id, same hostname+pid). + * Returns session IDs that were displaced (different session_id, same + * hostname+pid+project). Sessions of other projects on the same process are + * left open (GIT-86). */ export function registerSession(entry: ActiveSessionEntry): string[] { const displaced: string[] = []; withLockSync(getLockPath(), () => { const registry = readRegistry(); - // Collect displaced session IDs (same hostname+pid, different session_id) + // Collect displaced session IDs (same slot, different session_id) for (const s of registry.sessions) { - if (s.session_id !== entry.session_id && s.hostname === entry.hostname && s.pid === entry.pid) { + if (s.session_id !== entry.session_id && sameSlot(s, entry)) { displaced.push(s.session_id); } } - // Remove by session_id AND by hostname+pid to prevent duplicates + // Remove by session_id AND by slot to prevent duplicates registry.sessions = registry.sessions.filter((s) => - s.session_id !== entry.session_id && - !(s.hostname === entry.hostname && s.pid === entry.pid) + s.session_id !== entry.session_id && !sameSlot(s, entry) ); registry.sessions.push(entry); writeRegistry(registry); @@ -197,10 +210,16 @@ export function listActiveSessions(): ActiveSessionEntry[] { /** * Find a session by hostname and PID. * Used by session_start to detect if this process already has a registered session. + * + * GIT-86: with `project`, only a session of that project matches — a process + * can hold one open session per project. Without it, the most recently started + * session of the process is returned. */ -export function findSessionByHostPid(hostname: string, pid: number): ActiveSessionEntry | null { +export function findSessionByHostPid(hostname: string, pid: number, project?: string): ActiveSessionEntry | null { const registry = readRegistry(); - return registry.sessions.find((s) => s.hostname === hostname && s.pid === pid) || null; + return registry.sessions + .filter((s) => s.hostname === hostname && s.pid === pid && (project === undefined || s.project === project)) + .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime())[0] || null; } /** @@ -264,7 +283,7 @@ function toAgentIdentity(value: unknown): AgentIdentity { return AGENT_IDENTITIES.find((id) => id === normalized) ?? "Unknown"; } -export function findResumableSessionOnDisk(): ActiveSessionEntry | null { +export function findResumableSessionOnDisk(project?: string): ActiveSessionEntry | null { const currentHostname = os.hostname(); const currentPid = process.pid; const gitmemDir = getGitmemDir(); @@ -304,6 +323,11 @@ export function findResumableSessionOnDisk(): ActiveSessionEntry | null { const age = now - new Date(startedAt).getTime(); if (!Number.isFinite(age) || age > STALE_THRESHOLD_MS) continue; + // GIT-86: a session of another project is never the answer to a request + // for this one, whatever its pid says. + const sessionProject = typeof data.project === "string" ? data.project : "default"; + if (project !== undefined && sessionProject !== project) continue; + const pid = typeof data.pid === "number" ? data.pid : -1; candidates.push({ entry: { @@ -312,7 +336,7 @@ export function findResumableSessionOnDisk(): ActiveSessionEntry | null { started_at: startedAt, hostname: currentHostname, pid, - project: typeof data.project === "string" ? data.project : "default", + project: sessionProject, }, pidAlive: pid > 0 && pid !== currentPid && isPidAlive(pid), }); @@ -331,7 +355,7 @@ export function findResumableSessionOnDisk(): ActiveSessionEntry | null { } // 2. Orphaned by a restart. Adopt at most one — rebinding every dead-PID - // session would leave several rows sharing hostname+pid. + // session would leave several rows sharing hostname+pid+project. const orphaned = candidates.filter((c) => !c.pidAlive).sort(newestFirst)[0]; if (!orphaned) return null; @@ -382,9 +406,7 @@ function reconcileRegistryEntry(entry: ActiveSessionEntry): void { return; // already agrees — no write } registry.sessions = registry.sessions.filter( - (s) => - s.session_id !== entry.session_id && - !(s.hostname === entry.hostname && s.pid === entry.pid) + (s) => s.session_id !== entry.session_id && !sameSlot(s, entry) ); registry.sessions.push(entry); writeRegistry(registry); diff --git a/src/services/session-state.ts b/src/services/session-state.ts index 7d342de..fc021bf 100644 --- a/src/services/session-state.ts +++ b/src/services/session-state.ts @@ -78,12 +78,12 @@ let lastFailedRecoveryFingerprint: number | null | undefined = undefined; * Set the current active session * Called by session_start */ -export function setCurrentSession(context: Omit & { surfacedScars?: SurfacedScar[]; observations?: Observation[]; children?: SessionChild[]; threads?: ThreadObject[] }): void { +export function setCurrentSession(context: Omit & { surfacedScars?: SurfacedScar[]; confirmations?: ScarConfirmation[]; observations?: Observation[]; children?: SessionChild[]; threads?: ThreadObject[] }): void { currentSession = { ...context, recallCalled: false, surfacedScars: context.surfacedScars || [], - confirmations: [], + confirmations: context.confirmations || [], reflections: [], observations: context.observations || [], children: context.children || [], diff --git a/src/tools/session-close.ts b/src/tools/session-close.ts index 2388a4c..d19f229 100644 --- a/src/tools/session-close.ts +++ b/src/tools/session-close.ts @@ -43,7 +43,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { getGitmemPath, getGitmemDir, getSessionPath, getSessionDir } from "../services/gitmem-dir.js"; -import { unregisterSession, findSessionByHostPid } from "../services/active-sessions.js"; +import { unregisterSession, findSessionByHostPid, findSessionById } from "../services/active-sessions.js"; import { loadSuggestions, saveSuggestions, detectSuggestedThreads, loadRecentSessionEmbeddings } from "../services/thread-suggestions.js"; import { writeAgentBriefing } from "../services/agent-briefing.js"; import type { @@ -1285,8 +1285,10 @@ export async function sessionClose( // Fall back to active-sessions registry as last resort if (!existingSession) { try { - const mySession = findSessionByHostPid(os.hostname(), process.pid); - if (mySession && mySession.session_id === sessionId) { + // GIT-86: look up by id — a process can hold one session per project, + // so "this process's session" is not a single entry. + const mySession = findSessionById(sessionId); + if (mySession && mySession.hostname === os.hostname() && mySession.pid === process.pid) { existingSession = { id: sessionId, session_date: mySession.started_at?.split("T")[0] || new Date().toISOString().split("T")[0], diff --git a/src/tools/session-start.ts b/src/tools/session-start.ts index 28ffedd..62302d4 100644 --- a/src/tools/session-start.ts +++ b/src/tools/session-start.ts @@ -42,7 +42,7 @@ import * as os from "os"; import { formatDate } from "../services/timezone.js"; import { productLine, dimText, boldText } from "../services/display-protocol.js"; // Suggested threads removed from start display -import type { PerformanceBreakdown, ComponentPerformance, SurfacedScar, Observation, SessionChild } from "../types/index.js"; +import type { PerformanceBreakdown, ComponentPerformance, SurfacedScar, ScarConfirmation, Observation, SessionChild } from "../types/index.js"; import { querySessionsByDateRange, queryScarUsageByDateRange, @@ -515,7 +515,7 @@ async function sessionStartFree( metricsId: string, existingSessionId?: string, existingStartedAt?: Date, - forceCarryActivity?: { surfacedScars: SurfacedScar[]; observations: Observation[]; children: SessionChild[] }, + forceCarryActivity?: { surfacedScars: SurfacedScar[]; confirmations: ScarConfirmation[]; observations: Observation[]; children: SessionChild[] }, ): Promise { const storage = getStorage(); const isResuming = !!existingSessionId; @@ -613,7 +613,8 @@ async function sessionStartFree( // writeSessionFiles merges with existing file threads to preserve mid-session creations let freeMergedThreads = freeAggregatedThreads; try { - freeMergedThreads = writeSessionFiles(sessionId, agent, project, surfacedScars, freeAggregatedThreads, undefined, false, false, isResuming ? existingStartedAt : undefined); + // existingStartedAt is set only on resume or a same-project force carry-forward. + freeMergedThreads = writeSessionFiles(sessionId, agent, project, surfacedScars, freeAggregatedThreads, undefined, false, false, existingStartedAt); } catch (error) { console.warn("[session_start] Failed to persist session files:", error); } @@ -624,8 +625,12 @@ async function sessionStartFree( sessionId, linearIssue: params.linear_issue, agent, - startedAt: (isResuming && existingStartedAt) || new Date(), + project, + // GIT-86: the force carry-forward startedAt was passed in but only honoured + // on resume, so a forced free-tier session restarted its duration clock. + startedAt: existingStartedAt || new Date(), surfacedScars: freeMergedScars, + confirmations: forceCarryActivity?.confirmations, observations: forceCarryActivity?.observations, children: forceCarryActivity?.children, threads: freeMergedThreads, @@ -636,6 +641,7 @@ async function sessionStartFree( session_id: sessionId, agent, ...(isResuming && { resumed: true }), + ...(isResuming && !params.project && { project_from_resumed_session: true }), detected_environment: env, last_session: lastSession, ...(projectState && { project_state: projectState }), @@ -714,10 +720,16 @@ function restoreSessionState( * Uses the active-sessions registry (hostname+PID) to identify THIS process's * session, preventing cross-process session theft on shared filesystems. * Falls back to legacy active-session.json for backward compatibility. + * + * GIT-86: `project` is the project the caller asked for. When set, only a + * session of that project is resumed; one desktop process serves every chat, + * so this process's other sessions belong to other conversations. When + * absent, the most recent session is resumed, whatever its project. */ function checkExistingSession( agent: AgentIdentity, - force?: boolean + force?: boolean, + project?: Project ): { sessionId: string; agent: AgentIdentity; linearIssue?: string; startedAt?: Date; project?: Project } | null { if (force) { console.error("[session_start] force=true, skipping active session guard"); @@ -736,7 +748,7 @@ function checkExistingSession( // answer — a lost or diverged registry used to make an intact session on // disk invisible, so session_start would open a second session alongside it. const mySession = - findSessionByHostPid(os.hostname(), process.pid) ?? findResumableSessionOnDisk(); + findSessionByHostPid(os.hostname(), process.pid, project) ?? findResumableSessionOnDisk(project); if (mySession) { console.error(`[session_start] Found own session in registry: ${mySession.session_id} (host: ${mySession.hostname}, pid: ${mySession.pid})`); const data = readSessionFile(mySession.session_id); @@ -913,6 +925,12 @@ function formatStartDisplay(result: SessionStartResult, displayInfoMap?: Map | null = null; if (mySession) { diff --git a/src/types/index.ts b/src/types/index.ts index 61654e7..c2f51a4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -198,6 +198,11 @@ export interface SessionStartResult { performance?: PerformanceData; /** Whether this session was resumed from an existing active session */ resumed?: boolean; + /** + * GIT-86: no project was passed, so the resumed session's project was used. + * The display names it, so the caller can see which namespace it is in. + */ + project_from_resumed_session?: boolean; /** Whether this result is from a mid-session refresh (no new session created) */ refreshed?: boolean; /** Message explaining session state */ diff --git a/tests/unit/services/active-sessions.test.ts b/tests/unit/services/active-sessions.test.ts index 8e163a2..2452783 100644 --- a/tests/unit/services/active-sessions.test.ts +++ b/tests/unit/services/active-sessions.test.ts @@ -123,6 +123,17 @@ describe("registerSession", () => { expect(listActiveSessions()[0].session_id).toBe("22222222-2222-2222-2222-222222222222"); }); + it("does not displace a session of another project on the same hostname+pid (GIT-86)", () => { + // One desktop process serves every chat: two projects share hostname+pid. + registerSession(makeEntry({ session_id: "11111111-1111-1111-1111-111111111111", hostname: "host-a", pid: 100, project: "proj-x" })); + const displaced = registerSession( + makeEntry({ session_id: "22222222-2222-2222-2222-222222222222", hostname: "host-a", pid: 100, project: "proj-y" }) + ); + + expect(displaced).toEqual([]); + expect(listActiveSessions().map((s) => s.project).sort()).toEqual(["proj-x", "proj-y"]); + }); + it("returns empty array when no sessions displaced", () => { const entry = makeEntry(); const displaced = registerSession(entry); @@ -224,6 +235,20 @@ describe("findSessionByHostPid", () => { expect(findSessionByHostPid("test-host", 1111)).toBeNull(); }); + it("with a project, matches only a session of that project (GIT-86)", () => { + registerSession(makeEntry({ session_id: "11111111-1111-1111-1111-111111111111", hostname: "h", pid: 7, project: "proj-x" })); + + expect(findSessionByHostPid("h", 7, "proj-y")).toBeNull(); + expect(findSessionByHostPid("h", 7, "proj-x")?.session_id).toBe("11111111-1111-1111-1111-111111111111"); + }); + + it("without a project, returns the most recently started session of the process (GIT-86)", () => { + registerSession(makeEntry({ session_id: "22222222-2222-2222-2222-222222222222", hostname: "h", pid: 7, project: "proj-y", started_at: new Date().toISOString() })); + registerSession(makeEntry({ session_id: "11111111-1111-1111-1111-111111111111", hostname: "h", pid: 7, project: "proj-x", started_at: new Date(Date.now() - 60_000).toISOString() })); + + expect(findSessionByHostPid("h", 7)?.session_id).toBe("22222222-2222-2222-2222-222222222222"); + }); + it("requires both hostname AND pid to match", () => { registerSession(makeEntry({ hostname: "host-A", pid: 100 })); registerSession(makeEntry({ @@ -632,6 +657,16 @@ describe("findResumableSessionOnDisk (GIT-89)", () => { ); }); + it("with a project, never resolves a session of another project (GIT-86)", () => { + seedDiskOnlySession("11111111-1111-1111-1111-111111111111", { project: "proj-x" }); + seedDiskOnlySession("22222222-2222-2222-2222-222222222222", { project: "proj-y", pid: DEAD_PID }); + + expect(findResumableSessionOnDisk("proj-z")).toBeNull(); + expect(findResumableSessionOnDisk("proj-x")?.session_id).toBe("11111111-1111-1111-1111-111111111111"); + // proj-y's orphan is adoptable for proj-y and left alone by the proj-x lookup above. + expect(findResumableSessionOnDisk("proj-y")?.session_id).toBe("22222222-2222-2222-2222-222222222222"); + }); + it("returns null when there is nothing on disk", () => { expect(findResumableSessionOnDisk()).toBeNull(); }); diff --git a/tests/unit/tools/session-start-cross-project.test.ts b/tests/unit/tools/session-start-cross-project.test.ts new file mode 100644 index 0000000..ce158ee --- /dev/null +++ b/tests/unit/tools/session-start-cross-project.test.ts @@ -0,0 +1,141 @@ +/** + * GIT-86: session_start must never resume a session of another project. + * + * One desktop process serves every chat, so this process's registry entries + * and session directories belong to several conversations at once. Before the + * fix, session_start(Y) found X by hostname+pid, overwrote the caller's + * project with X's (stderr only) and continued the X session in the Y chat. + * + * Runs the real session_start on the free tier against a temp .gitmem root. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +vi.mock("../../../src/services/agent-detection.js", () => ({ + detectAgent: () => ({ agent: "desktop", entrypoint: "claude-desktop", docker: false, hostname: os.hostname() }), +})); + +import { sessionStart } from "../../../src/tools/session-start.js"; +import { setGitmemDir, clearGitmemDirCache } from "../../../src/services/gitmem-dir.js"; +import { resetTier } from "../../../src/services/tier.js"; +import { listActiveSessions, findResumableSessionOnDisk, pruneStale } from "../../../src/services/active-sessions.js"; +import { clearCurrentSession, getCurrentSession, addSurfacedScars, addConfirmations } from "../../../src/services/session-state.js"; + +let tmpDir: string; + +const sessionDirExists = (id: string) => fs.existsSync(path.join(tmpDir, "sessions", id, "session.json")); + +beforeEach(() => { + vi.stubEnv("GITMEM_TIER", "free"); + resetTier(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitmem-git86-")); + setGitmemDir(tmpDir); + clearCurrentSession(); +}); + +afterEach(() => { + clearCurrentSession(); + clearGitmemDirCache(); + resetTier(); + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("session_start across projects (GIT-86)", () => { + it("X open; session_start(Y) starts a new Y session and X stays resumable", async () => { + const x = await sessionStart({ project: "proj-x" }); + expect(x.project).toBe("proj-x"); + + const y = await sessionStart({ project: "proj-y" }); + expect(y.session_id).not.toBe(x.session_id); + expect(y.project).toBe("proj-y"); + expect(y.resumed).toBeUndefined(); + + // X left open: registry entry, session directory, and a prune pass keep it. + pruneStale(); + expect(sessionDirExists(x.session_id)).toBe(true); + expect(listActiveSessions().map((s) => [s.session_id, s.project]).sort()).toEqual( + [[x.session_id, "proj-x"], [y.session_id, "proj-y"]].sort() + ); + + // ...and resumable by asking for X again. + const xAgain = await sessionStart({ project: "proj-x" }); + expect(xAgain.session_id).toBe(x.session_id); + expect(xAgain.resumed).toBe(true); + expect(xAgain.project).toBe("proj-x"); + + // Y, likewise, was not displaced by resuming X. + const yAgain = await sessionStart({ project: "proj-y" }); + expect(yAgain.session_id).toBe(y.session_id); + expect(yAgain.resumed).toBe(true); + }); + + it("resumes the same project's session as before", async () => { + const first = await sessionStart({ project: "proj-x" }); + const second = await sessionStart({ project: "proj-x" }); + expect(second.session_id).toBe(first.session_id); + expect(second.resumed).toBe(true); + expect(second.project_from_resumed_session).toBeUndefined(); + }); + + it("no project passed: resumes the most recent session and names its project in the display", async () => { + await sessionStart({ project: "proj-x" }); + await new Promise((r) => setTimeout(r, 5)); // distinct started_at + const y = await sessionStart({ project: "proj-y" }); + + const bare = await sessionStart({}); + expect(bare.session_id).toBe(y.session_id); + expect(bare.resumed).toBe(true); + expect(bare.project).toBe("proj-y"); + expect(bare.project_from_resumed_session).toBe(true); + expect(bare.display).toContain("Resumed project: proj-y"); + }); + + it("the disk scan honours the requested project when the registry is lost", async () => { + const x = await sessionStart({ project: "proj-x" }); + fs.writeFileSync(path.join(tmpDir, "active-sessions.json"), JSON.stringify({ sessions: [] })); + + expect(findResumableSessionOnDisk("proj-y")).toBeNull(); + expect(findResumableSessionOnDisk("proj-x")?.session_id).toBe(x.session_id); + + const y = await sessionStart({ project: "proj-y" }); + expect(y.session_id).not.toBe(x.session_id); + expect(y.resumed).toBeUndefined(); + }); + + it("force:true carries activity forward from a same-project session only", async () => { + const scar = { scar_id: "s-1", scar_title: "t", scar_severity: "medium", surfaced_at: new Date().toISOString(), source: "recall" as const }; + + // Different project in memory: nothing carried, the X session stays open. + const conf = (id: string) => ({ scar_id: id, scar_title: "t", decision: "APPLYING" as const, evidence: "e", confirmed_at: new Date().toISOString() }); + const x = await sessionStart({ project: "proj-x" }); + addSurfacedScars([scar]); + addConfirmations([conf("s-1")]); + const xStartedAt = getCurrentSession()!.startedAt; + await new Promise((r) => setTimeout(r, 5)); + const y = await sessionStart({ project: "proj-y", force: true }); + expect(y.session_id).not.toBe(x.session_id); + expect(getCurrentSession()!.surfacedScars).toHaveLength(0); + expect(getCurrentSession()!.confirmations).toHaveLength(0); + expect(getCurrentSession()!.startedAt.getTime()).toBeGreaterThan(xStartedAt.getTime()); + expect(sessionDirExists(x.session_id)).toBe(true); + expect(listActiveSessions().some((s) => s.session_id === x.session_id)).toBe(true); + + // Same project in memory: carried as before (t-f7c2fa01). + addSurfacedScars([{ ...scar, scar_id: "s-2" }]); + addConfirmations([conf("s-2")]); + const yStartedAt = getCurrentSession()!.startedAt; + const y2 = await sessionStart({ project: "proj-y", force: true }); + expect(y2.session_id).not.toBe(y.session_id); + expect(getCurrentSession()!.surfacedScars.map((s) => s.scar_id)).toEqual(["s-2"]); + expect(getCurrentSession()!.confirmations.map((c) => c.scar_id)).toEqual(["s-2"]); + expect(getCurrentSession()!.startedAt.getTime()).toBe(yStartedAt.getTime()); + // The same-project predecessor is displaced; X is still untouched. + const ids = listActiveSessions().map((s) => s.session_id); + expect(ids).toContain(x.session_id); + expect(ids).not.toContain(y.session_id); + }); +}); From cf5e7bd9382c6c65138ff15753939a30685f0666 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:02:47 -0700 Subject: [PATCH 2/2] =?UTF-8?q?test:=20venue=20driver=20projects=20mode=20?= =?UTF-8?q?=E2=80=94=20one=20process=20serving=20two=20projects=20(GIT-86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session_start(X), session_start(Y), session_start(X), session_start({}), session_start(X, force) through one server over stdio, as the desktop app does. Checks the stored rows, close_compliance, the registry and the session directories. Exits 1 on 1.10.0 (Y resumes X); exits 0 with the fix. Co-Authored-By: Claude Opus 5 --- tests/e2e/blank-supabase.mjs | 70 ++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs index 84b343b..81443a4 100644 --- a/tests/e2e/blank-supabase.mjs +++ b/tests/e2e/blank-supabase.mjs @@ -14,6 +14,7 @@ * Usage (build first: npm run build): * VENUE_ENV=/path/outside/repo/.env node tests/e2e/blank-supabase.mjs flow --label main --out * VENUE_ENV=/path/outside/repo/.env node tests/e2e/blank-supabase.mjs egress --label pr31 --out [--rows 250] [--usage 5] + * VENUE_ENV=/path/outside/repo/.env node tests/e2e/blank-supabase.mjs projects --label git86 --out * * flow session_start -> list_threads -> recall -> confirm_scars -> create_thread * -> list_threads -> session_close -> health, with a local threads.json that @@ -21,6 +22,10 @@ * egress bytes received from the venue on a cold start, a warm start, and around * two session_starts (GIT-98 disk cache). --usage N seeds N scars with * >=3 usage rows so refresh_scar_behavioral_scores() has work to do. + * projects one server process serving two projects, as the desktop app does (GIT-86): + * session_start(X), session_start(Y), session_start(X), session_start({}), + * session_start(X, force). A session of one project is never resumed, + * displaced or superseded by a call for another. * * VENUE_ENV is a KEY=VALUE file OUTSIDE the repo defining SUPABASE_URL, * SUPABASE_SERVICE_ROLE_KEY and VENUE_REF. Credentials are never committed. @@ -96,8 +101,8 @@ function configError(message) { console.error(`[driver] ${message}`); process.exit(2); } -if (!["flow", "egress"].includes(mode)) { - configError("usage: blank-supabase.mjs flow|egress --label --out [--rows N] [--usage N]"); +if (!["flow", "egress", "projects"].includes(mode)) { + configError("usage: blank-supabase.mjs flow|egress|projects --label --out [--rows N] [--usage N]"); } const venueEnvPath = process.env.VENUE_ENV; @@ -514,7 +519,65 @@ async function egress() { return summary; } -const result = mode === "flow" ? await flow() : await egress(); +// ---------------------------------------------------------------- PROJECTS (GIT-86) +async function projects() { + await wipeVenue(); + const X = `${PROJECT}-x`, Y = `${PROJECT}-y`; + const { home, gitmemDir } = isolatedHome(`${label}-projects`); + const ollama = await startFakeOllama(); + const netlog = join(outDir, `netlog-${label}-projects.jsonl`); + writeFileSync(netlog, ""); + const stderr = []; + const srv = await startServer({ home, gitmemDir, ollamaUrl: ollama.url, netlog, stderrSink: stderr }); + await waitQuiet(netlog, { minMs: 2000 }); + + const steps = []; + const start = async (name, args) => { + const text = await srv.call("session_start", { agent_identity: "desktop", ...args }); + const id = (text.match(UUID) || [])[0] ?? null; + const step = { name, args, session_id: id, resumed: /\bresumed\b/.test(text.split("\n")[0]), + names_project: (text.match(/Resumed project: (\S+)/) || [])[1] ?? null }; + steps.push(step); + console.log(`[driver] ${name}: ${id?.slice(0, 8)} resumed=${step.resumed}${step.names_project ? ` names=${step.names_project}` : ""}`); + await waitQuiet(netlog, { minMs: 1500, quietMs: 2000 }); + return step; + }; + const x1 = await start("X", { project: X }); + const y1 = await start("Y while X open", { project: Y }); + const x2 = await start("X again", { project: X }); + const bare = await start("no project", {}); + const xf = await start("X force (Y in memory)", { project: X, force: true }); + await srv.close(); + ollama.close(); + allVenueRequests.push(...venueReqs(readNet(netlog))); + writeFileSync(join(outDir, `stderr-${label}-projects.log`), stderr.join("")); + + const rows = await rest("GET", `gitmem_sessions?select=id,project,close_compliance&id=in.(${[x1, y1, xf].map((s) => s.session_id).join(",")})`, undefined, { Prefer: "" }).then((r) => r.json()); + const row = (id) => rows.find((r) => r.id === id); + const superseded = (id) => row(id)?.close_compliance?.close_type === "superseded"; + const registry = JSON.parse(readFileSync(join(gitmemDir, "active-sessions.json"), "utf8")).sessions; + const dirExists = (id) => existsSync(join(gitmemDir, "sessions", id, "session.json")); + + const checks = { + "Y is a new session, not X resumed": y1.session_id !== x1.session_id && !y1.resumed, + "X row stored under X": row(x1.session_id)?.project === X, + "Y row stored under Y": row(y1.session_id)?.project === Y, + "X again resumes X": x2.session_id === x1.session_id && x2.resumed, + "no project resumes the most recent session (Y)": bare.session_id === y1.session_id && bare.resumed, + "no project names the resumed project": bare.names_project === Y, + "X force is a new session": xf.session_id !== x1.session_id && xf.session_id !== y1.session_id && !xf.resumed, + "Y never superseded by an X call": !superseded(y1.session_id), + "X superseded only by the same-project force": superseded(x1.session_id) && row(x1.session_id)?.close_compliance?.superseded_by === xf.session_id, + "Y still registered and on disk": registry.some((e) => e.session_id === y1.session_id && e.project === Y) && dirExists(y1.session_id), + "no cross-project override on stderr": !/Project override on resume/.test(stderr.join("")), + }; + const failed = Object.entries(checks).filter(([, ok]) => !ok).map(([k]) => k); + const summary = { label, mode, venue: SUPABASE_URL, steps, rows, registry, checks, failed }; + writeFileSync(join(outDir, `summary-${label}-projects.json`), JSON.stringify(summary, null, 2)); + return summary; +} + +const result = mode === "flow" ? await flow() : mode === "projects" ? await projects() : await egress(); const failureCheck = checkFailures(allVenueRequests); // Invariants the edge-function removal (GIT-97) guarantees, whatever the status code. const edgeCalls = allVenueRequests.filter((r) => r.path.startsWith("/functions/v1/")) @@ -531,6 +594,7 @@ if (mode === "flow" && !result.thread_counts_match) { `thread panel mismatch (GIT-97): session_start ${result.session_start_thread_count} != list_threads ${result.list_threads_open_right_after_session_start}` ); } +if (mode === "projects") failureCheck.unexpected.push(...result.failed.map((f) => `cross-project resume (GIT-86): ${f}`)); writeFileSync(join(outDir, `failure-check-${label}-${mode}.json`), JSON.stringify(failureCheck, null, 2)); console.log(JSON.stringify({ ...result, health_text: undefined, stderr_error_lines: undefined, failure_check: failureCheck }, null, 2)); if (failureCheck.unexpected.length > 0) {