the selector locked on `hasStarted`, so the tier was fixed for the life of a conversation. nothing justified it — the commit that added the flag says only "default model" — and the hazard it resembles is narrower than what it blocked: crossing HARNESS hands an opencode `ses_…` id to `claude --resume`, which reads as a dead agent rather than a wrong dispatch. changing TIER was always safe. so tier is free, harness is pinned, and the server refuses a crossing rather than relying on the UI to. the claude side does it the way the CLI's /model does: `Query.setModel()`, a control request that needs streaming input mode, which is the only mode this file uses. nothing is torn down and nothing is re-resumed — cwd, permission mode and transcript all continue and only the next response changes. an earlier plan here was to kill the session and re-create it with the stored resume id; that would have worked and was unnecessary. opencode already switched per turn (serve-runner POSTs the model to the live session), so only the UI was in its way. FIXES A SILENT MISMATCH while it is here. model reached the SDK once, in createSession's query() options, and every later turn was pushed onto that same call — so a changed model was dropped while session:init, the cost fold and the durable log all reported the new tier. reachable today by leaving a chat idle past the 30-minute GC: the UI said sonnet, opus answered, nothing logged it. the fold now tracks what the harness actually did rather than what the client asked for, and a refused switch reverts the picker. the switch is a durable `model:switched` event and a centred marker in the transcript, because the useful question is the BOUNDARY — which half of this conversation is opus. per-message model was already stamped server-side and shown nowhere, and claude-sessions.ts reads the FIRST model in a transcript, so a switch was invisible in history. tests: turn-stream proves the marker is durable and lands after the previous model's last words; the selector tests pull in opposite directions, so unlocking everything fails one and restoring the blanket lock fails the other. verified by re-breaking both. not done: opencode's `steer` path (a send mid-turn) returns before the model POST, so a switch there applies on the next full turn. session.model also stays stale for opencode tier changes — harness routing is unaffected.
225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
import type { UserSession } from './types';
|
|
import { logger } from './logger';
|
|
import { clearOpenCodeSession } from './opencode/state';
|
|
|
|
class SessionManager {
|
|
private sessions = new Map<string, UserSession>();
|
|
private userSessions = new Map<string, string[]>();
|
|
|
|
getOrCreate(
|
|
sessionId: string,
|
|
email: string,
|
|
cwd: string,
|
|
model: string,
|
|
groupSlug?: string | null,
|
|
context?: string,
|
|
contextId?: string,
|
|
): UserSession {
|
|
let session = this.sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
session = {
|
|
sessionId,
|
|
email,
|
|
cwd,
|
|
model,
|
|
piProcess: null,
|
|
sockets: new Set(),
|
|
lastActivity: Date.now(),
|
|
idleTimer: null,
|
|
streamBuffer: '',
|
|
isGenerating: false,
|
|
systemContextSent: false,
|
|
messages: [],
|
|
meta: {
|
|
id: sessionId,
|
|
title: '',
|
|
model,
|
|
cwd,
|
|
groupSlug: groupSlug || null,
|
|
context,
|
|
contextId,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messageCount: 0,
|
|
cost: {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
totalUSD: 0,
|
|
},
|
|
},
|
|
};
|
|
|
|
this.sessions.set(sessionId, session);
|
|
|
|
const userSessionIds = this.userSessions.get(email) || [];
|
|
userSessionIds.push(sessionId);
|
|
this.userSessions.set(email, userSessionIds);
|
|
}
|
|
|
|
session.lastActivity = Date.now();
|
|
return session;
|
|
}
|
|
|
|
getSession(sessionId: string): UserSession | null {
|
|
return this.sessions.get(sessionId) || null;
|
|
}
|
|
|
|
/**
|
|
* Record that a live session moved onto a different model.
|
|
*
|
|
* `getOrCreate` takes a `model` and, for a session that already exists, silently discards it — which was
|
|
* correct while the model could not change, and became a lie the moment it could. Everything that routes
|
|
* or attributes by `session.model` reads this: `handleStop`, `endTurnIfAgentIsGone`, the orphan adopter,
|
|
* and the cost fold. Leaving it stale meant a stop landing on the wrong harness's kill path.
|
|
*
|
|
* Called only AFTER the harness confirms the switch. A refused switch must leave this alone, or the
|
|
* server's record and the running agent disagree in the direction that is hardest to notice.
|
|
*/
|
|
setModel(sessionId: string, model: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.model === model) return;
|
|
session.model = model;
|
|
session.meta.model = model;
|
|
session.meta.updatedAt = Date.now();
|
|
}
|
|
|
|
getUserSessions(email: string): UserSession[] {
|
|
const sessionIds = this.userSessions.get(email) || [];
|
|
return sessionIds.map((id) => this.sessions.get(id)).filter((s): s is UserSession => s !== undefined);
|
|
}
|
|
|
|
/** Drop this process's record of a session. Shared by `deleteSession` and `releaseSession`. */
|
|
private forget(session: UserSession): void {
|
|
if (session.idleTimer) {
|
|
clearTimeout(session.idleTimer);
|
|
}
|
|
|
|
this.sessions.delete(session.sessionId);
|
|
|
|
const userSessionIds = this.userSessions.get(session.email);
|
|
if (userSessionIds) {
|
|
const filtered = userSessionIds.filter((id) => id !== session.sessionId);
|
|
if (filtered.length > 0) {
|
|
this.userSessions.set(session.email, filtered);
|
|
} else {
|
|
this.userSessions.delete(session.email);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* End the session for good: kill the agent upstream, drop the subscription, forget the record. This is
|
|
* what an explicit "disconnect" means — the user said stop.
|
|
*/
|
|
deleteSession(sessionId: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
|
|
// Clean up sidecar subscriptions
|
|
if (session._sidecarUnsub) {
|
|
session._sidecarUnsub();
|
|
}
|
|
if (session._claudeKill) {
|
|
session._claudeKill();
|
|
}
|
|
|
|
// The OpenCode sessionKey → `ses_…` map is keyed on our session id and nothing else cleared it, so
|
|
// it grew for the process lifetime and a REUSED key silently resumed a stale OpenCode session.
|
|
// Only on deleteSession, never on releaseSession: releasing is "let go, leave it running", and a
|
|
// returning browser has to be able to find the same `ses_…` again.
|
|
clearOpenCodeSession(sessionId);
|
|
|
|
this.forget(session);
|
|
}
|
|
|
|
/**
|
|
* Let go WITHOUT killing: unsubscribe, forget the record, leave the agent running.
|
|
*
|
|
* This is what the idle GC should always have done. Officer's timer was doing two unrelated jobs —
|
|
* collecting its own in-memory binding, which is its business, and terminating the agent, which is the
|
|
* sidecar's. The sidecar already refuses to collect a session that is mid-turn or holding background
|
|
* tasks (`claude-manager.ts` → `pendingTasks`, and an `armIdle` that re-checks rather than firing
|
|
* once); officer knew none of that and killed anyway. A `run_in_background` job outliving the browser
|
|
* — a laptop that ran out of battery — died an hour later for no reason.
|
|
*
|
|
* Nothing is stranded by forgetting the record. The sidecar keeps committing to `chat_session_events`,
|
|
* and a returning browser goes through `adoptOrphanedSession`, which rebuilds the record and a fresh
|
|
* subscription. That is the same path every `pm2 restart officer` already takes.
|
|
*/
|
|
releaseSession(sessionId: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
|
|
// Only the listener goes. `_claudeKill` is deliberately NOT called — and must not be left behind
|
|
// either: the record is being dropped, so the next turn starts from `adoptOrphanedSession`, which
|
|
// installs its own kill and subscription.
|
|
if (session._sidecarUnsub) {
|
|
session._sidecarUnsub();
|
|
}
|
|
|
|
this.forget(session);
|
|
}
|
|
|
|
attachWs(sessionId: string, ws: any): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.sockets.add(ws);
|
|
session.lastActivity = Date.now();
|
|
|
|
if (session.idleTimer) {
|
|
clearTimeout(session.idleTimer);
|
|
session.idleTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
|
|
* session's only socket field, so a stale client's close event silenced whichever client had attached
|
|
* after it. A close is only the end of the conversation when nothing else is still watching.
|
|
*/
|
|
detachWs(sessionId: string, ws: any): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.sockets.delete(ws);
|
|
session.lastActivity = Date.now();
|
|
}
|
|
}
|
|
|
|
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
|
|
hasSockets(sessionId: string): boolean {
|
|
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
|
|
}
|
|
|
|
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
|
|
if (session.idleTimer) {
|
|
clearTimeout(session.idleTimer);
|
|
}
|
|
|
|
session.idleTimer = setTimeout(() => {
|
|
logger.info('Session idle timeout reached, releasing binding (agent left running)', {
|
|
sessionId,
|
|
timeoutMs,
|
|
});
|
|
this.releaseSession(sessionId);
|
|
}, timeoutMs);
|
|
}
|
|
|
|
updateActivity(sessionId: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.lastActivity = Date.now();
|
|
}
|
|
}
|
|
|
|
getAllSessions(): UserSession[] {
|
|
return Array.from(this.sessions.values());
|
|
}
|
|
}
|
|
|
|
export const sessionManager = new SessionManager();
|