diff --git a/src/__tests__/per-action-rbac-1922.test.ts b/src/__tests__/per-action-rbac-1922.test.ts index 9e602b8c..77139b07 100644 --- a/src/__tests__/per-action-rbac-1922.test.ts +++ b/src/__tests__/per-action-rbac-1922.test.ts @@ -21,6 +21,7 @@ function makeMockApp(): FastifyInstance { get: vi.fn(), put: vi.fn(), delete: vi.fn(), + patch: vi.fn(), } as unknown as FastifyInstance; } diff --git a/src/__tests__/pinned-session-reaper.test.ts b/src/__tests__/pinned-session-reaper.test.ts new file mode 100644 index 00000000..e875ebfb --- /dev/null +++ b/src/__tests__/pinned-session-reaper.test.ts @@ -0,0 +1,117 @@ +/** + * Unit tests for Issue #4027: pinned-session reaper semantics. + * + * Tests that the SessionManager correctly stores/retrieves the isPinned field, + * and that the reaper logic can distinguish pinned vs unpinned sessions. + */ +import { describe, expect, it } from 'vitest'; +import { SessionManager } from '../session.js'; +import type { Config } from '../config.js'; + +function makeConfig(stateDir: string): Config { + return { + stateDir, + maxSessionAgeMs: 3600000, + reaperIntervalMs: 60000, + stallThresholdMs: 300000, + permissionStallMs: 300000, + computeStallThreshold: () => 300000, + baseUrl: 'http://localhost:9100', + allowedWorkDirs: ['/tmp'], + } as unknown as Config; +} + +describe('Issue #4027: pinned-session reaper semantics', () => { + describe('SessionManager.updateSessionMetadata', () => { + it('sets isPinned on a session', async () => { + const config = makeConfig('/tmp/test-pinned-' + Date.now()); + const sm = new SessionManager(config); + await sm.load(); + + const session = await sm.createSession({ + workDir: '/tmp', + name: 'test-pinned', + permissionMode: 'default', + }); + + const updated = await sm.updateSessionMetadata(session.id, { isPinned: true }); + expect(updated).not.toBeNull(); + expect(updated!.isPinned).toBe(true); + + // Verify in-memory state + const retrieved = sm.getSession(session.id); + expect(retrieved!.isPinned).toBe(true); + }); + + it('unpins a session', async () => { + const config = makeConfig('/tmp/test-unpin-' + Date.now()); + const sm = new SessionManager(config); + await sm.load(); + + const session = await sm.createSession({ + workDir: '/tmp', + name: 'test-unpin', + permissionMode: 'default', + }); + + await sm.updateSessionMetadata(session.id, { isPinned: true }); + const unpinned = await sm.updateSessionMetadata(session.id, { isPinned: false }); + expect(unpinned!.isPinned).toBe(false); + }); + + it('returns null for nonexistent session', async () => { + const config = makeConfig('/tmp/test-nonexist-' + Date.now()); + const sm = new SessionManager(config); + await sm.load(); + + const result = await sm.updateSessionMetadata('nonexistent-id', { isPinned: true }); + expect(result).toBeNull(); + }); + }); + + describe('reaper logic', () => { + it('isPinned sessions are distinguishable from unpinned', async () => { + const config = makeConfig('/tmp/test-logic-' + Date.now()); + const sm = new SessionManager(config); + await sm.load(); + + const pinned = await sm.createSession({ + workDir: '/tmp', + name: 'pinned', + permissionMode: 'default', + }); + await sm.updateSessionMetadata(pinned.id, { isPinned: true }); + + const unpinned = await sm.createSession({ + workDir: '/tmp', + name: 'unpinned', + permissionMode: 'default', + }); + + // Simulate reaper logic: filter by isPinned flag + const allSessions = sm.listSessions(); + const pinnedSessions = allSessions.filter(s => s.isPinned === true); + const unpinnedSessions = allSessions.filter(s => !s.isPinned); + + expect(pinnedSessions).toHaveLength(1); + expect(pinnedSessions[0]!.id).toBe(pinned.id); + expect(unpinnedSessions).toHaveLength(1); + expect(unpinnedSessions[0]!.id).toBe(unpinned.id); + }); + + it('isPinned defaults to undefined (falsy)', async () => { + const config = makeConfig('/tmp/test-default-' + Date.now()); + const sm = new SessionManager(config); + await sm.load(); + + const session = await sm.createSession({ + workDir: '/tmp', + name: 'default-pin', + permissionMode: 'default', + }); + + expect(session.isPinned).toBeFalsy(); + expect(session.isPinned).not.toBe(true); + }); + }); +}); diff --git a/src/routes/sessions.ts b/src/routes/sessions.ts index 9a468282..4cf6acef 100644 --- a/src/routes/sessions.ts +++ b/src/routes/sessions.ts @@ -582,6 +582,25 @@ export function registerSessionRoutes(app: FastifyInstance, ctx: RouteContext): return addActionHints(session, sessions, channels); })); + // Issue #4027: PATCH session metadata (isPinned, etc.) + registerWithLegacy(app, 'patch', '/v1/sessions/:id', withOwnership(sessions, async (req: FastifyRequest, reply: FastifyReply, session) => { + const body = req.body as Record; + const updates: Record = {}; + if ('isPinned' in body && typeof body.isPinned === 'boolean') { + updates.isPinned = body.isPinned; + } + if (Object.keys(updates).length === 0) { + reply.code(400); + return { error: 'No valid fields to update', code: 'INVALID_UPDATE' }; + } + const updated = await sessions.updateSessionMetadata(session.id, updates); + if (!updated) { + reply.code(404); + return { error: 'Session not found', code: 'NOT_FOUND' }; + } + return addActionHints(updated, sessions, channels); + })); + // Issue #3860: Lightweight status endpoint for polling registerWithLegacy(app, 'get', '/v1/sessions/:id/status', withOwnership(sessions, async (_req, _reply, session) => { return { id: session.id, status: session.status, lastActivity: session.lastActivity }; diff --git a/src/server.ts b/src/server.ts index a54342d9..2d0375aa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -580,6 +580,8 @@ async function reapStaleSessions(maxAgeMs: number): Promise { for (const session of snapshot) { // Guard: session may have been deleted by DELETE handler between snapshot and here if (!sessions.getSession(session.id)) continue; + // Issue #4027: Skip pinned sessions — user explicitly wants them alive. + if (session.isPinned) continue; const age = now - session.createdAt; if (age > maxAgeMs) { const ageMin = Math.round(age / 60000); @@ -631,6 +633,8 @@ async function reapZombieSessions(): Promise { for (const session of snapshot) { // Guard: session may have been deleted between snapshot and here if (!sessions.getSession(session.id)) continue; + // Issue #4027: Skip pinned sessions — user explicitly wants them alive. + if (session.isPinned) continue; if (!session.lastDeadAt) continue; const deadDuration = now - session.lastDeadAt; if (deadDuration < ZOMBIE_REAP_DELAY_MS) continue; diff --git a/src/session.ts b/src/session.ts index edba1ca3..598b3866 100644 --- a/src/session.ts +++ b/src/session.ts @@ -143,6 +143,8 @@ export interface SessionInfo { // Issue #2520: Premature termination detection for background agents toolUseCount?: number; // Count of PreToolUse hook events prematureTermination?: boolean; // True when session ended with suspiciously low tool use + // Issue #4027: Pinned session flag — reaper skips pinned sessions. + isPinned?: boolean; } /** Persisted session store keyed by Aegis session ID. */ @@ -893,6 +895,17 @@ export class SessionManager { return this.state.sessions[id] || null; } + /** Issue #4027: Update session metadata (isPinned, etc.). + * Merges the provided fields into the session and persists. */ + async updateSessionMetadata(id: string, updates: Partial>): Promise { + const session = this.state.sessions[id]; + if (!session) return null; + Object.assign(session, updates); + this.invalidateSessionsListCache(); + await this.save(); + return session; + } + /** Issue #169 Phase 3: Update session status from a hook event. * Returns the previous status for change detection. * Issue #87: Also records hook latency timestamps. */ diff --git a/src/validation.ts b/src/validation.ts index 766e6dbb..cbc34543 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -322,6 +322,7 @@ export const persistedStateSchema = z.record( circuitBreakerTripped: z.boolean().optional(), toolUseCount: z.number().int().nonnegative().optional(), prematureTermination: z.boolean().optional(), + isPinned: z.boolean().optional(), }), );