Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 36 additions & 14 deletions src/services/active-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: {
Expand All @@ -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),
});
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/services/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ let lastFailedRecoveryFingerprint: number | null | undefined = undefined;
* Set the current active session
* Called by session_start
*/
export function setCurrentSession(context: Omit<SessionContext, 'recallCalled' | 'surfacedScars' | 'confirmations' | 'reflections' | 'observations' | 'children' | 'threads' | 'feedbackSubmitCount'> & { surfacedScars?: SurfacedScar[]; observations?: Observation[]; children?: SessionChild[]; threads?: ThreadObject[] }): void {
export function setCurrentSession(context: Omit<SessionContext, 'recallCalled' | 'surfacedScars' | 'confirmations' | 'reflections' | 'observations' | 'children' | 'threads' | 'feedbackSubmitCount'> & { 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 || [],
Expand Down
8 changes: 5 additions & 3 deletions src/tools/session-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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],
Expand Down
64 changes: 45 additions & 19 deletions src/tools/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<SessionStartResult> {
const storage = getStorage();
const isResuming = !!existingSessionId;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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,
Expand All @@ -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 }),
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -913,6 +925,12 @@ function formatStartDisplay(result: SessionStartResult, displayInfoMap?: Map<str
if (result.project) parts.push(result.project);
visual.push(dimText(parts.join(" · ")));

// GIT-86: resumed without a project — say which one, so a chat that meant a
// different project is not silently working in this one.
if (result.project_from_resumed_session) {
visual.push(`Resumed project: ${result.project} (no project was passed — pass project to start a session in another)`);
}

// Line 3: duration + surfaced scars for resumed/refreshed sessions
if (result.resumed || result.refreshed) {
const session = getCurrentSession();
Expand Down Expand Up @@ -1064,34 +1082,40 @@ export async function sessionStart(
const agent = params.agent_identity || env.agent;
let project: Project = params.project || getConfigProject() || "default";

// Check for existing active session — reuse session_id but still load full context
const existingSession = checkExistingSession(agent, params.force);
// Check for existing active session — reuse session_id but still load full context.
// GIT-86: an explicit project only ever resumes a session of that project; a
// miss starts a new session and leaves this process's other sessions open.
const existingSession = checkExistingSession(agent, params.force, params.project);
const isResuming = existingSession !== null;

// When resuming, prefer the stored project from the existing session.
// This prevents project drift after context compaction — the agent may pass
// the wrong project (e.g., from CLAUDE.md defaults) but the stored session
// knows the real project.
if (isResuming && existingSession?.project) {
if (existingSession.project !== project) {
console.error(`[session_start] Project override on resume: ${project} → ${existingSession.project} (from stored session)`);
}
// No project requested: the resumed session's project stands, and the
// display names it (project_from_resumed_session). With a project requested
// the lookup only matched that project, so there is nothing to override.
if (isResuming && existingSession?.project && !params.project) {
project = existingSession.project;
}

// t-f7c2fa01: When force:true kills an existing session, carry forward its startedAt
// so session_close duration reflects the full conversation, not just the new session.
// Also carry forward activity counts (recalls, observations) so standard close isn't rejected.
const priorSession = params.force ? getCurrentSession() : null;
// GIT-86: only from a session of the same project. The in-memory session may
// belong to another chat served by this process; its scars, observations and
// children are that conversation's, and it stays open.
const inMemorySession = params.force ? getCurrentSession() : null;
const priorSession = inMemorySession && inMemorySession.project === project ? inMemorySession : null;
if (inMemorySession && !priorSession) {
console.error(`[session_start] force=true: prior session ${inMemorySession.sessionId.slice(0, 8)} is project ${inMemorySession.project ?? "(none)"}, not ${project} — nothing carried forward, prior session left open`);
}
const forceCarryStartedAt = priorSession?.startedAt;
const forceCarrySurfacedScars = priorSession?.surfacedScars || [];
const forceCarryConfirmations = priorSession?.confirmations || [];
const forceCarryObservations = priorSession?.observations || [];
const forceCarryChildren = priorSession?.children || [];

// Free tier: all-local path
if (!hasSupabase()) {
return sessionStartFree(params, env, agent, project, timer, metricsId, existingSession?.sessionId, existingSession?.startedAt || forceCarryStartedAt,
priorSession ? { surfacedScars: forceCarrySurfacedScars, observations: forceCarryObservations, children: forceCarryChildren } : undefined);
priorSession ? { surfacedScars: forceCarrySurfacedScars, confirmations: forceCarryConfirmations, observations: forceCarryObservations, children: forceCarryChildren } : undefined);
}

// 2. Load last session + decisions + analytics in parallel (was sequential)
Expand Down Expand Up @@ -1203,6 +1227,7 @@ export async function sessionStart(
project,
startedAt: (isResuming && existingSession?.startedAt) || forceCarryStartedAt || new Date(),
surfacedScars: mergedScars,
confirmations: forceCarryConfirmations,
observations: forceCarryObservations,
children: forceCarryChildren,
threads: mergedThreads,
Expand All @@ -1222,6 +1247,7 @@ export async function sessionStart(
session_id: sessionId,
agent,
...(isResuming && { resumed: true }),
...(isResuming && !params.project && { project_from_resumed_session: true }),
detected_environment: env,
last_session: slimLastSession,
...(projectState && { project_state: projectState }),
Expand Down Expand Up @@ -1311,7 +1337,7 @@ export async function sessionRefresh(
project = params.project || currentSession.project || "default";
} else {
// Fallback — check registry for this process, then legacy file
const mySession = findSessionByHostPid(os.hostname(), process.pid);
const mySession = findSessionByHostPid(os.hostname(), process.pid, params.project);
let raw: Record<string, unknown> | null = null;

if (mySession) {
Expand Down
5 changes: 5 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading
Loading