diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index 5608b9646..0481eafa6 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -56,7 +56,9 @@ attaches/stops/respawns/deletes/dispatches through the CLI. User doc: 9. `delete-session` asks `deleteSessionRefusal` (`delete-session-guard.js`) first, and it fails closed. It refuses a session held by an open terminal (by id or `realSessionId`), by a live job (`liveJobCheck`: the - job files, read directly so it works before the view was ever opened; the + job files, read directly so it works before the view was ever opened, a + job's session id taken from `linkScanPath`, else from its `sessionId` or + `resumeSessionId` field, which a job with no transcript yet carries; the roster only supplies a session id a file lacks, or answers when `jobs/` does not exist or a listed job's `state.json` is missing), or by another process diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7be204a..b35ca6d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - In a sandboxed session, a repository or a `.claude` folder below an Additional Directory that is a symbolic link now has its Git config, hooks and Claude settings read-only, like one below a plain directory. Before, they were writable from inside the sandbox. Such a directory holding a `.claude` that is itself a symbolic link, or a repository Git cannot read, now refuses to start the session (status 125), as a plain directory already did, instead of starting it unprotected. (#538) - 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) ## v0.0.92 — 2026-10-10 diff --git a/bg-agents-roster.js b/bg-agents-roster.js index 09934dd4a..203a3219f 100644 --- a/bg-agents-roster.js +++ b/bg-agents-roster.js @@ -7,6 +7,7 @@ const SESSION_STATUSES = new Set(['busy', 'idle', 'waiting', 'shell']); const JOB_ID_RE = /^[0-9a-f]{8}$/; const JOB_ID_IN_TEXT_RE = /(?:^|[^0-9a-f])([0-9a-f]{8})(?![0-9a-f])/i; const TRANSCRIPT_ID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const str = (v) => (typeof v === 'string' && v ? v : null); const num = (v) => (Number.isFinite(v) ? v : null); @@ -17,6 +18,10 @@ function sessionIdFromLinkScanPath(p) { return m ? m[1].toLowerCase() : null; } +function sessionIdField(v) { + return typeof v === 'string' && SESSION_ID_RE.test(v) ? v.toLowerCase() : null; +} + function flagValue(flags, name) { if (!Array.isArray(flags)) return null; const i = flags.indexOf(name); @@ -49,7 +54,7 @@ function parseJobState(text) { agent: flagValue(raw.respawnFlags, '--agent'), model: flagValue(raw.respawnFlags, '--model'), name: flagValue(raw.respawnFlags, '--name'), - sessionId: sessionIdFromLinkScanPath(raw.linkScanPath), + sessionId: sessionIdFromLinkScanPath(raw.linkScanPath) || sessionIdField(raw.sessionId) || sessionIdField(raw.resumeSessionId), transcriptPath: str(raw.linkScanPath), bridgeSessionId: str(raw.bridgeSessionId), }; diff --git a/test/bg-agents-roster.test.js b/test/bg-agents-roster.test.js index 408944166..c080b3225 100644 --- a/test/bg-agents-roster.test.js +++ b/test/bg-agents-roster.test.js @@ -35,6 +35,16 @@ test('parseJobState keeps the fields the view shows and derives agent/model/name assert.equal(job.sessionId, 'bc3fd129-60bb-4bd2-8f38-63fecd1256e5'); }); +test('parseJobState takes the session id from sessionId when the job has no transcript yet', () => { + const job = parseJobState(JSON.stringify({ + state: 'working', needs: 'send a prompt to start', linkScanOffset: 0, + sessionId: 'FC9016D8-5781-452C-BB22-687C121E6C43', resumeSessionId: 'fc9016d8-5781-452c-bb22-687c121e6c43', + })); + assert.equal(job.sessionId, 'fc9016d8-5781-452c-bb22-687c121e6c43'); + assert.equal(parseJobState(JSON.stringify({ state: 'working', resumeSessionId: 'fc9016d8-5781-452c-bb22-687c121e6c43' })).sessionId, 'fc9016d8-5781-452c-bb22-687c121e6c43'); + assert.equal(parseJobState(JSON.stringify({ state: 'working', sessionId: 'not a session id' })).sessionId, null); +}); + test('parseJobState: unknown state, missing output and garbage are tolerated', () => { assert.equal(parseJobState(''), null); assert.equal(parseJobState('[]'), null); diff --git a/test/bg-agents.test.js b/test/bg-agents.test.js index 5175ba335..0529db2de 100644 --- a/test/bg-agents.test.js +++ b/test/bg-agents.test.js @@ -366,6 +366,18 @@ test('liveJobCheck reads the job files, takes the session id from the roster whe } finally { rmTmp(dir); } }); +test('liveJobCheck: a live job with no transcript yet names its session through sessionId, so other sessions are not refused', async () => { + const dir = mkTmp(); + try { + writeJob(dir, 'aaaaaaaa', { state: 'working', needs: 'send a prompt to start', sessionId: 'aaaaaaaa-5781-452c-bb22-687c121e6c43' }); + boot(dir, { cli: fakeCli({ list: [] }) }); + bgAgents.start(); + await bgAgents.reconcile(); + assert.deepEqual(bgAgents.liveJobCheck('11111111-2222-4333-8444-555555555555'), { known: true, job: null }); + assert.deepEqual(bgAgents.liveJobCheck('aaaaaaaa-5781-452c-bb22-687c121e6c43'), { known: true, job: { id: 'aaaaaaaa', state: 'working' } }); + } finally { rmTmp(dir); } +}); + test('roster entries carry projectRoot and worktreeRoot: the pattern or the cwd at once, the resolved roots after a change event', async () => { const dir = mkTmp(); try {