From 6daa029fbb17a84b0b4256107daa3c8c523de070 Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 15:14:53 +0200 Subject: [PATCH 1/2] test(sessions): the row after /clear, /reset and /new, trigger locks by id, and owner checks Tests for the last review round of #477, which #548 did not merge: the row after /clear is a new session rather than a copy of the cleared one; /reset, /new and /clear with a name are followed; a trigger waits for a chain whose session got a new PTY under the same id; owner logs fire on change only; clearOwner reads the process table on Windows and never claims on macOS; held header-only files and fresh entries wait for indexing. --- test/clear-pending-persist.test.js | 131 +++++++++++++++++++-- test/cli-session-process-table.test.js | 20 ++++ test/session-transitions.test.js | 152 ++++++++++++++++++++++++- test/trigger-clear-chain.test.js | 22 ++++ test/trigger-context.test.js | 32 ++++++ test/trigger-watcher.test.js | 4 +- 6 files changed, 349 insertions(+), 12 deletions(-) diff --git a/test/clear-pending-persist.test.js b/test/clear-pending-persist.test.js index 97a2a1847..883115d53 100644 --- a/test/clear-pending-persist.test.js +++ b/test/clear-pending-persist.test.js @@ -9,7 +9,7 @@ const path = require('node:path'); const vm = require('node:vm'); const { setupSidebarDom } = require('./dom-setup'); -const { loadAppFunctions } = require('./app-source'); +const { loadAppFunctions, readAppSource } = require('./app-source'); const PLAN_SRC = fs.readFileSync(path.join(__dirname, '..', 'public', 'restore-plan.js'), 'utf8'); const PROJECT = { folder: '-srv-app', projectPath: '/srv/app', sessions: [] }; @@ -40,7 +40,7 @@ function setup() { showLiveElsewhereNotice: () => {}, showSession: (id) => shown.push(id), openSession: async () => true, - resolveResumeSession: async (session) => session, + resolveResumeSession: async (s) => s, resolveDefaultSessionOptions: async () => ({ permissionMode: 'plan', worktree: true, worktreeName: 'w' }), launchNewSession: async (project, options) => { const id = `started-${launched.length + 1}`; @@ -60,8 +60,8 @@ function setup() { window.cachedAllProjects = []; vm.runInContext(PLAN_SRC, ctx.context); const fns = loadAppFunctions(ctx.context, { - functions: ['dedup', 'loadProjects', 'persistWorkingSet', 'pendingRestoreEntries', 'runRestore', 'applySessionForked'], - declarations: ['_persistChain', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restorePlanner', 'exitingApp', 'persistSkippedWhileExiting', 'restoreIndexingDone'], + functions: ['dedup', 'loadProjects', 'persistWorkingSet', 'pendingRestoreEntries', 'runRestore'], + declarations: ['_persistChain', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restorePlanner', 'exitingApp', 'persistSkippedWhileExiting', 'restoreIndexingDone', 'continuationRetryCancelled'], }); const open = (sessionId, { pending, remoteAlias } = {}) => { const session = { sessionId, projectPath: '/srv/app', summary: 'Earlier work', ...(remoteAlias ? { remoteAlias } : {}) }; @@ -69,8 +69,19 @@ function setup() { window.sessionMap.set(sessionId, session); if (pending) window.pendingSessions.set(sessionId, { session, projectPath: '/srv/app', folder: '-srv-app' }); }; + let forkedListener = null; + const sessionForked = (...args) => { + if (!forkedListener) { + window.api.onSessionForked = (cb) => { forkedListener = cb; }; + const src = readAppSource(); + const start = src.indexOf('window.api.onSessionForked('); + const named = src.indexOf('function applySessionForked('); + vm.runInContext(named >= 0 ? src.slice(named, src.indexOf(';\n', start) + 1) : src.slice(start, src.indexOf('\n});', start) + 4), ctx.context); + } + forkedListener(...args); + }; return { - ...fns, open, settings, window, launched, shown, + ...fns, open, settings, window, launched, shown, sessionForked, run: (code) => vm.runInContext(code, ctx.context), persists: () => persists, setProjects: (list) => { projects = list; }, @@ -85,13 +96,37 @@ test('after /clear, the new id has no transcript yet: it is saved as a fresh ses try { h.open('old-id'); h.window.activeSessionId = 'old-id'; - h.applySessionForked('old-id', 'new-id'); + h.sessionForked('old-id', 'new-id', 'clear'); assert.ok(h.window.pendingSessions.has('new-id'), 'the cleared session is a pending row'); await h.persistWorkingSet(); assert.deepEqual(h.saved(), [{ sessionId: 'new-id', projectPath: '/srv/app', active: true, fresh: true }]); } finally { h.destroy(); } }); +test('the row after /clear is a new session, not a copy of the cleared conversation', async () => { + const h = setup(); + try { + h.open('old-id'); + Object.assign(h.window.sessionMap.get('old-id'), { + name: 'Payments', aiTitle: 'Refactor the payment flow', starred: 1, bridgeSessionId: 'bridge-1', + messageCount: 42, created: '2026-01-01T00:00:00.000Z', modified: '2026-01-02T00:00:00.000Z', + }); + h.sessionForked('old-id', 'new-id', 'clear'); + assert.ok(h.window.pendingSessions.has('new-id'), 'the cleared session is a pending row'); + const rows = [h.window.pendingSessions.get('new-id')?.session, h.window.sessionMap.get('new-id'), h.window.openSessions.get('new-id')?.session]; + for (const row of rows) { + assert.equal(row.name || row.aiTitle || row.summary, 'New session'); + assert.equal(row.starred, 0); + assert.equal(row.bridgeSessionId, undefined); + assert.equal(row.messageCount, 0); + assert.equal(row.projectPath, '/srv/app'); + assert.notEqual(row.created, '2026-01-01T00:00:00.000Z'); + } + assert.equal(h.window.sessionMap.get('old-id'), undefined); + await new Promise((r) => setTimeout(r, 0)); + } finally { h.destroy(); } +}); + test('a fresh entry is restored by starting a new session in its project, without the index', async () => { const h = setup(); try { @@ -156,7 +191,7 @@ test('the row added after /clear does not bring back an archived folder', async h.setProjects([]); h.setArchived([{ ...PROJECT, sessions: [{ sessionId: 'old-id', projectPath: '/srv/app' }] }]); h.open('old-id'); - h.applySessionForked('old-id', 'new-id'); + h.sessionForked('old-id', 'new-id', 'clear'); await h.loadProjects(); assert.ok(!h.window.cachedProjects.some(p => p.projectPath === '/srv/app'), 'hidden like the rest of the folder'); assert.ok(h.window.cachedAllProjects.some(p => p.sessions.some(s => s.sessionId === 'new-id')), 'still listed with the archived folders'); @@ -166,3 +201,85 @@ test('the row added after /clear does not bring back an archived folder', async assert.ok(h.window.cachedProjects.some(p => p.sessions.some(s => s.sessionId === 'plus')), 'a session started with + still shows, as before'); } finally { h.destroy(); } }); + +test('a fork re-key adds no pending row and is never saved as fresh', async () => { + const h = setup(); + try { + h.open('source'); + h.window.activeSessionId = 'source'; + h.sessionForked('source', 'forked', 'fork'); + assert.ok(!h.window.pendingSessions.has('forked')); + await h.persistWorkingSet(); + assert.deepEqual(h.saved(), [{ sessionId: 'forked', projectPath: '/srv/app', active: true }]); + } finally { h.destroy(); } +}); + +test('a fresh entry is not given up on, nor started empty, before indexing is done', () => { + const h = setup(); + try { + const savedSet = [{ sessionId: 'cleared', projectPath: '/srv/app', active: false, fresh: true }]; + h.run(`restorePlanner = createRestorePlanner({ savedSet: ${JSON.stringify(savedSet)} });`); + const early = h.run('restorePlanner.tick({ sessionMap, openSessions, indexingDone: false })'); + assert.equal(early.action, 'wait', 'its transcript may still be indexed'); + const done = h.run('restorePlanner.tick({ sessionMap, openSessions, indexingDone: true })'); + assert.deepEqual(Array.from(done.candidates, c => c.sessionId), ['cleared']); + assert.equal(done.unavailable.length, 0); + } finally { h.destroy(); } +}); + +test('a fresh entry whose transcript is indexed by the restart is resumed, not started empty', async () => { + const h = setup(); + try { + const opened = []; + h.window.openSession = async (s) => { opened.push(s.sessionId); return true; }; + h.window.sessionMap.set('cleared', { sessionId: 'cleared', projectPath: '/srv/app' }); + const savedSet = [{ sessionId: 'cleared', projectPath: '/srv/app', active: true, fresh: true }]; + h.run(`restorePlanner = createRestorePlanner({ savedSet: ${JSON.stringify(savedSet)} });`); + const plan = h.run('restorePlanner.tick({ sessionMap, openSessions, indexingDone: false })'); + assert.deepEqual(Array.from(plan.candidates, c => c.sessionId), ['cleared']); + await h.runRestore(plan.candidates); + assert.deepEqual(opened, ['cleared']); + assert.deepEqual(h.launched, []); + } finally { h.destroy(); } +}); + +test('a session started as a fork or in a new worktree is not saved as fresh', async () => { + for (const options of [{ forkFrom: 'source' }, { worktree: true }, {}]) { + const ctx = setupSidebarDom(); + const { window } = ctx; + try { + const settings = { global: { openWorkingSet: [] } }; + Object.defineProperty(window, 'crypto', { value: { randomUUID: () => 'started' }, configurable: true }); + Object.assign(window, { + refreshSidebar: () => {}, + createTerminalEntry: (session) => { + const entry = { session, closed: false, terminal: { write() {} }, initialSize: {} }; + window.openSessions.set(session.sessionId, entry); + return entry; + }, + syncPtySizeAfterOpen: () => {}, + setSessionMcpState: () => {}, + setSessionSandboxed: () => {}, + showSession: () => {}, + schedulePersistWorkingSet: () => {}, + pollActiveSessions: () => {}, + }); + window.api = { + openTerminal: async () => ({ ok: true }), + getSetting: async (key) => JSON.parse(JSON.stringify(settings[key] || null)), + setSetting: async (key, value) => { settings[key] = JSON.parse(JSON.stringify(value)); }, + }; + window.cachedProjects = []; + window.cachedAllProjects = []; + vm.runInContext(PLAN_SRC, ctx.context); + const fns = loadAppFunctions(ctx.context, { + functions: ['launchNewSession', 'persistWorkingSet', 'pendingRestoreEntries'], + declarations: ['_persistChain', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restorePlanner', 'exitingApp', 'persistSkippedWhileExiting'], + }); + await fns.launchNewSession({ projectPath: '/srv/app' }, options); + await fns.persistWorkingSet(); + const expected = Object.keys(options).length ? [] : ['started']; + assert.deepEqual(settings.global.openWorkingSet.map(i => i.sessionId), expected, JSON.stringify(options)); + } finally { ctx.destroy(); } + } +}); diff --git a/test/cli-session-process-table.test.js b/test/cli-session-process-table.test.js index 6603c3478..cd8c1463d 100644 --- a/test/cli-session-process-table.test.js +++ b/test/cli-session-process-table.test.js @@ -378,3 +378,23 @@ test('probeProcessTable rejects when the command fails', async () => { const exec = (exe, args, opts, cb) => cb(Object.assign(new Error('killed'), { killed: true }), ''); await assert.rejects(cliSessionState.probeProcessTable('win32', 10, exec)); }); + +// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") +test('clearOwner on Windows reads ownership from the process table, and waits for it', () => withDir(async (dir) => { + writeState(dir, 5004, { sessionId: 'cleared' }); + writeState(dir, 7004, { sessionId: 'stranger', procStart: ft(60) }); + boot(dir); + assert.equal(cliSessionState.clearOwner('cleared', 5001), 'pending', 'no table yet: asked again later, not given up'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(cliSessionState.clearOwner('cleared', 5001), 'mine'); + assert.equal(cliSessionState.clearOwner('stranger', 5001), 'other'); + assert.equal(cliSessionState.clearOwner('cleared', 7003), 'other'); +})); + +test('clearOwner on macOS never claims a /clear: the descriptor carries no anchor to check the pid against', () => withDir(async (dir) => { + writeState(dir, 5004, { sessionId: 'cleared', pidDomain: undefined, procStart: undefined }); + boot(dir, { platform: 'darwin' }); + cliSessionState.clearOwner('cleared', 5001); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(cliSessionState.clearOwner('cleared', 5001), 'other'); +})); diff --git a/test/session-transitions.test.js b/test/session-transitions.test.js index 417ed9dfa..f2a47bb32 100644 --- a/test/session-transitions.test.js +++ b/test/session-transitions.test.js @@ -29,7 +29,7 @@ function makeMockWindow() { return { isDestroyed: () => false, webContents: { - send: (channel, payload) => events.push({ channel, payload }), + send: (channel, payload, ...rest) => events.push({ channel, payload, args: [payload, ...rest] }), }, _events: events, }; @@ -862,13 +862,13 @@ test('fork detection: the re-key drops the subagent scan state, so the new sessi }); // see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") -function writeClearTranscript(folderPath, id) { +function writeClearTranscript(folderPath, id, command = '/clear', args = '') { const rec = (o) => JSON.stringify({ sessionId: id, ...o }); fs.writeFileSync(path.join(folderPath, `${id}.jsonl`), [ rec({ type: 'mode', mode: 'normal' }), JSON.stringify({ type: 'file-history-snapshot', messageId: 'm1', snapshot: {} }), rec({ type: 'user', isMeta: true, message: { role: 'user', content: 'Caveat: The messages below were generated by the user while running local commands.' } }), - rec({ type: 'user', message: { role: 'user', content: '/clear\n clear\n ' } }), + rec({ type: 'user', message: { role: 'user', content: `${command}\n ${command.slice(1)}\n ${args}` } }), ].join('\n') + '\n', 'utf8'); } @@ -1065,6 +1065,23 @@ test('/clear: the new transcript of the CLI under this PTY re-keys the session', } }); +for (const [command, args] of [['/reset', ''], ['/new', ''], ['/clear', 'payments']]) { + test(`/clear: ${command}${args ? ' with a name' : ''} is followed like /clear`, () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'mine' }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 7 } })); + writeClearTranscript(path.join(tmp, folder), 'new-id', command, args); + sessionTransitions.detectSessionTransitions(folder); + assert.ok(activeSessions.has('new-id')); + } finally { + cleanup(tmp); + } + }); +} + test('/clear: a transcript owned by another process is recorded and never re-keys', () => { const tmp = mkTmp(); try { @@ -1284,3 +1301,132 @@ test('/clear: the old id still leads to the session, so a trigger chain that sen cleanup(tmp); } }); + +test('/clear: a trigger aimed at the cleared id reaches the conversation reopened under it, not the terminal that cleared it', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'mine' }); + const ptyA = { pid: 4242, write() {} }; + const ptyB = { pid: 5353, write() {} }; + activeSessions.set('old-id', makePtySession(folder, { pty: ptyA })); + const { createTriggerContext } = require('../trigger-context'); + const ctx = createTriggerContext({ + activeSessions, log: { info() {}, warn() {}, error() {}, debug() {} }, + resolveSessionId: (id) => sessionTransitions.currentSessionId(id), + }); + + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + assert.equal(ctx.getPtyForSession('old-id').ptyProcess, ptyA, 'while nothing else holds the old id, it leads to the cleared terminal'); + + activeSessions.set('old-id', makePtySession(folder, { pty: ptyB })); + assert.equal(sessionTransitions.currentSessionId('old-id'), 'old-id'); + assert.equal(ctx.getPtyForSession('old-id').ptyProcess, ptyB, 'the reopened conversation owns its id'); + assert.equal(ctx.getPtyForSession('new-id').ptyProcess, ptyA); + + activeSessions.delete('old-id'); + assert.equal(sessionTransitions.currentSessionId('old-id'), 'old-id', 'the alias was dropped when its source came back'); + } finally { + cleanup(tmp); + } +}); + +test('/clear: an alias whose terminal has exited no longer resolves', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'mine' }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 4242 } })); + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + assert.equal(sessionTransitions.currentSessionId('old-id'), 'new-id'); + activeSessions.get('new-id').exited = true; + assert.equal(sessionTransitions.currentSessionId('old-id'), 'old-id'); + activeSessions.get('new-id').exited = false; + assert.equal(sessionTransitions.currentSessionId('old-id'), 'old-id', 'pruned, not just skipped'); + } finally { + cleanup(tmp); + } +}); + +test('the renderer is told whether a re-key is a /clear or a fork', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const { events, activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'mine' }); + activeSessions.set('a', makePtySession(folder, { pty: { pid: 1 } })); + writeClearTranscript(path.join(tmp, folder), 'b'); + sessionTransitions.detectSessionTransitions(folder); + activeSessions.set('c', makePtySession(folder, { pty: { pid: 2 }, knownJsonlFiles: new Set(fs.readdirSync(path.join(tmp, folder))) })); + fs.writeFileSync(path.join(tmp, folder, 'd.jsonl'), JSON.stringify({ sessionId: 'd', type: 'user', forkedFrom: { sessionId: 'c' } }) + '\n', 'utf8'); + sessionTransitions.detectSessionTransitions(folder); + assert.deepEqual(events.filter(e => e.channel === 'session-forked').map(e => e.args), [['a', 'b', 'clear'], ['c', 'd', 'fork']]); + } finally { + cleanup(tmp); + } +}); + +test('fork detection: NO MATCH is logged once per file, not on every flush', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const { activeSessions, logLines } = setupForkDetection(tmp, { clearOwner: () => 'pending' }); + activeSessions.set('pty-id', makePtySession(folder, { forkFrom: 'source-id', pty: { pid: 3 } })); + writeHeaderOnly(path.join(tmp, folder), 'stranger'); + for (let i = 0; i < 3; i++) sessionTransitions.detectSessionTransitions(folder); + assert.equal(logLines.filter(l => l.includes('NO MATCH') && l.includes('stranger')).length, 1); + } finally { + cleanup(tmp); + } +}); + +test('/clear: a pending owner is logged when it changes, not on every recheck', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + let owner = 'pending'; + const { activeSessions, logLines } = setupForkDetection(tmp, { clearOwner: () => owner }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 7 } })); + writeClearTranscript(path.join(tmp, folder), 'new-id'); + for (let i = 0; i < 3; i++) sessionTransitions.detectSessionTransitions(folder); + owner = 'mine'; + sessionTransitions.detectSessionTransitions(folder); + assert.ok(activeSessions.has('new-id')); + assert.deepEqual(logLines.filter(l => l.includes('/clear file=new-id')).map(l => l.match(/owner=\w+/)[0]), ['owner=pending', 'owner=mine']); + } finally { + cleanup(tmp); + } +}); + +test('a held header-only file is read again only when it changes', () => { + const tmp = mkTmp(); + const realOpen = fs.openSync; + try { + const folder = 'proj'; + const folderPath = path.join(tmp, folder); + fs.mkdirSync(folderPath, { recursive: true }); + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'other' }); + activeSessions.set('pty-id', makePtySession(folder, { pty: { pid: 3 } })); + writeHeaderOnly(folderPath, 'stranger'); + const target = path.join(folderPath, 'stranger.jsonl'); + const second = Math.floor(Date.now() / 1000); + fs.utimesSync(target, second, second); + let opens = 0; + fs.openSync = (p, ...rest) => { if (p === target) opens++; return realOpen(p, ...rest); }; + for (let i = 0; i < 3; i++) sessionTransitions.detectSessionTransitions(folder); + assert.equal(opens, 1); + fs.appendFileSync(target, JSON.stringify({ sessionId: 'stranger', type: 'user', message: { role: 'user', content: 'hi' } }) + '\n'); + fs.utimesSync(target, second, second); + sessionTransitions.detectSessionTransitions(folder); + assert.equal(opens, 2, 'a write within the same mtime still shows in the size'); + } finally { + fs.openSync = realOpen; + cleanup(tmp); + } +}); diff --git a/test/trigger-clear-chain.test.js b/test/trigger-clear-chain.test.js index f68a0ce9a..82ab6b07a 100644 --- a/test/trigger-clear-chain.test.js +++ b/test/trigger-clear-chain.test.js @@ -152,3 +152,25 @@ test('a trigger for the new id waits for a chain still running under the old id' s.cleanup(); } }); + +test('a trigger waits for a chain whose session got a new PTY under the same id', async () => { + const tmp = mkTmp('sw-clear-chain-triggers-'); + let s = null; + s = clearingSession((text) => { + if (text !== 'first') return; + const session = s.activeSessions.get('old-id'); + const previous = session.pty; + session.pty = { pid: previous.pid, write: (data) => previous.write(data) }; + fs.writeFileSync(path.join(tmp, 'single.json'), JSON.stringify({ sessionId: 'old-id', command: 'other', wait: 'none', timeout_ms: 6000 }), 'utf8'); + }); + try { + const { chain } = await runTriggers(s.ctx, [['chain', { sessionId: 'old-id', chain: [{ command: 'first' }, { command: 'second' }] }]], 6000, tmp); + assert.equal(chain.ok, true, JSON.stringify(chain)); + const resultPath = path.join(tmp, 'processed', 'single.result.json'); + const deadline = Date.now() + 6000; + while (!fs.existsSync(resultPath) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 20)); + assert.deepEqual(s.written.filter((d) => d !== '\r'), ['first', 'second', 'other']); + } finally { + s.cleanup(); + } +}); diff --git a/test/trigger-context.test.js b/test/trigger-context.test.js index f07bc44d8..dc160433e 100644 --- a/test/trigger-context.test.js +++ b/test/trigger-context.test.js @@ -187,3 +187,35 @@ test('a re-keyed id is resolved before every lookup, the live descriptor include assert.deepEqual(ctx.getLiveDescriptor('old-id'), { id: 'new-id' }); assert.deepEqual(asked, ['new-id']); }); + +test('a re-key mid-chain forgets the transcript read under the previous id', () => { + const turnModule = require('../transcript-turn'); + const original = turnModule.createTranscriptTurnReader; + const forgotten = []; + turnModule.createTranscriptTurnReader = (...args) => { + const reader = original(...args); + return { ...reader, forget: (p) => { forgotten.push(p); reader.forget(p); } }; + }; + const contextPath = require.resolve('../trigger-context'); + const cached = require.cache[contextPath]; + delete require.cache[contextPath]; + try { + const { createTriggerContext: create } = require('../trigger-context'); + const session = { pty: { pid: 1 }, host: null, projectFolder: 'proj', exited: false }; + const activeSessions = new Map([['old-id', session]]); + let alias = null; + const ctx = create({ activeSessions, log: silentLog, projectsDir: '/projects', resolveSessionId: (id) => alias && id === 'old-id' ? alias : id }); + ctx.getTranscriptTurn('old-id'); + activeSessions.delete('old-id'); + activeSessions.set('new-id', session); + session.realSessionId = 'new-id'; + alias = 'new-id'; + ctx.getTranscriptTurn('old-id'); + assert.deepEqual(forgotten, [require('node:path').join('/projects', 'proj', 'old-id.jsonl')]); + ctx.forgetTranscriptTurn('old-id'); + assert.deepEqual(forgotten.at(-1), require('node:path').join('/projects', 'proj', 'new-id.jsonl')); + } finally { + turnModule.createTranscriptTurnReader = original; + require.cache[contextPath] = cached; + } +}); diff --git a/test/trigger-watcher.test.js b/test/trigger-watcher.test.js index 8d98e732f..624a822f7 100644 --- a/test/trigger-watcher.test.js +++ b/test/trigger-watcher.test.js @@ -5398,11 +5398,11 @@ test('startup scan respects MAX_INFLIGHT: only 8 of 12 pre-existing triggers sta const startedSessions = new Set(); // sessions whose isSessionBusy has been polled at least once const written = []; const composer = { pending: 0, lastInputAt: 0 }; - const ptys = new Map(sessionIds.map((sid) => [sid, { pid: process.pid, write(data) { written.push(data); } }])); + const ptyProcess = { pid: process.pid, write(data) { written.push(data); } }; const ctx = { log: silentLog, - getPtyForSession(sessionId) { return { ptyProcess: ptys.get(sessionId) }; }, + getPtyForSession() { return { ptyProcess }; }, isSessionBusy(sessionId) { startedSessions.add(sessionId); return busy.get(sessionId) === true; From a88f03c1b3eec750b87fa0771140a96da7f66adf Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 15:15:19 +0200 Subject: [PATCH 2/2] fix(sessions): a new session row after /clear, /reset and /new, and one trigger queue per session After /clear the open entry gets a new session built as launchNewSession builds one, instead of inheriting the cleared conversation's title, star, bridge id and dates. /reset, /new and /clear with a name are recognised. The trigger lock is keyed by the resolved session id again, so a chain and a new trigger share one queue across a PTY replacement or a /clear. Owner logs fire only when the owner changes, the fork listener is anonymous again (tests capture it through the onSessionForked stub), and the changelog entry moves to Unreleased. --- .ai/contexts/cli-session-state.md | 46 ++++++++++++++++----- .ai/contexts/trigger-watcher.md | 19 +++++---- CHANGELOG.md | 2 +- cli-session-state.js | 18 +++++++-- docs/session-restore.md | 16 +++++--- preload.js | 2 +- public/app.js | 25 ++++++------ public/restore-plan.js | 2 +- session-transitions.js | 58 +++++++++++++++++++++++---- test/clear-pending-persist.test.js | 3 +- test/session-activity.test.js | 3 +- test/terminal-webgl-lifecycle.test.js | 5 +-- test/tool-bar.test.js | 10 ++--- trigger-context.js | 3 ++ trigger-watcher.js | 12 +++--- 15 files changed, 154 insertions(+), 70 deletions(-) diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 61f829f00..cbdabcd96 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -444,17 +444,29 @@ finds the live pid whose state file names `newId` and walks its parent chain the watched folder, and the transcript gets no other write before the first prompt. A new file whose first records are not written yet is rechecked the same way. -- `unknown` — the pid is found but its parent cannot be read (no `/proc`: - macOS, Windows). Never matched, not even when this is the only Claude PTY in - the folder: a `claude` run outside the app in the same folder would be taken - over. The file is recorded and not rechecked, so on macOS and Windows a - `/clear` is followed only when the PTY's own pid is the CLI's. +- `unknown` — the pid is found but its parent cannot be read. Never matched, + not even when this is the only Claude PTY in the folder: a `claude` run + outside the app in the same folder would be taken over. The file is recorded + and not rechecked. + +On Windows and macOS there is no `/proc`; `clearOwner` uses the process table +of "Own descendants outside Linux" instead and gives the same answer as +`conversationOwns`: `mine` only when the descriptor's pid is the writer the +table names (`pidDomain: 'win32:anchor'`, `procStart`) and its chain reaches +the PTY's pid with creation times that only go back in time. A pid the table +does not list yet is `pending` (the table refreshes in the background, and the +recheck asks again). The CLI on macOS writes no anchor, so there a `/clear` is +`other` and never followed: the new conversation is listed apart, as before; +the follow-up for #518 may re-key it from the descriptor's `sessionId`. Re-keying keeps `oldId → newId` (`currentSessionId`), so a trigger chain that sent `/clear` reaches the same terminal on its next step (see trigger-watcher.md, "Re-keyed sessions"). On each re-key every older id that pointed at `oldId` is moved to `newId`, so any id the session ever had resolves -in one lookup, however many times it was cleared. +in one lookup, however many times it was cleared. An id that a live session +holds itself is never redirected: reopening the cleared conversation starts a +terminal under the old id, and from then on that id is its own (the alias is +dropped). An alias whose target is gone or exited is dropped too. **A file with no turn yet.** The CLI writes a `/clear` transcript in several appends: a mode record and a `file-history-snapshot` can land, and be read, @@ -463,7 +475,9 @@ before the `/clear` record. A new file whose first real turn `knownJsonlFiles`, whatever its other signals, until it shows one or is an hour old (the same limit as a file with no parseable record). While it is under 60 s old, `scheduleRecheck` runs the folder again a second later. The -session's own transcript is never held this way. +session's own transcript is never held this way. A held file is read again +only when its size or mtime changes (the signals are cached by path), and the +"NO MATCH" line of a session awaiting a fork is logged once per file. **Snapshot-only fork file.** A session started with `--fork-session` waits for its fork's transcript, which holds only snapshots until the first prompt. @@ -476,10 +490,20 @@ folder was taken, including another session's `/clear` or new session. `readNewSessionSignals()` recognises the file by its first user record that is not local-command bookkeeping, through `classifyUserText()` — the caveat record -comes first and must be skipped, not taken as the file's first turn. - -The renderer re-keys as for a fork, and when the old id was not a pending -(transcript-less) row it adds one for the new id titled "New session": the +comes first and must be skipped, not taken as the file's first turn. The +command may be `/clear`, or its aliases `/reset` and `/new` (CLI 2.1.296: +`name:"clear"`, `aliases:["reset","new"]`), with or without a name argument. +The owner and match lines are logged when they change, not on every recheck. +`/clear` writes no `continued-in` record (checked on CLI 2.1.296: the only +writer is the background-session handoff; none of 41 local `/clear` +transcripts is the target of one), so #518's continuation restore does not +redirect a click on the cleared conversation to the new one. + +`session-forked` carries the kind of re-key, `'clear'` or `'fork'`. The +renderer re-keys as for a fork, and for a `/clear` whose old id was not a +pending (transcript-less) row it replaces the open entry's session with a new +one for the new id, built as `launchNewSession` builds one (titled "New +session", not starred, no title, bridge or dates of the old conversation): the `/clear` transcript is not indexed until its first prompt (see session-cache.md), so without it the open terminal would have no row at all until then. That row is marked `cleared`, and is not re-added to the default diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index c1f2dbb2b..6aaa043ee 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -67,7 +67,11 @@ the commands a chain may send. `createTriggerContext` takes `resolveSessionId` (`getPtyForSession`, `isSessionBusy`, `getComposerState`, `getTranscriptTurn`, `getCliStatus`) goes through it, so the old id keeps reaching the same terminal and the CLI status is read under the id the CLI now writes, as is -`getLiveDescriptor`. Results still carry the id the trigger named. +`getLiveDescriptor`. Results still carry the id the trigger named. An id a +live session holds itself resolves to that session, so a trigger aimed at a +cleared conversation that was reopened reaches its own terminal. When a lookup +finds the transcript under a new id, the turn reader forgets the file it read +under the old one. `test/trigger-clear-chain.test.js` runs `[/clear, reply]` through the shipped watcher, with the re-key done by `session-transitions.js` while step 0 is verified; without the resolution that chain ends with "session exited during @@ -348,11 +352,11 @@ in-flight write. Two commands landing in one composer half-typed into each other is a worse failure than anything `submitted` reports: corrupted input, not just a wrong report field. -`start()` keeps `sessionLocks`, a `Map` used as a FIFO mutex queue -(`acquireSessionLock`), keyed by the PTY that `getPtyForSession` returns, or by -the session id when there is none. A fork or `/clear` gives one terminal a new -id; keyed by the PTY, triggers naming the old and the new id share one queue -(see "Re-keyed sessions"). `processTriggerFile` awaits it right after +`start()` keeps `sessionLocks`, a `Map` used as a FIFO +mutex queue (`acquireSessionLock`). A fork or `/clear` gives one terminal a new +id, so a trigger waits for every queued id that resolves (`ctx.resolveSessionId`, +see "Re-keyed sessions") to the same session as its own: the old and the new id +share one queue, and a PTY replaced under the same id keeps it. `processTriggerFile` awaits it right after `sessionId` is known to be a non-empty string — before any wait, any PTY lookup, any write — and releases it in the function's own `finally`, so every exit path (success, validation refusal, thrown exception) releases exactly @@ -988,8 +992,7 @@ session, which has no local descriptor). No new watcher: it reuses the cache the CLI reacted. `idle` alone covers a turn too fast for a poll to see `busy`; `waiting` is a permission dialog our Enter opened. A spinner on the level probe, or a status that began earlier, proves nothing. Otherwise the - existing recovery applies (one bare ` -`, only into a free composer, same + existing recovery applies (one bare ` `, only into a free composer, same window), and the reaction is looked for again. Still nothing: `confirmed: false`. - **The recovery Enter is never written while the descriptor reads `waiting` diff --git a/CHANGELOG.md b/CHANGELOG.md index b35ca6d6a..177bbb540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - A double click on a session in the sidebar opens it once, instead of sometimes starting two terminals that resume the same conversation. (#539) - Closing the window or quitting no longer leaves Switchboard running when its window has stopped responding: the window closes as before, and once the system reports it not responding, about 5 seconds after the close, or once you close it again, it is closed anyway. A window still busy at that point is closed too, and its last unsaved changes to the list of open sessions are lost; one that recovers sooner closes normally. (#540) - Archiving or deleting a session no longer fails with "cannot tell whether a background job is running this session" while a background job that has not received its first prompt yet is live. (#553) +- On Linux and Windows, `/clear` (or `/reset`, `/new`) in a session keeps the open terminal on its sidebar row, which now follows the new conversation, instead of listing that conversation as a separate session. The cleared conversation stays in the list as a past session, and a session quit after `/clear` before its first prompt comes back on restore as a new session in the same project. On macOS the new conversation is still listed apart. (#477) ## v0.0.92 — 2026-10-10 @@ -49,7 +50,6 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - A session that moves into a worktree while it runs stays in the sidebar, under its project, instead of disappearing while its terminal keeps running. A running session also stays listed when its transcript leaves its folder for any other reason. (#485) - Worktree folders from another host, or whose repository is not listed, show in the sidebar instead of being nested under the wrong folder or not shown at all. The worktrees of a repository hidden with Hide Project stay hidden. (#473) - Quitting or closing the window saves the open sessions right away, so the next start restores the sessions that were open, not an empty or out-of-date list. (#479) -- `/clear` in a session keeps the open terminal on its sidebar row, which now follows the new conversation, instead of listing that conversation as a separate session. The cleared conversation stays in the list as a past session, and a session quit after `/clear` before its first prompt comes back on restore as a new session in the same project. (#477) - Remote triggers refuse commands containing invisible format characters, default-ignorable characters or braille blanks, including joined emoji, emoji with variation selectors (such as hearts), soft hyphens and right-to-left marks. Fullwidth slash, exclamation and number-sign prefixes are refused too. (#440) ## v0.0.89 — 2026-10-04 diff --git a/cli-session-state.js b/cli-session-state.js index cffd69b53..c9c426a7f 100644 --- a/cli-session-state.js +++ b/cli-session-state.js @@ -577,11 +577,11 @@ async function findLiveProcess(sessionId, { exclude = () => false } = {}) { // see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") function clearOwner(sessionId, ptyPid) { if (typeof sessionId !== 'string' || !sessionId || !Number.isInteger(ptyPid)) return 'pending'; - startProcessTableRefresh(); let names; try { names = fs.readdirSync(dir); } catch { return 'pending'; } const key = sessionId.toLowerCase(); let pid = null; + let found = null; for (const name of names) { if (!STATE_FILE_RE.test(name)) continue; let raw; @@ -590,11 +590,23 @@ function clearOwner(sessionId, ptyPid) { if (!Number.isInteger(raw.pid) || raw.pid <= 0) continue; if (!isProcessAlive(raw.pid)) continue; pid = raw.pid; + found = raw; break; } if (pid == null) return 'pending'; - if (chainReaches(pid, (current) => current === ptyPid, false)) return 'mine'; - return readParentPid(pid) == null ? 'unknown' : 'other'; + if (processTableEnabled) { + startProcessTableRefresh(); + if (!tableEntry(pid)) return 'pending'; + return conversationOwns(found, new Set([ptyPid])) ? 'mine' : 'other'; + } + let current = pid; + for (let depth = 0; depth < 64 && current && current > 1; depth++) { + if (current === ptyPid) return 'mine'; + const parent = readParentPid(current); + if (parent == null) return depth === 0 ? 'unknown' : 'other'; + current = parent; + } + return 'other'; } function ownPidTest(ptyPids) { diff --git a/docs/session-restore.md b/docs/session-restore.md index 9987a2397..33fc4df1b 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -26,12 +26,16 @@ session's id and project, and which one was active — in its global settings A session started with **+**, or one just cleared with `/clear`, has no conversation until its first prompt: the CLI has nothing to resume, and the index does not list it. It is saved as a *fresh* entry (`fresh: true`, with -its project). On the next start a fresh entry needs no index: restore starts -a new session in that project, with the project's current defaults for a new -session, except that it never creates a worktree, since the saved project -path already is the folder to start in. Once its first prompt makes it a real -session, the set is saved again with it as an ordinary entry. A session -without a transcript on a remote host is still left out. +its project). Restore waits for indexing to finish before it treats a fresh +entry as having no transcript: one whose first prompt landed before the quit +is indexed by then and is resumed like any other session. A fresh entry that +is still not in the index then is started anew: a new session in that project, +with the project's current defaults for a new session, except that it never +creates a worktree, since the saved project path already is the folder to +start in. Once its first prompt makes it a real session, the set is saved +again with it as an ordinary entry. A session without a transcript is still +left out when it runs on a remote host, or was started as a fork or in a new +worktree: starting it anew would not give back the same thing. ## Closing the app diff --git a/preload.js b/preload.js index 1fca50165..82d46c043 100644 --- a/preload.js +++ b/preload.js @@ -136,7 +136,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('cli-busy-state', (_event, sessionId, busy) => callback(sessionId, busy)); }, onSessionForked: (callback) => { - ipcRenderer.on('session-forked', (_event, oldId, newId) => callback(oldId, newId)); + ipcRenderer.on('session-forked', (_event, oldId, newId, kind) => callback(oldId, newId, kind)); }, onSubagentSpawned: (cb) => ipcRenderer.on('subagent-spawned', (_e, payload) => cb(payload)), onSubagentCompleted: (cb) => ipcRenderer.on('subagent-completed', (_e, payload) => cb(payload)), diff --git a/public/app.js b/public/app.js index 400f5edc9..f23c78e59 100644 --- a/public/app.js +++ b/public/app.js @@ -195,7 +195,7 @@ function persistWorkingSet({ final = false } = {}) { if (entry.closed) continue; // see docs/session-restore.md ("A session with no transcript yet") const fresh = pendingSessions.has(sessionId); - if (fresh && entry.session.remoteAlias) continue; + if (fresh && (entry.session.remoteAlias || pendingSessions.get(sessionId).restorable === false)) continue; set.push({ sessionId, projectPath: entry.session.projectPath, @@ -264,14 +264,15 @@ window.flushStateForExit = flushStateForExit; if (window.api.onExitFlush) window.api.onExitFlush(() => { flushStateForExit(); }); async function runRestore(list, { retryAfterIndexing = !restoreIndexingDone } = {}) { - const pending = list.filter(item => (item.fresh || sessionMap.has(item.sessionId)) && !openSessions.has(item.sessionId)); + const startsFresh = item => item.fresh && !sessionMap.has(item.sessionId); + const pending = list.filter(item => (startsFresh(item) || sessionMap.has(item.sessionId)) && !openSessions.has(item.sessionId)); for (const item of pending) restoreInFlight.set(item.sessionId, item); const skippedNow = []; const startedAs = new Map(); try { - const liveById = await liveElsewhereMany(pending.filter(item => !item.fresh).map(item => item.sessionId), { api: window.api }); + const liveById = await liveElsewhereMany(pending.filter(item => !startsFresh(item)).map(item => item.sessionId), { api: window.api }); for (const [position, item] of list.entries()) { - if (item.fresh) { + if (startsFresh(item)) { if (!restoreInFlight.has(item.sessionId)) continue; try { const project = { projectPath: item.projectPath }; @@ -598,7 +599,7 @@ window.api.onSessionDetected((tempId, realId) => { pollActiveSessions(); }); -function applySessionForked(oldId, newId) { +window.api.onSessionForked((oldId, newId, kind) => { if (window.ATRACE) window.atrace('recv.session-forked', newId, { oldId, known: openSessions.has(oldId) }); const entry = openSessions.get(oldId); if (!entry) return; @@ -620,11 +621,12 @@ function applySessionForked(oldId, newId) { if (pendingEntry) { pendingEntry.sessionId = newId; pendingSessions.set(newId, pendingEntry); - } else if (entry.session.projectPath) { + } else if (kind === 'clear' && entry.session.projectPath) { // see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") const projectPath = entry.session.projectPath; - const session = { ...entry.session, sessionId: newId, summary: 'New session', firstPrompt: '', name: null, messageCount: 0 }; - pendingSessions.set(newId, { session, projectPath, folder: encodeProjectPath(projectPath), cleared: true }); + const now = new Date().toISOString(); + entry.session = { sessionId: newId, summary: 'New session', firstPrompt: '', projectPath, name: null, starred: 0, archived: 0, messageCount: 0, modified: now, created: now }; + pendingSessions.set(newId, { session: entry.session, projectPath, folder: encodeProjectPath(projectPath), cleared: true }); } sessionMap.delete(oldId); sessionMap.set(newId, entry.session); @@ -642,9 +644,7 @@ function applySessionForked(oldId, newId) { }); schedulePersistWorkingSet(); pollActiveSessions(); -} - -window.api.onSessionForked(applySessionForked); +}); function applyProcessExit(sessionId, exitCode, signal, stopped) { if (window.ATRACE) window.atrace('recv.process-exited', sessionId, { exitCode, signal, stopped }); @@ -1346,7 +1346,8 @@ async function launchNewSession(project, sessionOptions) { // Track as pending (no .jsonl yet) const folder = encodeProjectPath(projectPath); - pendingSessions.set(sessionId, { session, projectPath, folder }); + const restorable = !(sessionOptions && (sessionOptions.forkFrom || sessionOptions.worktree)); + pendingSessions.set(sessionId, { session, projectPath, folder, ...(restorable ? {} : { restorable: false }) }); // Inject into cached project data so it appears in sidebar immediately sessionMap.set(sessionId, session); diff --git a/public/restore-plan.js b/public/restore-plan.js index 3b0dac3c1..f3e5dbe93 100644 --- a/public/restore-plan.js +++ b/public/restore-plan.js @@ -37,7 +37,7 @@ function createRestorePlanner({ savedSet, maxTicks = 50, askOnce = false } = {}) return { action: 'nothing', candidates: [], remaining: 0, unavailable: [] }; } - const indexedIds = [...remaining].filter(id => items.get(id).fresh || (sessionMap && sessionMap.has(id))); + const indexedIds = [...remaining].filter(id => (sessionMap && sessionMap.has(id)) || (indexingDone && items.get(id).fresh)); const allIndexed = indexedIds.length === remaining.size; if (askOnce) { diff --git a/session-transitions.js b/session-transitions.js index 5d416e284..e2b3df969 100644 --- a/session-transitions.js +++ b/session-transitions.js @@ -20,6 +20,7 @@ function init(ctx) { clearOwner = ctx.clearOwner || (() => 'pending'); setTimeoutFn = ctx.setTimeout || setTimeout; rekeyed.clear(); + signalsCache.clear(); for (const timer of recheckTimers.values()) clearTimeout(timer); recheckTimers.clear(); } @@ -27,9 +28,33 @@ function init(ctx) { // see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") const rekeyed = new Map(); +function isLive(sessionId) { + const session = activeSessions && activeSessions.get(sessionId); + return !!session && !session.exited; +} + /** The id a session is keyed under now, following the re-keys of forks and /clear. */ +const CLEAR_COMMANDS = new Set(['/clear', '/reset', '/new']); + +function logOwnerOnce(session, file, line) { + const logged = session._ownerLogged || (session._ownerLogged = new Map()); + if (logged.get(file) === line) return; + logged.set(file, line); + log.info(line); +} + function currentSessionId(sessionId) { - return rekeyed.get(sessionId) || sessionId; + if (isLive(sessionId)) { + rekeyed.delete(sessionId); + return sessionId; + } + const target = rekeyed.get(sessionId); + if (target === undefined) return sessionId; + if (!isLive(target)) { + rekeyed.delete(sessionId); + return sessionId; + } + return target; } function recordRekey(oldId, newId) { @@ -328,11 +353,27 @@ function detectSubagentTransitions(sessionId, session, folderPath) { /** Read first few lines of a new .jsonl to extract signals. * Skips file-history-snapshot lines which can be very large (tens of KB) * and reads up to 512KB to find the first user/assistant entry. */ +const signalsCache = new Map(); +const SIGNALS_CACHE_MAX = 256; + function readNewSessionSignals(filePath) { + let stat; + try { stat = fs.statSync(filePath); } catch { stat = null; } + const cached = stat && signalsCache.get(filePath); + if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.signals; + const signals = readSignalsFromDisk(filePath, stat ? Math.min(stat.size, 524288) : 524288); + if (stat) { + if (signalsCache.size >= SIGNALS_CACHE_MAX) signalsCache.clear(); + signalsCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, signals }); + } + return signals; +} + +function readSignalsFromDisk(filePath, length) { try { const fd = fs.openSync(filePath, 'r'); - const buf = Buffer.alloc(524288); - const bytesRead = fs.readSync(fd, buf, 0, 524288, 0); + const buf = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buf, 0, length, 0); fs.closeSync(fd); const head = buf.toString('utf8', 0, bytesRead); const lines = head.split('\n').filter(Boolean); @@ -355,7 +396,7 @@ function readNewSessionSignals(filePath) { const kind = classifyUserText(text); // see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") if (kind.kind === 'skip') continue; - cleared = kind.kind === 'command' && kind.text === '/clear'; + cleared = kind.kind === 'command' && CLEAR_COMMANDS.has(kind.text.split(' ')[0]); } // Stop after finding a user or assistant message if (entry.type === 'user' || entry.type === 'assistant') { hasTurn = true; break; } @@ -454,7 +495,7 @@ function detectSessionTransitions(folder) { const owner = clearOwner(newId, session.pty && session.pty.pid); if (owner === 'mine') matched = true; else pendingOwner = owner === 'pending'; - log.info(`[detect] session=${sessionId} snapshot-only fork file=${newId} owner=${owner} matched=${matched}`); + logOwnerOnce(session, newFile, `[detect] session=${sessionId} snapshot-only fork file=${newId} owner=${owner} matched=${matched}`); } if (!matched && signals.cleared && !awaitingFork && newId !== (session.realSessionId || sessionId)) { const owner = clearOwner(newId, session.pty && session.pty.pid); @@ -464,7 +505,7 @@ function detectSessionTransitions(folder) { emptyFiles.add(newFile); recheck = true; } - log.info(`[detect] session=${sessionId} /clear file=${newId} owner=${owner} matched=${matched}`); + logOwnerOnce(session, newFile, `[detect] session=${sessionId} /clear file=${newId} owner=${owner} matched=${matched}`); } if (!matched && !signals.hasTurn && newId !== (session.realSessionId || sessionId) && ageMs(newFilePath) <= STALE_EMPTY_MS) { @@ -472,7 +513,8 @@ function detectSessionTransitions(folder) { if (isFresh(newFilePath) && (pendingOwner || !awaitingFork)) recheck = true; } - if (session.forkFrom && !matched) { + if (session.forkFrom && !matched && !(session._noMatchLogged || (session._noMatchLogged = new Set())).has(newFile)) { + session._noMatchLogged.add(newFile); log.info(`[detect] session=${sessionId} NO MATCH for newFile=${newId} forkFrom=${session.forkFrom} parentSessionId=${signals.parentSessionId||'null'} forkedFrom=${signals.forkedFrom||'null'}`); } @@ -494,7 +536,7 @@ function detectSessionTransitions(folder) { const mainWindow = getMainWindow(); if (TRACE) trace('session.forked', sessionId, { newId, wasBusy: !!session._cliBusy, sent: !!(mainWindow && !mainWindow.isDestroyed()) }); if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('session-forked', sessionId, newId); + mainWindow.webContents.send('session-forked', sessionId, newId, signals.cleared && !awaitingFork ? 'clear' : 'fork'); } break; // Only one transition per session per flush } diff --git a/test/clear-pending-persist.test.js b/test/clear-pending-persist.test.js index 883115d53..e42390803 100644 --- a/test/clear-pending-persist.test.js +++ b/test/clear-pending-persist.test.js @@ -75,8 +75,7 @@ function setup() { window.api.onSessionForked = (cb) => { forkedListener = cb; }; const src = readAppSource(); const start = src.indexOf('window.api.onSessionForked('); - const named = src.indexOf('function applySessionForked('); - vm.runInContext(named >= 0 ? src.slice(named, src.indexOf(';\n', start) + 1) : src.slice(start, src.indexOf('\n});', start) + 4), ctx.context); + vm.runInContext(src.slice(start, src.indexOf('\n});', start) + 4), ctx.context); } forkedListener(...args); }; diff --git a/test/session-activity.test.js b/test/session-activity.test.js index 2ca437ca8..d7ec1c266 100644 --- a/test/session-activity.test.js +++ b/test/session-activity.test.js @@ -458,8 +458,7 @@ test('public/app.js: the poll reconciles busy state and re-keys it on fork/detec assert.match(poll, /reconcileBusyState\(entries,\s*seq\)/, 'the poll must reconcile the busy state'); assert.match(poll, /entries\.map\(e => e\.sessionId\)/, 'activePtyIds must be rebuilt from the new payload shape'); - assert.match(src, /window\.api\.onSessionForked\(applySessionForked\)/); - const forked = src.slice(src.indexOf('function applySessionForked'), src.indexOf('window.api.onSessionForked')); + const forked = src.slice(src.indexOf('window.api.onSessionForked'), src.indexOf('window.api.onProcessExited')); assert.match(forked, /rekeyActivityState\(oldId, newId\)/, 'a fork must carry the activity state to the new id'); const detected = src.slice(src.indexOf('window.api.onSessionDetected'), src.indexOf('window.api.onSessionForked')); diff --git a/test/terminal-webgl-lifecycle.test.js b/test/terminal-webgl-lifecycle.test.js index 8b6c768e8..f4d19d010 100644 --- a/test/terminal-webgl-lifecycle.test.js +++ b/test/terminal-webgl-lifecycle.test.js @@ -73,10 +73,9 @@ function loadShippedListener(f, listener) { f.w.terminalHeaderId = f.w.document.createElement('span'); f.w.terminalHeaderName = f.w.document.createElement('span'); const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8'); - const REG = 'window.api.onSessionForked(applySessionForked);'; - const start = src.indexOf(listener === 'onSessionForked' ? 'function applySessionForked' : `window.api.${listener}(`); + const start = src.indexOf(`window.api.${listener}(`); assert.ok(start >= 0, `${listener} found in app.js`); - const end = listener === 'onSessionForked' ? src.indexOf(REG) + REG.length : src.indexOf('\n});', start) + '\n});'.length; + const end = src.indexOf('\n});', start) + '\n});'.length; f.inCtx(src.slice(start, end)); return receive; } diff --git a/test/tool-bar.test.js b/test/tool-bar.test.js index 9a09de51c..98d0b20de 100644 --- a/test/tool-bar.test.js +++ b/test/tool-bar.test.js @@ -235,10 +235,9 @@ test('R3 N1: forking with Changes open keeps panel visibility and pressed state assert.equal(changes.getAttribute('aria-pressed'), 'true'); const state = ctx.state(); const src = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8'); - const start = src.indexOf('function applySessionForked'); + const start = src.indexOf('window.api.onSessionForked('); assert.ok(start >= 0); - const REG = 'window.api.onSessionForked(applySessionForked);'; - const end = src.indexOf(REG) + REG.length; + const end = src.indexOf('\n});', start) + '\n});'.length; ctx.inCtx(src.slice(start, end)); receive('s1', 'forked'); await flush(); @@ -349,10 +348,9 @@ for (const listener of ['onSessionDetected', 'onSessionForked']) { window.terminalHeaderId = window.document.createElement('span'); window.terminalHeaderName = window.document.createElement('span'); const src = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8'); - const REG = 'window.api.onSessionForked(applySessionForked);'; - const start = src.indexOf(listener === 'onSessionForked' ? 'function applySessionForked' : `window.api.${listener}(`); + const start = src.indexOf(`window.api.${listener}(`); assert.ok(start >= 0); - const end = listener === 'onSessionForked' ? src.indexOf(REG) + REG.length : src.indexOf('\n});', start) + '\n});'.length; + const end = src.indexOf('\n});', start) + '\n});'.length; ctx.inCtx(src.slice(start, end)); receive('s1', 'real'); await flush(); diff --git a/trigger-context.js b/trigger-context.js index d8cdf56f6..af89a1e84 100644 --- a/trigger-context.js +++ b/trigger-context.js @@ -38,6 +38,7 @@ function createTriggerContext(deps) { const resolve = deps.resolveSessionId || ((id) => id); const ctx = { log, + resolveSessionId: resolve, getPtyForSession(sessionId) { const session = activeSessions.get(resolve(sessionId)); if (!session || session.exited) return null; @@ -88,6 +89,8 @@ function createTriggerContext(deps) { if (!session || session.exited || session.host != null || !session.projectFolder) return null; const id = session.realSessionId || resolve(sessionId); const filePath = path.join(projectsDir, session.projectFolder, id + '.jsonl'); + const previous = readPaths.get(sessionId); + if (previous !== undefined && previous !== filePath) reader.forget(previous); readPaths.set(sessionId, filePath); return reader.read(filePath, compactionCursor); }; diff --git a/trigger-watcher.js b/trigger-watcher.js index 86d7720b3..4b2482ddc 100644 --- a/trigger-watcher.js +++ b/trigger-watcher.js @@ -1922,18 +1922,18 @@ function start(ctx) { const retained = new Set(); // see .ai/contexts/trigger-watcher.md, "Session serialization" - const sessionLocks = new Map(); // PTY (or sessionId) -> tail Promise of the queue + const sessionLocks = new Map(); // sessionId -> tail Promise of the queue + const resolveId = (id) => (ctx.resolveSessionId ? ctx.resolveSessionId(id) : id); function acquireSessionLock(sessionId) { - const entry = ctx.getPtyForSession(sessionId); - const key = (entry && entry.ptyProcess) || sessionId; - const previous = sessionLocks.get(key) || Promise.resolve(); + const target = resolveId(sessionId); + const previous = Promise.all([...sessionLocks].filter(([id]) => resolveId(id) === target).map(([, t]) => t)); let release; const held = new Promise((resolve) => { release = resolve; }); const tail = previous.then(() => held); - sessionLocks.set(key, tail); + sessionLocks.set(sessionId, tail); return previous.then(() => () => { release(); - if (sessionLocks.get(key) === tail) sessionLocks.delete(key); + if (sessionLocks.get(sessionId) === tail) sessionLocks.delete(sessionId); }); }