From 1e86c1f8f5af6730ece8e698a20ee8c568e51ba7 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 7 Nov 2026 18:26:55 +0100 Subject: [PATCH 1/7] harness: claim card branch Harness-Claim: true Agent: switchboard-dev Host: ANCHOR Base-Ref: origin/main Base-SHA: e0f144b4048f7ddb8a9cb76b61001c94e599ceef Claimed-At: 2026-11-07T17:26:54.967654Z Nonce: 7a26dddc72e719b3 From a1eb9b55ac3be0de80bad8e66aeace35b8899838 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 7 Nov 2026 20:00:33 +0100 Subject: [PATCH 2/7] (bg-agents): adopt the CLI's fork instead of respawning a second copy (#1020) On exit the CLI may fork the session into a background job. The handoff took the terminal's exit to mean the conversation was free and respawned the old id, so two copies ran with the same crons. Adopt a new continuation, refuse a respawn when one is live or unknown, and do not bring such a job back at restore. --- .ai/contexts/bg-agents.md | 43 +++++++-- CHANGELOG.md | 1 + src/main/bg-agents/bg-agents-ipc.js | 14 ++- src/main/bg-agents/bg-agents.js | 35 ++++++- src/main/bg-agents/bg-handoff.js | 29 +++++- test/bg-agents-522.test.js | 7 +- test/bg-agents-ipc.test.js | 5 +- test/bg-agents-restore.test.js | 8 +- test/bg-duplicate-1020.test.js | 138 ++++++++++++++++++++++++++++ test/bg-handoff-respawn.test.js | 5 +- test/bg-handoff.test.js | 1 + 11 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 test/bg-duplicate-1020.test.js diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index 4b4c2ba0e..815ff9734 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -50,11 +50,14 @@ every matching id. A missing record releases the held entry, with a notice; a later sidebar click follows the ordinary guarded resume path. `conversationCheck(id, { workerLiveness: 'all' })` requests S1a's classifier -for terminal states too. Live attaches; dead working/blocked/running/done/failed -goes through the existing `checkVerb('respawn')` and `runVerb` guards. Only the +for terminal states too. Live attaches; only a dead `working` job goes through +the existing `checkVerb('respawn')` and `runVerb` guards. Dead `running` is +ambiguous, `blocked` requires intervention, and `done`, `failed` and `stopped` +have ended or been stopped: all require explicit Respawn through the existing +button. Unknown states or worker liveness refuse. Only the restore check passes its classified worker evidence to `liveJobCheck`, keyed by job id; Delete and ordinary Respawn retain their conservative defaults. Dead -stopped offers explicit Respawn; unknown never respawns. A per-session promise +non-working jobs offer explicit Respawn; unknown never respawns. A per-session promise coalesces overlapping requests. Descriptor observation subscribes before its initial read, matches this job through `liveWorkerDescriptor`, waits at most 15 seconds and always releases the listener. A successful respawn without a @@ -94,8 +97,9 @@ M1 evidence (2026-10-25, CLI 2.1.302; untracked scratch notes in the primary checkout's `.work-files/m1-785-report.md`, not in the repository) measured dead `working` surviving daemon death, respawn cold-starting the daemon, retaining job/session/name, then producing a usable background descriptor. This implementation uses those recorded facts; -it does not repeat the live acceptance run. The stopped policy is Q-E's -unconfirmed default from the S1b brief. +it does not repeat the live acceptance run. Issue #1020 explicitly retains +manual recovery for stopped jobs and extends it to every dead state other than +`working`. ## Invariants @@ -181,7 +185,17 @@ Stop or Close action. See `test/dom-agents-view.test.js` (#566/S5). remains the daemon's stop operation, including missing or unknown job files. The check and CLI invocation are separate: a process can start after the check but before the CLI runs. This reduces stale-roster mistakes; it is - not an atomic lock on the conversation. + not an atomic lock on the conversation. Respawn additionally scans fresh + transcript continuations transitively, checking every reachable id through + `liveJobCheck`. A live continuation refuses both preview and execution, + including automatic restore and the handoff helper. Missing/unreadable + transcripts or unknown continuation ownership refuse; a 64-member bound + fails closed. This scan uses `resolveTranscriptPath`, independently of the + cached continuation index. Fresh identity includes a validated raw + `resumeSessionId` even when the job's transcript id differs. Bridge aliases + still take the ordinary process guards with their case-sensitive suffixes + but do not identify transcripts to scan; `continuationSessionIds` contains + only transcript UUIDs. ### Agents confirmations and detail buttons (#565) @@ -1061,16 +1075,27 @@ can still cause a refusal after exit. The route runs under the existing tab input lock, followed by a bound-tab and eligibility recheck. The same command-parameterized composer/300 ms/Enter guard handles `/bg` and `/exit`. The respawn path waits up to 15 seconds for -`session.exited`, never kills the source and never confirms a continuation. +`session.exited`, then observes the fresh transcript for another 10 seconds. +The CLI fork link was measured at 5.5 seconds after `/exit` on 2026-11-07; +the 10-second grace after detected exit allows that delay plus margin. Polling +uses the injected clock and change waiter. A new continuation, excluding the +pre-command snapshot, plus one matching live background descriptor confirms +the handoff and adopts that job without respawning. A new link without a +descriptor, multiple new links or an uncertain final read refuse respawn. +Observed links remain held even if a later rewrite drops them. Only a complete +final read with no new continuation permits the checked respawn. This is a +bounded observation, not a CLI-level atomic writer lock; the shared guard +re-reads continuations immediately before the CLI verb as another check. The helper exposed as `unsubscribeBgAgents.respawnForHandoff(job, extraSessionIds)` is shared with S1b's restore IPC: checked respawn, runVerb, then descriptor wait using the full job identity (including descriptors without jobId). S2 passes the source id and raw resumeTarget as extra ids; after exit, the verb checks their lowercased, deduplicated union with fresh check.sessionIds. The optional extra ids default to empty, preserving ordinary Agents-view Respawn/Delete -and S1b restore guards. Restore's action shapes and stopped-job policy stay the same. +and S1b restore identity guards. Restore retains its action shapes and now holds +every dead state except `working` for explicit Respawn. -Success sets `backgrounded` and returns the same conversation id with +Respawn success sets `backgrounded` and returns the same conversation id with `respawned:true` and `attached`. Main skips transition detection and continuation persistence. The held source exit removes the active session before the handler resolves. The toolbar replaces it with one attach tab; diff --git a/CHANGELOG.md b/CHANGELOG.md index b2dd31cbd..82e60d365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed +- Sending a resumed session to the background no longer leaves two copies of the conversation running. (#1020) - A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017) ## v0.0.103 — 2026-11-03 diff --git a/src/main/bg-agents/bg-agents-ipc.js b/src/main/bg-agents/bg-agents-ipc.js index 367c659c0..f6c94f3cc 100644 --- a/src/main/bg-agents/bg-agents-ipc.js +++ b/src/main/bg-agents/bg-agents-ipc.js @@ -39,8 +39,9 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio if (check.hosted) { return { ok: false, error: `cannot ${action} a ${check.state} session; stop it first` }; } - const sessionIds = extraSessionIds.length - ? new Set([...check.sessionIds, ...extraSessionIds].map(s => s.toLowerCase())) : check.sessionIds; + const sessionIds = new Set(extraSessionIds.length + ? [...check.sessionIds, ...extraSessionIds].map(s => s.toLowerCase()) : check.sessionIds); + if (verb === 'respawn') for (const sid of check.continuationSessionIds || []) sessionIds.add(sid); for (const sessionId of sessionIds) { const refusal = await deleteSessionRefusal(sessionId, { activeSessions: verb === 'stop' ? new Map([...activeSessions || []].filter(([, session]) => @@ -53,6 +54,13 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio ? refusal.replace(/ — [^—]*(?:retry Delete|not deleted)$/, ' — not stopped') : refusal.replace('not deleted', declined).replace(/then retry Delete$/, verb === 'respawn' ? 'then retry Respawn' : 'then retry Delete') }; } + if (verb === 'respawn') { + for (const sessionId of new Set([...(check.continuationSessionIds || check.sessionIds), ...extraSessionIds].map(s => s.toLowerCase()))) { + const continuation = await bgAgents.continuationJobCheck(sessionId); + if (!continuation.known) return { ok: false, error: `cannot check conversation continuations (${continuation.reason}) — not respawned` }; + if (continuation.job) return { ok: false, error: `continuation job ${continuation.job.id} still runs this conversation — not respawned` }; + } + } } catch (err) { if (verb === 'stop' && !check) return { ok: true }; return { ok: false, error: `cannot tell whether this conversation is running (${err.message}) — ${declined}` }; @@ -119,7 +127,7 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio if (check.workerLiveness !== 'live' && check.workerLiveness !== 'dead') return refuse('worker liveness unknown — not respawned'); if (check.workerLiveness === 'dead') { if (attachOnly) return refuse('worker is not live — not attached'); - if (check.state === 'stopped') return refuse('stopped job was not respawned automatically', 'respawn'); + if (check.state !== 'working') return refuse(check.state + ' job was not respawned automatically', 'respawn'); const result = await respawnForHandoff(job); if (!result.ok) return refuse(result.error); return result.attached ? { action: 'attach', sessionId, jobId: job.id } diff --git a/src/main/bg-agents/bg-agents.js b/src/main/bg-agents/bg-agents.js index 1c8bc75d2..4d242096c 100644 --- a/src/main/bg-agents/bg-agents.js +++ b/src/main/bg-agents/bg-agents.js @@ -10,6 +10,7 @@ const { } = require('./bg-agents-roster'); const { resolveProjectRoots, projectRootFromPattern, worktreeRootFromPattern } = require('../../../project-root'); const { classifyWorkerLiveness, workerTimestamp } = require('./bg-worker-liveness'); +const { createContinuationReader } = require('./bg-handoff'); const DEFAULT_JOBS_DIR = path.join(os.homedir(), '.claude', 'jobs'); const FLUSH_MS = 250; @@ -374,8 +375,15 @@ async function workerEvidence(job, allStates = false) { async function conversationCheck(id, { workerLiveness = true } = {}) { if (typeof id !== 'string' || !JOB_ID_RE.test(id)) return { known: false, reason: 'invalid background session id' }; const gen = generation; - let job; - try { job = parseJobState(fs.readFileSync(path.join(jobsDir, id, 'state.json'), 'utf8')); } catch (err) { + let job, resumeSessionId; + try { + const text = fs.readFileSync(path.join(jobsDir, id, 'state.json'), 'utf8'); + job = parseJobState(text); + if (job?.state) { + const target = JSON.parse(text).resumeSessionId; + if (typeof target === 'string' && SESSION_ID_RE.test(target)) resumeSessionId = target.toLowerCase(); + } + } catch (err) { return { known: false, reason: `cannot read job ${id}: ${err.message}` }; } if (!job || !job.state) return { known: false, reason: `job ${id} has an unknown or unreadable state` }; @@ -387,9 +395,30 @@ async function conversationCheck(id, { workerLiveness = true } = {}) { const evidence = workerLiveness ? await workerEvidence(identified, workerLiveness === 'all') : undefined; if (gen !== generation) return { known: false, reason: 'background agent service changed while checking the worker' }; return { known: true, sessionIds, state: job.state, workerLiveness: evidence, + continuationSessionIds: [...new Set([...sessionIds.filter(sid => SESSION_ID_RE.test(sid)), resumeSessionId].filter(Boolean))], hosted: isHostedJob(identified, liveDescriptors(), evidence) }; } +async function continuationJobCheck(sessionId) { + const pending = [sessionId], seen = new Set(); + while (pending.length) { + const id = pending.shift().toLowerCase(); + if (seen.has(id)) continue; + if (seen.size >= 64) return { known: false, reason: 'too many conversation continuations' }; + seen.add(id); + if (id !== sessionId.toLowerCase()) { + const owner = liveJobCheck(id); + if (!owner.known || owner.job) return owner; + } + const file = resolveTranscriptPath?.(id); + if (!file) return { known: false, reason: 'continuation transcript unavailable for ' + id }; + const ids = await createContinuationReader(file, id)(); + if (!ids) return { known: false, reason: 'cannot read conversation continuations for ' + id }; + pending.push(...ids); + } + return { known: true, job: null }; +} + function findRestoreJob(sessionId, attachJobId, { ignoreUnrelatedInvalid = false } = {}) { const same = value => typeof value === 'string' && value.toLowerCase() === sessionId.toLowerCase(); const listed = id => roster.find(e => e.kind === 'background' && e.id === id) || null; @@ -539,6 +568,6 @@ function liveJobCheck(sessionId, { workerLiveness = new Map() } = {}) { module.exports = { getDaemonRosterPath: () => daemonRosterPath, invalidateTranscripts, - init, start, stop, onChange, onJobsChanged, getSnapshot, getCachedJobs, reconcile, runVerb, dispatch, liveJobCheck, conversationCheck, findRestoreJob, + init, start, stop, onChange, onJobsChanged, getSnapshot, getCachedJobs, reconcile, runVerb, dispatch, liveJobCheck, conversationCheck, continuationJobCheck, findRestoreJob, DEFAULT_JOBS_DIR, FLUSH_MS, MAX_JOBS, ROOT_CACHE_MAX, LIST_TIMEOUT_MS, VERB_TIMEOUT_MS, }; diff --git a/src/main/bg-agents/bg-handoff.js b/src/main/bg-agents/bg-handoff.js index d88972ec2..bf917d6af 100644 --- a/src/main/bg-agents/bg-handoff.js +++ b/src/main/bg-agents/bg-handoff.js @@ -11,6 +11,7 @@ const { releaseWindowsPty } = require('../terminals/pty-ops'); const BG_HANDOFF_TIMEOUT_MS = 120000; const BG_HANDOFF_EXIT_TIMEOUT_MS = 15000; const BG_CONFIRM_NOTICE_MS = 1000; +const BG_RESPAWN_CONTINUATION_WAIT_MS = 10000; function backgroundRefusal(session, status) { if (!session || session.exited) return 'This session is not running.'; @@ -91,7 +92,7 @@ function respawnRoute({ oldId, lookup, check, owned, elsewhere = [] }) { return { route: 'respawn', jobId: job.id, job }; } -async function respawnHandoff(session, sessionId, oldId, job, deps) { +async function respawnHandoff(session, sessionId, oldId, job, previous, deps) { const jobId = job.id; const exitTimeoutMs = deps.exitTimeoutMs ?? BG_HANDOFF_EXIT_TIMEOUT_MS; const exitBy = deps.now() + exitTimeoutMs; @@ -99,6 +100,30 @@ async function respawnHandoff(session, sessionId, oldId, job, deps) { if (deps.now() >= exitBy) return { ok: false, code: 'exit-timeout', jobId, error: 'Sent /exit, but the terminal did not exit within ' + (exitTimeoutMs / 1000) + ' s; nothing was relaunched. Check the terminal.' }; await deps.waitForChange(Math.max(0, Math.min(100, exitBy - deps.now()))); } + const continuationBy = deps.now() + (deps.continuationWaitMs ?? BG_RESPAWN_CONTINUATION_WAIT_MS); + let uncertain = true; + const newIds = new Set(); + for (;;) { + const ids = await deps.readContinuationIds(oldId, session); + uncertain = !ids; + for (const id of ids || []) { + if (id.toLowerCase() !== oldId.toLowerCase() && !previous.has(id.toLowerCase())) newIds.add(id.toLowerCase()); + } + if (!uncertain && newIds.size === 1) { + const continuation = findHandoff(deps.readDescriptors(), [...newIds]); + if (continuation) { + session.backgrounded = true; + await releaseWindowsPty(session.pty); + deps.onConfirmed?.(continuation); + return { ok: true, oldSessionId: sessionId, jobId: continuation.jobId, + sessionId: continuation.sessionId, cwd: continuation.cwd || session.cwd }; + } + } + if (deps.now() >= continuationBy) break; + await deps.waitForChange(Math.min(100, continuationBy - deps.now())); + } + if (uncertain || newIds.size) return { ok: false, code: 'respawn-failed', jobId, + error: 'The terminal exited, but its continuation could not be confirmed. The job was not respawned. Check the Agents view or open the conversation from the sidebar before retrying.' }; let result; try { result = await deps.respawnJob(job); } catch (err) { result = { ok: false, error: err.message || String(err) }; } @@ -161,7 +186,7 @@ async function sendToBackground(sessionId, deps) { } if (!deps.write(session, '\r')) return { ok: false, code: 'write-failed', error: 'Could not submit ' + command + ' to the terminal. Check the terminal before trying again.' }; if (composer) noteUserInput(composer, '\r', deps.now()); - if (route.route === 'respawn') return await respawnHandoff(session, sessionId, oldId, route.job, deps); + if (route.route === 'respawn') return await respawnHandoff(session, sessionId, oldId, route.job, previous, deps); for (;;) { if (session.exited && exitDeadline === Infinity) exitDeadline = deps.now() + (deps.exitTimeoutMs ?? BG_HANDOFF_EXIT_TIMEOUT_MS); if (deps.now() >= Math.min(deadline, exitDeadline)) { diff --git a/test/bg-agents-522.test.js b/test/bg-agents-522.test.js index efa8f9c62..8bc7f31b5 100644 --- a/test/bg-agents-522.test.js +++ b/test/bg-agents-522.test.js @@ -20,11 +20,12 @@ const OTHER_SID = 'bbbbbbbb-1234-4321-abcd-123456789abc'; const JOB = 'aaaaaaaa'; const PID = 987654; -function fixture(t, { state = 'stopped', live = true, own = false, ownDescriptor = false, bridge = false, unknown = false, resolveTranscriptPath } = {}) { +function fixture(t, { state = 'stopped', live = true, own = false, ownDescriptor = false, bridge = false, unknown = false, resolveTranscriptPath, withTranscript = false } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sw-522-')); const jobs = path.join(dir, 'jobs'); const sessions = path.join(dir, 'sessions'); const transcript = path.join(dir, SID + '.jsonl'); + if (withTranscript) fs.writeFileSync(transcript, JSON.stringify({ type: 'user', sessionId: SID }) + '\n'); fs.mkdirSync(path.join(jobs, JOB), { recursive: true }); fs.mkdirSync(sessions); fs.writeFileSync(path.join(jobs, JOB, 'state.json'), JSON.stringify({ @@ -40,7 +41,7 @@ function fixture(t, { state = 'stopped', live = true, own = false, ownDescriptor cliSessionState.init({ dir: sessions, activeSessions, onIdle() {}, isProcessAlive: () => live, readProcStartMany: async () => new Map(), platform: 'win32' }); const calls = []; bgAgents.init({ - jobsDir: jobs, homeDir: dir, cliSessionState, resolveProjectRoots: async () => null, resolveTranscriptPath, + jobsDir: jobs, homeDir: dir, cliSessionState, resolveProjectRoots: async () => null, resolveTranscriptPath: resolveTranscriptPath || (withTranscript ? () => transcript : undefined), makeIsOwnPid: () => pid => ownDescriptor && pid === PID, runClaude: async (args) => { calls.push(args); @@ -84,7 +85,7 @@ for (const state of ['stopped', 'done', 'failed']) { for (const verb of ['rm', 'respawn', 'stop']) { for (const scenario of ['external', 'own', 'own-descriptor', 'bridge', 'unknown']) { test(`[522] IPC ${verb} refuses ${scenario}, then allows a checked stopped conversation`, { timeout: 9000 }, async (t) => { - const f = fixture(t, { own: scenario === 'own', ownDescriptor: scenario === 'own-descriptor', bridge: scenario === 'bridge', unknown: scenario === 'unknown' }); + const f = fixture(t, { own: scenario === 'own', ownDescriptor: scenario === 'own-descriptor', bridge: scenario === 'bridge', unknown: scenario === 'unknown', withTranscript: true }); const invoke = () => f.handlers.get('bg-agent-verb')({}, verb, JOB); const refusal = await invoke(); assert.equal(refusal.ok, false, 'the shipped IPC must refuse before invoking the CLI'); diff --git a/test/bg-agents-ipc.test.js b/test/bg-agents-ipc.test.js index 974c6c113..a490a9d15 100644 --- a/test/bg-agents-ipc.test.js +++ b/test/bg-agents-ipc.test.js @@ -20,6 +20,7 @@ function fakeBgAgents() { conversationCheck: () => ({ known: true, state: 'working', hosted: true, sessionIds: ['s-1'] }), dispatch: async (fields) => { calls.push(['dispatch', fields]); return { ok: true, id: 'aaaaaaaa' }; }, liveJobCheck: (sessionId) => { calls.push(['liveJobCheck', sessionId]); return { known: true, job: null }; }, + continuationJobCheck: async () => ({ known: true, job: null }), onChange: (l) => { listener = l; return () => { listener = null; }; }, fire: (snap) => listener && listener(snap), }; @@ -72,12 +73,12 @@ for (const state of ['working', 'blocked', 'running', 'done', 'failed', 'stopped test(`B main restore ${state}/${liveness} follows the classifier and checked verb`, async t => { const h = restoreFixture(t, { state, liveness, descriptors: [{ kind: 'bg', jobId: 'aaaaaaaa', sessionId: 's1' }] }); const result = await h.restore(); - const shouldRespawn = liveness === 'dead' && state !== 'stopped'; + const shouldRespawn = liveness === 'dead' && state === 'working'; const shouldAttach = liveness === 'live' || shouldRespawn; assert.equal(result.action, shouldAttach ? 'attach' : 'refuse'); assert.deepEqual(h.seen.filter(call => call[0] === 'respawn'), shouldRespawn ? [['respawn', 'aaaaaaaa']] : []); assert.ok(h.seen.some(call => call[0] === 'check' && call[1]?.workerLiveness === 'all'), 'S1a classifies every restore state'); - if (state === 'stopped' && liveness === 'dead') assert.equal(result.recovery, 'respawn'); + if (state !== 'working' && liveness === 'dead') assert.equal(result.recovery, 'respawn'); if (!shouldAttach) assert.match(result.reason, new RegExp(`aaaaaaaa.*${state}`)); }); } diff --git a/test/bg-agents-restore.test.js b/test/bg-agents-restore.test.js index 6382ae190..8315d5a00 100644 --- a/test/bg-agents-restore.test.js +++ b/test/bg-agents-restore.test.js @@ -12,7 +12,8 @@ function fixture(t) { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-restore-785-')); const jobsDir = path.join(home, 'jobs'); fs.mkdirSync(jobsDir); - bg.init({ jobsDir, homeDir: home, cliSessionState: { readAllDescriptors: () => [], + fs.writeFileSync(path.join(home, SID + '.jsonl'), JSON.stringify({ type: 'user', sessionId: SID }) + '\n'); + bg.init({ jobsDir, homeDir: home, resolveTranscriptPath: id => path.join(home, id + '.jsonl'), cliSessionState: { readAllDescriptors: () => [], refreshProcessTable: async () => {}, readWorkerProcessEvidence: () => ({ processTableFresh: true, tableEntry: () => undefined }) } }); fs.mkdirSync(path.join(home, 'daemon')); fs.writeFileSync(path.join(home, 'daemon', 'roster.json'), JSON.stringify({ proto: 1, updatedAt: '2000-01-01T00:00:00Z', workers: {} })); @@ -167,8 +168,9 @@ for (const state of ['working', 'blocked']) { } assert.deepEqual(calls, []); const result = await handlers.get('restore-background-job')({}, SID); - assert.deepEqual(calls, [['respawn', 'aaaaaaaa']]); - assert.equal(result.action, 'attach'); + assert.deepEqual(calls, state === 'working' ? [['respawn', 'aaaaaaaa']] : []); + assert.equal(result.action, state === 'working' ? 'attach' : 'refuse'); + if (state === 'blocked') assert.equal(result.recovery, 'respawn'); }); } diff --git a/test/bg-duplicate-1020.test.js b/test/bg-duplicate-1020.test.js new file mode 100644 index 000000000..ca633d650 --- /dev/null +++ b/test/bg-duplicate-1020.test.js @@ -0,0 +1,138 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const bg = require('../src/main/bg-agents/bg-agents'); +const { init } = require('../src/main/bg-agents/bg-agents-ipc'); +const { sendToBackground, createContinuationReader } = require('../src/main/bg-agents/bg-handoff'); +const { createComposerState } = require('../src/main/terminals/composer-state'); + +const SID = '11111111-1111-4111-8111-111111111111'; +const NEXT = '22222222-2222-4222-8222-222222222222'; +const PRIOR = '33333333-3333-4333-8333-333333333333'; +const line = value => JSON.stringify(value) + '\n'; +const link = id => line({ type: 'continued-in', sessionId: SID, continuedInSessionId: id }); + +function fixture(t, state = 'stopped') { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-1020-')); + const file = path.join(root, SID + '.jsonl'); + fs.writeFileSync(file, line({ type: 'user', sessionId: SID, message: { role: 'user', content: 'hello' } })); + const jobsDir = path.join(root, 'jobs'); + fs.mkdirSync(jobsDir); + fs.mkdirSync(path.join(root, 'daemon')); + fs.writeFileSync(path.join(root, 'daemon', 'roster.json'), line({ proto: 1, updatedAt: '2000-01-01T00:00:00Z', workers: {} })); + const writeJob = (id, sessionId, jobState) => { + fs.mkdirSync(path.join(jobsDir, id), { recursive: true }); + fs.writeFileSync(path.join(jobsDir, id, 'state.json'), line({ state: jobState, sessionId, resumeSessionId: sessionId, linkScanPath: path.join(root, sessionId + '.jsonl') })); + }; + writeJob('aaaaaaaa', SID, state); + const descriptors = []; + const cli = { readAllDescriptors: () => descriptors, onDescriptorsChanged: () => () => {}, + liveElsewhereChecked: async () => ({ known: true, live: null }), + refreshProcessTable: async () => {}, readWorkerProcessEvidence: () => ({ processTableFresh: true, tableEntry: () => undefined }) }; + bg.init({ jobsDir, homeDir: root, cliSessionState: cli, resolveTranscriptPath: id => path.join(root, id + '.jsonl') }); + t.after(() => { bg.stop(); fs.rmSync(root, { recursive: true, force: true }); }); + const fork = () => { + fs.appendFileSync(file, line({ type: 'system', subtype: 'session_cron_carry', sessionId: SID }) + link(NEXT)); + fs.writeFileSync(path.join(root, NEXT + '.jsonl'), line({ type: 'user', sessionId: NEXT })); + writeJob('bbbbbbbb', NEXT, 'working'); + descriptors.push({ kind: 'bg', sessionId: NEXT, jobId: 'bbbbbbbb', cwd: root }); + }; + const calls = [], handlers = new Map(); + init({ ipcMain: { handle: (name, fn) => handlers.set(name, fn) }, + bgAgents: { ...bg, runVerb: async (...args) => { calls.push(args); return { ok: true }; } }, + cliSessionState: cli, getMainWindow: () => null, log: { warn() {} }, activeSessions: new Map(), + sessionHasPty: () => false, ptyPids: () => [], getSetting: () => ({ openWorkingSet: [{ sessionId: SID, bg: true, attachJobId: 'aaaaaaaa' }] }) }); + return { root, file, descriptors, fork, calls, handlers }; +} + +function handoff(h, { exitAt = 600, forkAt = Infinity, uncertain = false, descriptor = true, prior = false } = {}) { + let now = 0, forked = false; + const session = { kind: 'local-pty', realSessionId: SID, cwd: h.root, composerState: createComposerState() }; + if (prior) fs.appendFileSync(h.file, link(PRIOR)); + const read = createContinuationReader(h.file, SID); + const advance = async ms => { + now += ms; + if (!forked && now >= forkAt) { h.fork(); forked = true; if (!descriptor) h.descriptors.length = 0; } + if (now >= exitAt) session.exited = true; + }; + return sendToBackground(SID, { + getSession: () => session, getStatus: () => 'idle', write: () => true, tapOutput: () => () => {}, + readContinuationIds: () => uncertain && session.exited ? null : read(), readDescriptors: () => h.descriptors, + now: () => now, delay: advance, waitForChange: advance, + decideRoute: async () => ({ route: 'respawn', job: { id: 'aaaaaaaa' } }), + respawnJob: async () => { h.calls.push(['respawn']); return { ok: true, attached: true }; }, + }).then(result => ({ result, now })); +} + +for (const [label, exitAt, forkAt] of [['before exit detection', 6500, 5800], ['just after exit', 600, 5800]]) { + test('1020 handoff adopts fork ' + label, async t => { + const h = fixture(t); + const { result } = await handoff(h, { exitAt, forkAt, prior: true }); + assert.deepEqual(h.calls, [], 'a CLI continuation must prevent a second writer'); + assert.equal(result.ok, true); + assert.equal(result.jobId, 'bbbbbbbb'); + assert.equal(result.sessionId, NEXT); + assert.notEqual(result.respawned, true); + }); +} + +for (const options of [{ uncertain: true }, { forkAt: 5800, descriptor: false }]) { + test('1020 handoff refuses uncertain exit ' + JSON.stringify(options), async t => { + const h = fixture(t); + const { result } = await handoff(h, options); + assert.deepEqual(h.calls, [], 'uncertainty must never create a writer'); + assert.equal(result.ok, false); + assert.match(result.error, /not.*respawn|nothing.*relaunch|not.*relaunch/i); + }); +} + +test('1020 handoff waits a bounded grace then respawns with a readable unchanged transcript', async t => { + const h = fixture(t); + const { result, now } = await handoff(h, { prior: true }); + assert.ok(now >= 10600 && now <= 15600, 'allow the measured 5.5 second fork plus margin'); + assert.equal(result.respawned, true); + assert.deepEqual(h.calls, [['respawn']]); +}); + +for (const channel of ['check-bg-agent-verb', 'bg-agent-verb']) { + test('1020 ' + channel + ' refuses a live continuation', async t => { + const h = fixture(t); + h.fork(); + const result = await h.handlers.get(channel)({}, 'respawn', 'aaaaaaaa'); + assert.equal(result.ok, false, 'the old id cannot respawn while its continuation runs'); + assert.match(result.error, /bbbbbbbb|continuation/i); + assert.deepEqual(h.calls, []); + }); +} + +test('1020 manual respawn also checks the raw resume target when the job transcript differs', async t => { + const h = fixture(t); + h.fork(); + fs.writeFileSync(h.file, line({ type: 'user', sessionId: SID })); + fs.writeFileSync(path.join(h.root, PRIOR + '.jsonl'), line({ type: 'continued-in', sessionId: PRIOR, continuedInSessionId: NEXT })); + fs.writeFileSync(path.join(h.root, 'jobs', 'aaaaaaaa', 'state.json'), line({ state: 'stopped', sessionId: SID, resumeSessionId: PRIOR, linkScanPath: h.file })); + const result = await h.handlers.get('bg-agent-verb')({}, 'respawn', 'aaaaaaaa'); + assert.deepEqual(h.calls, [], 'the resume target continuation must prevent a second writer'); + assert.equal(result.ok, false); +}); + +test('1020 restore refuses a live continuation of a dead working job', async t => { + const h = fixture(t, 'working'); + h.fork(); + const result = await h.handlers.get('restore-background-job')({}, SID); + assert.deepEqual(h.calls, [], 'restore cannot create a second writer'); + assert.equal(result.action, 'refuse'); +}); + +for (const state of ['blocked', 'running', 'done', 'failed']) { + test('1020 dead ' + state + ' needs explicit Respawn', async t => { + const h = fixture(t, state); + const result = await h.handlers.get('restore-background-job')({}, SID); + assert.deepEqual(h.calls, [], 'non-resumable or ambiguous states stay stopped'); + assert.equal(result.action, 'refuse'); + assert.equal(result.recovery, 'respawn'); + }); +} diff --git a/test/bg-handoff-respawn.test.js b/test/bg-handoff-respawn.test.js index 2f1f8519c..dbf04e3b7 100644 --- a/test/bg-handoff-respawn.test.js +++ b/test/bg-handoff-respawn.test.js @@ -25,11 +25,12 @@ function setup(t, { jobs = {}, outside = [], others = [], listed = [], checkedRe fs.writeFileSync(path.join(jobsDir, id, 'state.json'), JSON.stringify(fields)); } const cliCalls = []; - bg.init({ jobsDir, homeDir: root, runClaude: async (...args) => { cliCalls.push(args); return { code: 0, stdout: JSON.stringify(listed) }; }, cliSessionState: { readAllDescriptors: () => [], onDescriptorsChanged: () => () => {}, ensureWatching() {}, + bg.init({ jobsDir, homeDir: root, resolveTranscriptPath: id => path.join(root, 'folder', id + '.jsonl'), runClaude: async (...args) => { cliCalls.push(args); return { code: 0, stdout: JSON.stringify(listed) }; }, cliSessionState: { readAllDescriptors: () => [], onDescriptorsChanged: () => () => {}, ensureWatching() {}, refreshProcessTable: async () => {}, readWorkerProcessEvidence: () => ({ processTableFresh: true, tableEntry: () => undefined }) } }); const folder = 'folder'; fs.mkdirSync(path.join(root, folder)); fs.writeFileSync(path.join(root, folder, SID + '.jsonl'), JSON.stringify({ type: 'user', message: { content: 'hello' } }) + '\n'); + fs.writeFileSync(path.join(root, folder, OTHER + '.jsonl'), JSON.stringify({ type: 'user', message: { content: 'hello' } }) + '\n'); const writes = [], respawned = [], exitedAtRespawn = [], listeners = new Set(); const session = { kind: 'local-pty', realSessionId: SID, projectFolder: folder, exitDrain: Promise.resolve(), composerState: require('../src/main/terminals/composer-state').createComposerState(), @@ -63,7 +64,7 @@ function setup(t, { jobs = {}, outside = [], others = [], listed = [], checkedRe }, }, ...require('../src/main/bg-agents/bg-handoff'), - sendToBackground: (id, deps) => require('../src/main/bg-agents/bg-handoff').sendToBackground(id, { ...deps, timeoutMs: 1000, exitTimeoutMs: 300 }), + sendToBackground: (id, deps) => require('../src/main/bg-agents/bg-handoff').sendToBackground(id, { ...deps, timeoutMs: 1000, exitTimeoutMs: 300, continuationWaitMs: 0 }), }); const { handleSendToBackground } = loadAppFunctions(ctx, { declarations: ['backgroundHandoffs'], functions: ['handleSendToBackground', 'persistBackgroundHandoff', 'readBackgroundHandoffStamp'], sourcePath: path.join(__dirname, '../main.js') }); diff --git a/test/bg-handoff.test.js b/test/bg-handoff.test.js index 8cb1b10f9..cf30e9d0f 100644 --- a/test/bg-handoff.test.js +++ b/test/bg-handoff.test.js @@ -385,6 +385,7 @@ for (const failure of ['throw', 'false']) { const S2_SID = '11111111-1111-4111-8111-111111111111'; function respawnSetup(respawnResult) { const h = setup(); + h.deps.readContinuationIds = () => []; h.deps.decideRoute = async () => ({ route: 'respawn', jobId: 'aaaaaaaa', job: { id: 'aaaaaaaa', sessionId: 'old' } }); h.deps.write = (_, text) => { h.writes.push(text); if (text === '\r') h.session.exited = true; return true; }; h.deps.respawnJob = async () => respawnResult; From bc1217e453e67132e1d0ee816d460ceaaf62a705 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 00:29:04 +0100 Subject: [PATCH 3/7] (bg-agents): keep restoring resting jobs and give every refusal a way out Restore still respawns dead working, done and running jobs unless a live continuation exists; a dead blocked job waits for a Respawn click that now works. An unreadable or deleted continuation no longer refuses forever, the check skips the job itself, and the handoff checks continuations before typing /exit. --- .ai/contexts/bg-agents.md | 50 ++++++++----- CHANGELOG.md | 2 +- docs/background-agents.md | 8 +- docs/session-restore.md | 6 +- main.js | 21 +++++- src/main/bg-agents/bg-agents-ipc.js | 14 ++-- src/main/bg-agents/bg-agents.js | 37 +++++++--- src/main/bg-agents/bg-handoff.js | 17 ++++- test/bg-agents-ipc.test.js | 4 +- test/bg-agents-restore.test.js | 6 +- test/bg-duplicate-1020.test.js | 98 +++++++++++++++++++++++- test/bg-handoff-main.test.js | 4 +- test/bg-handoff.test.js | 4 +- test/bg-recovery-1020fix.test.js | 111 ++++++++++++++++++++++++++++ 14 files changed, 324 insertions(+), 58 deletions(-) create mode 100644 test/bg-recovery-1020fix.test.js diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index 815ff9734..39a004c91 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -50,14 +50,13 @@ every matching id. A missing record releases the held entry, with a notice; a later sidebar click follows the ordinary guarded resume path. `conversationCheck(id, { workerLiveness: 'all' })` requests S1a's classifier -for terminal states too. Live attaches; only a dead `working` job goes through -the existing `checkVerb('respawn')` and `runVerb` guards. Dead `running` is -ambiguous, `blocked` requires intervention, and `done`, `failed` and `stopped` -have ended or been stopped: all require explicit Respawn through the existing -button. Unknown states or worker liveness refuse. Only the -restore check passes its classified worker evidence to `liveJobCheck`, keyed -by job id; Delete and ordinary Respawn retain their conservative defaults. Dead -non-working jobs offer explicit Respawn; unknown never respawns. A per-session promise +for terminal states too. Live attaches; dead `working`, `done` and `running` jobs go through +the existing `checkVerb('respawn')` and `runVerb` guards, preserving #796 recovery +after a reboot or daemon death. A live continuation prevents a duplicate respawn. +Dead `blocked`, `failed` and `stopped` jobs require explicit Respawn through the +existing button. Unknown states or worker liveness refuse. Restore and manual +Respawn pass their classified worker evidence to `liveJobCheck`, keyed by job id; +Delete retains its conservative default. Unknown never respawns. A per-session promise coalesces overlapping requests. Descriptor observation subscribes before its initial read, matches this job through `liveWorkerDescriptor`, waits at most 15 seconds and always releases the listener. A successful respawn without a @@ -97,9 +96,9 @@ M1 evidence (2026-10-25, CLI 2.1.302; untracked scratch notes in the primary checkout's `.work-files/m1-785-report.md`, not in the repository) measured dead `working` surviving daemon death, respawn cold-starting the daemon, retaining job/session/name, then producing a usable background descriptor. This implementation uses those recorded facts; -it does not repeat the live acceptance run. Issue #1020 explicitly retains -manual recovery for stopped jobs and extends it to every dead state other than -`working`. +it does not repeat the live acceptance run. The #1020 policy retains #796's +automatic recovery for dead `working`, `done` and `running`, with manual recovery +for dead `blocked`, `failed` and `stopped`. ## Invariants @@ -187,9 +186,12 @@ Stop or Close action. See `test/dom-agents-view.test.js` (#566/S5). check but before the CLI runs. This reduces stale-roster mistakes; it is not an atomic lock on the conversation. Respawn additionally scans fresh transcript continuations transitively, checking every reachable id through - `liveJobCheck`. A live continuation refuses both preview and execution, - including automatic restore and the handoff helper. Missing/unreadable - transcripts or unknown continuation ownership refuse; a 64-member bound + `liveJobCheck`, using the same worker classifier as restore for each reachable job + and excluding the job being respawned. Dead working/blocked forks do not count + as running. A live continuation refuses both preview and execution, + including automatic restore and the handoff helper. Missing transcripts with + known unowned continuations permit recovery. Unreadable existing transcripts + or unknown continuation liveness refuse with recovery guidance; a 64-member bound fails closed. This scan uses `resolveTranscriptPath`, independently of the cached continuation index. Fresh identity includes a validated raw `resumeSessionId` even when the job's transcript id differs. Bridge aliases @@ -1076,6 +1078,14 @@ The route runs under the existing tab input lock, followed by a bound-tab and eligibility recheck. The same command-parameterized composer/300 ms/Enter guard handles `/bg` and `/exit`. The respawn path waits up to 15 seconds for `session.exited`, then observes the fresh transcript for another 10 seconds. +Before writing either command, main checks existing continuations with worker +evidence and excludes the source job. A unique live fork with one matching bg +descriptor is adopted without writing or killing the source PTY; unknown or +ambiguous ownership refuses before `/exit`. At the 15-second exit timeout the +same 10-second continuation observation still runs. It can adopt a confirmed +fork even if the source has not exited. Once the original exit budget expires, +this path can only adopt or refuse; even a later source exit cannot authorize +respawn before its own post-exit grace. No exit budget was increased. The CLI fork link was measured at 5.5 seconds after `/exit` on 2026-11-07; the 10-second grace after detected exit allows that delay plus margin. Polling uses the injected clock and change waiter. A new continuation, excluding the @@ -1086,14 +1096,17 @@ Observed links remain held even if a later rewrite drops them. Only a complete final read with no new continuation permits the checked respawn. This is a bounded observation, not a CLI-level atomic writer lock; the shared guard re-reads continuations immediately before the CLI verb as another check. +The reader ignores an unparsable final unterminated line, retaining sealed +continuation records and reconsidering the tail when the file grows. A malformed +sealed continuation record still refuses. The helper exposed as `unsubscribeBgAgents.respawnForHandoff(job, extraSessionIds)` is shared with S1b's restore IPC: checked respawn, runVerb, then descriptor wait using the full job identity (including descriptors without jobId). S2 passes the source id and raw resumeTarget as extra ids; after exit, the verb checks their lowercased, deduplicated union with fresh check.sessionIds. The optional extra ids default to empty, preserving ordinary Agents-view Respawn/Delete -and S1b restore identity guards. Restore retains its action shapes and now holds -every dead state except `working` for explicit Respawn. +and S1b restore identity guards. Restore retains its action shapes and automatic +recovery for dead `working`, `done` and `running`; dead `blocked` waits for Respawn. Respawn success sets `backgrounded` and returns the same conversation id with `respawned:true` and `attached`. Main skips transition detection and @@ -1107,8 +1120,9 @@ failure uses `respawn-failed` and releases the held exit unsuppressed. The exit batch counts that row as started and leaves other unwritten rows eligible. The checked-refusal notice says the job was not relaunched; a verb failure or 60-second timeout says it may still start and directs the user to check Agents -before respawning again. An exit timeout uses `exit-timeout` and never -relaunches anything. No rename-back workaround or pending-restore trigger wait +before respawning again. An exit timeout without a confirmed continuation uses +`exit-timeout`, reports the continuation observation and never relaunches a live source. +No rename-back workaround or pending-restore trigger wait (S4) is part of this slice. diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e60d365..29b3495f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed -- Sending a resumed session to the background no longer leaves two copies of the conversation running. (#1020) +- Sending a resumed session to the background adopts its live continuation without restarting another copy. After a reboot or daemon death, working, done and running jobs still recover automatically when no live continuation holds them; blocked jobs wait for the notice's Respawn button. (#1020) - A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017) ## v0.0.103 — 2026-11-03 diff --git a/docs/background-agents.md b/docs/background-agents.md index e45926e96..ea3ae31a9 100644 --- a/docs/background-agents.md +++ b/docs/background-agents.md @@ -10,14 +10,16 @@ Switchboard, and acts on them without a terminal. ## Restoring saved background tabs After a restart or reboot, startup restore reopens only background jobs whose -attach tabs were saved, including tabs opened from the Agents view. A live worker is attached; a worker proven dead is -respawned as the same job and conversation, retaining its name, then attached. +attach tabs were saved, including tabs opened from the Agents view. A live worker is attached. A dead working, done or running job is +respawned as the same job and conversation, retaining its name, then attached, +unless a live continuation already holds the conversation. Automatic mode does this without a click, Ask waits for Restore, Restore on click waits for the saved row, and Don't restore starts nothing. A persistent Restore notice names the job and reason when its worker cannot be classified or a respawn is refused. Conflicting legacy records list their -ids. A stopped job offers **Respawn** and is never restarted automatically. +ids. Dead blocked, failed and stopped jobs offer **Respawn** and are never restarted automatically. +Respawn checks worker liveness and continuations again; a deleted, unowned continuation does not prevent recovery. **Respawned, not attached** means the job started but its live background descriptor did not appear within 15 seconds; **Attach** retries attachment without another respawn. An attach failure retains the CLI's message and diff --git a/docs/session-restore.md b/docs/session-restore.md index fb61b522d..c355d81fb 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -33,10 +33,10 @@ never added just because they exist in the Agents view. nothing. **Restore on click** waits until the saved row is opened. **Don't restore** starts nothing, including for the remembered active background tab. -A live worker is attached. A worker proven dead is respawned as the same job, -with the same conversation and name, then attached. Background restore never +A live worker is attached. A working, done or running job proven dead is respawned as the same job, +with the same conversation and name, then attached, unless a live continuation holds it. Background restore never opens an interactive `--resume` tab or sends `/bg`. A stopped job is not -respawned automatically: its notice offers **Respawn**, which checks that the +respawned automatically, and neither is a dead blocked or failed job: its notice offers **Respawn**, which checks that the conversation can be restarted before doing so. Unknown worker liveness, conflicting records and a refused diff --git a/main.js b/main.js index 20d017a82..dfd86b9be 100644 --- a/main.js +++ b/main.js @@ -1912,9 +1912,24 @@ async function handleSendToBackground(_event, sessionId) { kill: entry => killPty(entry, sessionId), decideRoute: async (id, entry) => { const lookup = bgAgents.findRestoreJob(id, undefined, { ignoreUnrelatedInvalid: true }); - if (!lookup.known || !lookup.job) return respawnRoute({ oldId: id, lookup }); - const check = await bgAgents.conversationCheck(lookup.job.id, { workerLiveness: 'all' }); - if (!check.known) return respawnRoute({ oldId: id, lookup, check }); + if (!lookup.known && !lookup.missing) return respawnRoute({ oldId: id, lookup }); + const check = lookup.job ? await bgAgents.conversationCheck(lookup.job.id, { workerLiveness: 'all' }) : null; + if (lookup.job && !check.known) return respawnRoute({ oldId: id, lookup, check }); + const continuations = new Map(); + for (const sid of new Set([id, lookup.job?.resumeTarget, ...(check?.continuationSessionIds || [])].filter(Boolean))) { + const continuation = await bgAgents.continuationJobCheck(sid, { + excludeJobId: lookup.job?.id, workerLiveness: new Map(lookup.job ? [[lookup.job.id, check.workerLiveness]] : []), + }); + if (!continuation.known) return { route: 'refuse', error: `Cannot check conversation continuations (${continuation.reason}). Check the Agents view, then retry.` }; + if (continuation.job) continuations.set(continuation.job.id, continuation.job); + } + if (continuations.size) { + const job = continuations.values().next().value; + const matches = cliSessionState.readAllDescriptors().filter(d => d.kind === 'bg' && d.jobId === job.id && d.sessionId.toLowerCase() === job.sessionId.toLowerCase()); + if (continuations.size === 1 && matches.length === 1) return { route: 'adopt', continuation: matches[0] }; + return { route: 'refuse', error: 'A continuation already runs in the background. Attach to it from the Agents view.' }; + } + if (!lookup.job) return respawnRoute({ oldId: id, lookup }); let owned = { known: true, job: null }; for (const sid of new Set([id, ...check.sessionIds].map(s => s.toLowerCase()))) { owned = bgAgents.liveJobCheck(sid, { workerLiveness: new Map([[lookup.job.id, check.workerLiveness]]) }); diff --git a/src/main/bg-agents/bg-agents-ipc.js b/src/main/bg-agents/bg-agents-ipc.js index f6c94f3cc..e58eb8d86 100644 --- a/src/main/bg-agents/bg-agents-ipc.js +++ b/src/main/bg-agents/bg-agents-ipc.js @@ -34,7 +34,7 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio if (verb === 'stop' && (!check.known || check.hosted)) return { ok: true }; if (!check.known) return { ok: false, error: `cannot tell whether this conversation is running (${check.reason}) — ${declined}` }; if (verb !== 'stop' && check.state === 'running' && check.workerLiveness === 'unknown') { - return { ok: false, error: `cannot tell whether this conversation's worker is running — ${declined}` }; + return { ok: false, error: `cannot tell whether this conversation's worker is running — ${declined}. Check the Agents view, then retry.` }; } if (check.hosted) { return { ok: false, error: `cannot ${action} a ${check.state} session; stop it first` }; @@ -46,7 +46,7 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio const refusal = await deleteSessionRefusal(sessionId, { activeSessions: verb === 'stop' ? new Map([...activeSessions || []].filter(([, session]) => !(session?.isAttach && String(session.attachJobId).toLowerCase() === id.toLowerCase()))) : activeSessions, - liveJobCheck: sid => bgAgents.liveJobCheck(sid, restoring + liveJobCheck: sid => bgAgents.liveJobCheck(sid, restoring || verb === 'respawn' ? { workerLiveness: new Map([[id, check.workerLiveness]]) } : undefined), describeLiveProcess: true, liveElsewhereChecked: sid => cliSessionState.liveElsewhereChecked(sid, sessionHasPty, ptyPids, { includeOwnProcesses: true }), }); @@ -56,9 +56,11 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio } if (verb === 'respawn') { for (const sessionId of new Set([...(check.continuationSessionIds || check.sessionIds), ...extraSessionIds].map(s => s.toLowerCase()))) { - const continuation = await bgAgents.continuationJobCheck(sessionId); - if (!continuation.known) return { ok: false, error: `cannot check conversation continuations (${continuation.reason}) — not respawned` }; - if (continuation.job) return { ok: false, error: `continuation job ${continuation.job.id} still runs this conversation — not respawned` }; + const continuation = await bgAgents.continuationJobCheck(sessionId, { + excludeJobId: id, workerLiveness: new Map([[id, check.workerLiveness]]), + }); + if (!continuation.known) return { ok: false, error: `cannot check conversation continuations (${continuation.reason}) — not respawned. Check the Agents view, then retry.` }; + if (continuation.job) return { ok: false, error: `continuation job ${continuation.job.id} still runs this conversation — not respawned. Attach to it or stop it from the Agents view, then retry.` }; } } } catch (err) { @@ -127,7 +129,7 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio if (check.workerLiveness !== 'live' && check.workerLiveness !== 'dead') return refuse('worker liveness unknown — not respawned'); if (check.workerLiveness === 'dead') { if (attachOnly) return refuse('worker is not live — not attached'); - if (check.state !== 'working') return refuse(check.state + ' job was not respawned automatically', 'respawn'); + if (!['working', 'done', 'running'].includes(check.state)) return refuse(check.state + ' job was not respawned automatically', 'respawn'); const result = await respawnForHandoff(job); if (!result.ok) return refuse(result.error); return result.attached ? { action: 'attach', sessionId, jobId: job.id } diff --git a/src/main/bg-agents/bg-agents.js b/src/main/bg-agents/bg-agents.js index 4d242096c..05769883e 100644 --- a/src/main/bg-agents/bg-agents.js +++ b/src/main/bg-agents/bg-agents.js @@ -399,24 +399,41 @@ async function conversationCheck(id, { workerLiveness = true } = {}) { hosted: isHostedJob(identified, liveDescriptors(), evidence) }; } -async function continuationJobCheck(sessionId) { - const pending = [sessionId], seen = new Set(); +async function continuationJobCheck(sessionId, { excludeJobId, workerLiveness = new Map() } = {}) { + const pending = [sessionId], seen = new Set(), owners = new Map(); + const evidence = new Map(workerLiveness); while (pending.length) { const id = pending.shift().toLowerCase(); if (seen.has(id)) continue; if (seen.size >= 64) return { known: false, reason: 'too many conversation continuations' }; seen.add(id); if (id !== sessionId.toLowerCase()) { - const owner = liveJobCheck(id); - if (!owner.known || owner.job) return owner; + let names; + try { names = fs.readdirSync(jobsDir); } + catch (err) { if (err.code !== 'ENOENT') return { known: false, reason: 'cannot read background jobs' }; names = []; } + for (const jobId of names.filter(name => JOB_ID_RE.test(name) && name !== excludeJobId)) { + let job; + try { job = parseJobState(fs.readFileSync(path.join(jobsDir, jobId, 'state.json'), 'utf8')); } catch {} + const sid = job?.sessionId || roster.find(entry => entry.id === jobId)?.sessionId; + if (sid?.toLowerCase() !== id) continue; + const liveness = await workerEvidence({ ...job, id: jobId, sessionId: sid }, true); + if (liveness !== 'live' && liveness !== 'dead') return { known: false, reason: 'worker liveness unknown for continuation job ' + jobId }; + evidence.set(jobId, liveness); + } + const owner = liveJobCheck(id, { workerLiveness: evidence, excludeJobId }); + if (!owner.known) return owner; + if (owner.job) owners.set(owner.job.id, { ...owner.job, sessionId: id }); } const file = resolveTranscriptPath?.(id); - if (!file) return { known: false, reason: 'continuation transcript unavailable for ' + id }; + if (!file) continue; + try { fs.statSync(file); } + catch (err) { if (err.code === 'ENOENT') continue; return { known: false, reason: 'cannot read continuation transcript for ' + id }; } const ids = await createContinuationReader(file, id)(); if (!ids) return { known: false, reason: 'cannot read conversation continuations for ' + id }; pending.push(...ids); } - return { known: true, job: null }; + if (owners.size > 1) return { known: false, reason: 'multiple live continuation jobs; check the Agents view' }; + return { known: true, job: owners.values().next().value || null }; } function findRestoreJob(sessionId, attachJobId, { ignoreUnrelatedInvalid = false } = {}) { @@ -531,7 +548,7 @@ async function dispatch(fields) { return { ok: true, id }; } -function liveJobCheck(sessionId, { workerLiveness = new Map() } = {}) { +function liveJobCheck(sessionId, { workerLiveness = new Map(), excludeJobId } = {}) { const key = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''; if (!key) return { known: true, job: null }; const sameSession = (sid) => typeof sid === 'string' && sid.toLowerCase() === key; @@ -540,13 +557,13 @@ function liveJobCheck(sessionId, { workerLiveness = new Map() } = {}) { let names; try { names = fs.readdirSync(jobsDir); } catch (err) { if (!(err && err.code === 'ENOENT')) return { known: false, reason: `cannot read ${jobsDir}: ${err && err.message}` }; - const cached = roster.find(e => isHostedJob(e, descriptors, workerLiveness.get(e.id)) && sameSession(e.sessionId)); + const cached = roster.find(e => e.id !== excludeJobId && isHostedJob(e, descriptors, workerLiveness.get(e.id)) && sameSession(e.sessionId)); return { known: true, job: cached ? { id: cached.id, state: cached.state } : null }; } let unknown = null; const read = new Set(); for (const id of names) { - if (!JOB_ID_RE.test(id)) continue; + if (!JOB_ID_RE.test(id) || id === excludeJobId) continue; let parsed; try { parsed = parseJobState(fs.readFileSync(path.join(jobsDir, id, 'state.json'), 'utf8')); } catch (err) { if (!(err && err.code === 'ENOENT')) unknown = unknown || `cannot read job ${id}`; @@ -560,7 +577,7 @@ function liveJobCheck(sessionId, { workerLiveness = new Map() } = {}) { if (!sid) { unknown = unknown || `live job ${id} does not name its session`; continue; } if (sameSession(sid)) return { known: true, job: { id, state: parsed.state } }; } - const cached = roster.find(e => !read.has(e.id) && isHostedJob(e, descriptors, workerLiveness.get(e.id)) && sameSession(e.sessionId)); + const cached = roster.find(e => e.id !== excludeJobId && !read.has(e.id) && isHostedJob(e, descriptors, workerLiveness.get(e.id)) && sameSession(e.sessionId)); if (cached) return { known: true, job: { id: cached.id, state: cached.state } }; return unknown ? { known: false, reason: unknown } : { known: true, job: null }; } diff --git a/src/main/bg-agents/bg-handoff.js b/src/main/bg-agents/bg-handoff.js index bf917d6af..854f83037 100644 --- a/src/main/bg-agents/bg-handoff.js +++ b/src/main/bg-agents/bg-handoff.js @@ -56,6 +56,10 @@ function createContinuationReader(file, id) { index = JSON.parse(serialized); if (index.bytes < index.size) await new Promise(resolve => setImmediate(resolve)); } while (index.bytes < index.size); + if (!index.complete && index.pending && !index.invalid) { + try { JSON.parse(Buffer.from(index.pending, 'base64').toString('utf8')); } + catch { return index.sealedIds; } + } return index.complete && !index.unresolved ? index.ids : null; } catch { serialized = previous = undefined; return null; } }; @@ -97,9 +101,10 @@ async function respawnHandoff(session, sessionId, oldId, job, previous, deps) { const exitTimeoutMs = deps.exitTimeoutMs ?? BG_HANDOFF_EXIT_TIMEOUT_MS; const exitBy = deps.now() + exitTimeoutMs; while (!session.exited) { - if (deps.now() >= exitBy) return { ok: false, code: 'exit-timeout', jobId, error: 'Sent /exit, but the terminal did not exit within ' + (exitTimeoutMs / 1000) + ' s; nothing was relaunched. Check the terminal.' }; + if (deps.now() >= exitBy) break; await deps.waitForChange(Math.max(0, Math.min(100, exitBy - deps.now()))); } + const exitTimedOut = !session.exited; const continuationBy = deps.now() + (deps.continuationWaitMs ?? BG_RESPAWN_CONTINUATION_WAIT_MS); let uncertain = true; const newIds = new Set(); @@ -122,6 +127,8 @@ async function respawnHandoff(session, sessionId, oldId, job, previous, deps) { if (deps.now() >= continuationBy) break; await deps.waitForChange(Math.min(100, continuationBy - deps.now())); } + if (exitTimedOut || !session.exited) return { ok: false, code: 'exit-timeout', jobId, + error: 'Sent /exit, but the terminal did not exit within ' + (exitTimeoutMs / 1000) + ' s. No continuation was confirmed during the following 10 s; nothing was relaunched. Check the terminal and the Agents view.' }; if (uncertain || newIds.size) return { ok: false, code: 'respawn-failed', jobId, error: 'The terminal exited, but its continuation could not be confirmed. The job was not respawned. Check the Agents view or open the conversation from the sidebar before retrying.' }; let result; @@ -166,6 +173,14 @@ async function sendToBackground(sessionId, deps) { const afterRoute = backgroundRefusal({ ...session, backgrounding: false }, deps.getStatus(oldId)); if (afterRoute || !bound() || deps.now() >= deadline) return { ok: false, code: 'refused', error: afterRoute || 'The tab changed or its handoff deadline passed. Check the terminal, then try again.' }; if (route.route === 'refuse') return { ok: false, code: 'refused', error: route.error }; + if (route.route === 'adopt') { + const continuation = route.continuation; + session.backgrounded = true; + await releaseWindowsPty(session.pty); + deps.onConfirmed?.(continuation); + return { ok: true, oldSessionId: sessionId, sessionId: continuation.sessionId, + jobId: continuation.jobId, cwd: continuation.cwd || session.cwd }; + } const command = route.route === 'respawn' ? '/exit' : '/bg'; dispose = deps.tapOutput(session, text => { output = (output + text).slice(-16384); }); const composer = session.composerState; diff --git a/test/bg-agents-ipc.test.js b/test/bg-agents-ipc.test.js index a490a9d15..505f7a208 100644 --- a/test/bg-agents-ipc.test.js +++ b/test/bg-agents-ipc.test.js @@ -73,12 +73,12 @@ for (const state of ['working', 'blocked', 'running', 'done', 'failed', 'stopped test(`B main restore ${state}/${liveness} follows the classifier and checked verb`, async t => { const h = restoreFixture(t, { state, liveness, descriptors: [{ kind: 'bg', jobId: 'aaaaaaaa', sessionId: 's1' }] }); const result = await h.restore(); - const shouldRespawn = liveness === 'dead' && state === 'working'; + const shouldRespawn = liveness === 'dead' && ['working', 'running', 'done'].includes(state); const shouldAttach = liveness === 'live' || shouldRespawn; assert.equal(result.action, shouldAttach ? 'attach' : 'refuse'); assert.deepEqual(h.seen.filter(call => call[0] === 'respawn'), shouldRespawn ? [['respawn', 'aaaaaaaa']] : []); assert.ok(h.seen.some(call => call[0] === 'check' && call[1]?.workerLiveness === 'all'), 'S1a classifies every restore state'); - if (state !== 'working' && liveness === 'dead') assert.equal(result.recovery, 'respawn'); + if (!shouldRespawn && liveness === 'dead') assert.equal(result.recovery, 'respawn'); if (!shouldAttach) assert.match(result.reason, new RegExp(`aaaaaaaa.*${state}`)); }); } diff --git a/test/bg-agents-restore.test.js b/test/bg-agents-restore.test.js index 8315d5a00..c8ce914b6 100644 --- a/test/bg-agents-restore.test.js +++ b/test/bg-agents-restore.test.js @@ -162,10 +162,8 @@ for (const state of ['working', 'blocked']) { cliSessionState: { readAllDescriptors: () => descriptors, onDescriptorsChanged: () => () => {}, liveElsewhereChecked: async () => ({ known: true, live: null }) }, }); - for (const verb of ['rm', 'respawn']) { - const ordinary = await handlers.get('bg-agent-verb')({}, verb, 'aaaaaaaa'); - assert.equal(ordinary.ok, false, `GUARD ordinary ${verb} retains no-evidence refusal`); - } + const ordinary = await handlers.get('bg-agent-verb')({}, 'rm', 'aaaaaaaa'); + assert.equal(ordinary.ok, false, 'GUARD ordinary rm retains no-evidence refusal'); assert.deepEqual(calls, []); const result = await handlers.get('restore-background-job')({}, SID); assert.deepEqual(calls, state === 'working' ? [['respawn', 'aaaaaaaa']] : []); diff --git a/test/bg-duplicate-1020.test.js b/test/bg-duplicate-1020.test.js index ca633d650..14fefc3a1 100644 --- a/test/bg-duplicate-1020.test.js +++ b/test/bg-duplicate-1020.test.js @@ -40,12 +40,12 @@ function fixture(t, state = 'stopped') { writeJob('bbbbbbbb', NEXT, 'working'); descriptors.push({ kind: 'bg', sessionId: NEXT, jobId: 'bbbbbbbb', cwd: root }); }; - const calls = [], handlers = new Map(); + const calls = [], handlers = new Map(), delivery = { autoAttach: false }; init({ ipcMain: { handle: (name, fn) => handlers.set(name, fn) }, - bgAgents: { ...bg, runVerb: async (...args) => { calls.push(args); return { ok: true }; } }, + bgAgents: { ...bg, runVerb: async (...args) => { calls.push(args); if (delivery.autoAttach) descriptors.push({ kind: 'bg', jobId: 'aaaaaaaa', sessionId: bg.findRestoreJob(SID, 'aaaaaaaa').job.sessionId }); return { ok: true }; } }, cliSessionState: cli, getMainWindow: () => null, log: { warn() {} }, activeSessions: new Map(), sessionHasPty: () => false, ptyPids: () => [], getSetting: () => ({ openWorkingSet: [{ sessionId: SID, bg: true, attachJobId: 'aaaaaaaa' }] }) }); - return { root, file, descriptors, fork, calls, handlers }; + return { root, file, descriptors, fork, calls, handlers, writeJob, cli, delivery }; } function handoff(h, { exitAt = 600, forkAt = Infinity, uncertain = false, descriptor = true, prior = false } = {}) { @@ -127,7 +127,7 @@ test('1020 restore refuses a live continuation of a dead working job', async t = assert.equal(result.action, 'refuse'); }); -for (const state of ['blocked', 'running', 'done', 'failed']) { +for (const state of ['blocked', 'failed']) { test('1020 dead ' + state + ' needs explicit Respawn', async t => { const h = fixture(t, state); const result = await h.handlers.get('restore-background-job')({}, SID); @@ -136,3 +136,93 @@ for (const state of ['blocked', 'running', 'done', 'failed']) { assert.equal(result.recovery, 'respawn'); }); } + +for (const state of ['working', 'running', 'done']) { + test('1020fix restore respawns and attaches dead ' + state, async t => { + const h = fixture(t, state); + h.delivery.autoAttach = true; + const result = await h.handlers.get('restore-background-job')({}, SID); + assert.equal(result.action, 'attach', 'restore must retain #796 recovery'); + assert.deepEqual(h.calls, [['respawn', 'aaaaaaaa']]); + }); +} + +for (const state of ['blocked', 'working', 'running', 'done', 'failed', 'stopped']) { + test('1020fix dead ' + state + ' can use manual Respawn after restore', async t => { + const h = fixture(t, state); + if (state === 'blocked') { + const notice = await h.handlers.get('restore-background-job')({}, SID); + assert.equal(notice.recovery, 'respawn'); + assert.deepEqual(h.calls, []); + } + assert.equal((await h.handlers.get('check-bg-agent-verb')({}, 'respawn', 'aaaaaaaa')).ok, true, 'Respawn preview must accept dead workers'); + assert.equal((await h.handlers.get('bg-agent-verb')({}, 'respawn', 'aaaaaaaa')).ok, true, 'the notice recovery must reach respawn'); + assert.deepEqual(h.calls, [['respawn', 'aaaaaaaa']]); + }); +} + +for (const tail of ['{"type":"assistant","mess', '{"type":"continued-in","sessionId":"' + SID + '","continuedInSessionId":"']) { + test('1020fix torn final line permits manual recovery ' + tail, async t => { + const h = fixture(t, 'done'); + fs.appendFileSync(h.file, tail); + assert.deepEqual(await createContinuationReader(h.file, SID)(), [], 'an incomplete final record cannot invalidate sealed evidence'); + assert.equal((await h.handlers.get('bg-agent-verb')({}, 'respawn', 'aaaaaaaa')).ok, true); + assert.deepEqual(h.calls, [['respawn', 'aaaaaaaa']]); + }); +} + +for (const missing of ['resolved-path', 'dangling-link']) { + test('1020fix deleted continuation permits recovery ' + missing, async t => { + const h = fixture(t); + fs.appendFileSync(h.file, link(NEXT)); + if (missing === 'dangling-link') bg.init({ jobsDir: path.join(h.root, 'jobs'), homeDir: h.root, cliSessionState: h.cli, + resolveTranscriptPath: id => id === NEXT ? null : h.file }); + assert.equal((await h.handlers.get('bg-agent-verb')({}, 'respawn', 'aaaaaaaa')).ok, true, 'missing unowned continuation must permit recovery'); + assert.deepEqual(h.calls, [['respawn', 'aaaaaaaa']]); + }); +} + +for (const state of ['working', 'blocked']) { + test('1020fix dead ' + state + ' fork does not block manual Respawn', async t => { + const h = fixture(t); + h.fork(); h.descriptors.length = 0; + h.writeJob('bbbbbbbb', NEXT, state); + assert.equal((await h.handlers.get('bg-agent-verb')({}, 'respawn', 'aaaaaaaa')).ok, true, 'dead forks are not running'); + assert.deepEqual(h.calls, [['respawn', 'aaaaaaaa']]); + }); +} + +test('1020fix dead fork does not refuse its own resume ancestry', async t => { + const h = fixture(t, 'working'); + fs.appendFileSync(h.file, link(NEXT)); + fs.writeFileSync(path.join(h.root, NEXT + '.jsonl'), line({ type: 'user', sessionId: NEXT })); + fs.writeFileSync(path.join(h.root, 'jobs', 'aaaaaaaa', 'state.json'), line({ state: 'working', sessionId: NEXT, resumeSessionId: SID, + linkScanPath: path.join(h.root, NEXT + '.jsonl') })); + h.delivery.autoAttach = true; + const result = await h.handlers.get('restore-background-job')({}, SID); + assert.equal(result.action, 'attach', 'a dead job must not block itself through A -> B'); + assert.deepEqual(h.calls, [['respawn', 'aaaaaaaa']]); +}); + +test('1020fix blocked restore notice Respawn click reaches the verb and attaches', async t => { + const h = fixture(t, 'blocked'); + h.delivery.autoAttach = true; + let notice; + const opened = []; + const ctx = require('node:vm').createContext({ + window: { api: { + restoreBackgroundJob: (sid, options) => h.handlers.get('restore-background-job')({}, sid, options), + bgAgentVerb: (verb, id) => h.handlers.get('bg-agent-verb')({}, verb, id), + getSessionLiveElsewhere: async () => h.descriptors[0], + } }, + sessionActionNoticeId: () => 'restore', sessionActionDescription: () => 'fixture', + showAppNotice: value => { notice = value; }, openSession: async (...args) => opened.push(args), + }); + const { prepareBackgroundRestore } = require('./app-source').loadAppFunctions(ctx, { functions: ['prepareBackgroundRestore'] }); + await prepareBackgroundRestore({ sessionId: SID }, { sessionId: SID }); + assert.equal(notice.actionLabel, 'Respawn'); + await notice.onAction(); + assert.deepEqual(h.calls, [['respawn', 'aaaaaaaa']], 'clicking the notice must invoke respawn'); + assert.equal(opened.length, 1, 'recovery attaches the resulting live fork'); + assert.equal(opened[0][2].backgroundRestoreJobId, 'aaaaaaaa'); +}); diff --git a/test/bg-handoff-main.test.js b/test/bg-handoff-main.test.js index 22de17124..749b734f3 100644 --- a/test/bg-handoff-main.test.js +++ b/test/bg-handoff-main.test.js @@ -107,7 +107,8 @@ function ipcSetup(t, { status = 'idle', sessionCache, writer, onTransitions = () }, log: { warn: (...args) => warned.push(args.join(' ')) }, sessionHasPty: () => false, ptyPids: () => [], - bgAgents: { findRestoreJob: () => ({ known: false, missing: true, reason: 'no background job record' }) }, + bgAgents: { findRestoreJob: () => ({ known: false, missing: true, reason: 'no background job record' }), + continuationJobCheck: async () => ({ known: true, job: null }) }, activeSessions: new Map([['tab', session]]), mainWindow: { isDestroyed: () => false, webContents: { send() {} } }, writePty: (entry, text) => { @@ -371,6 +372,7 @@ test('S2-T9 shipped respawn handler releases the real PTY exit without persistin findRestoreJob: () => ({ known: true, job: { id: 'aaaaaaaa', resumeTarget: 'old', sessionId: 'OLD' } }), conversationCheck: async () => ({ known: true, state: 'stopped', workerLiveness: 'dead', sessionIds: ['OLD'] }), liveJobCheck: () => ({ known: true, job: null }), + continuationJobCheck: async () => ({ known: true, job: null }), }, unsubscribeBgAgents: { respawnForHandoff: async job => { calls.push(job.id); assert.equal(h.session.exited, true); return { ok: true, attached: true }; } }, detectSessionTransitions: () => assert.fail('respawn must not detect a new continuation'), diff --git a/test/bg-handoff.test.js b/test/bg-handoff.test.js index cf30e9d0f..018315ef5 100644 --- a/test/bg-handoff.test.js +++ b/test/bg-handoff.test.js @@ -457,13 +457,13 @@ test('S2-T3 refused route preserves reason and writes nothing', async () => { }); test('S2-T4 source that never exits times out without respawn or kill', async () => { - const h = setup(); + const h = setup({ ids: [], descriptors: [] }); h.deps.decideRoute = async () => ({ route: 'respawn', job: { id: 'aaaaaaaa' } }); const calls = []; h.deps.respawnJob = async job => { calls.push(job); return { ok: true }; }; const result = await sendToBackground('old', h.deps); assert.equal(result.code, 'exit-timeout'); - assert.equal(h.deps.now(), 600); + assert.equal(h.deps.now(), 10600); assert.deepEqual(h.writes, ['/exit', '\r']); assert.deepEqual(calls, []); assert.deepEqual(h.kills, []); diff --git a/test/bg-recovery-1020fix.test.js b/test/bg-recovery-1020fix.test.js new file mode 100644 index 000000000..804f966c6 --- /dev/null +++ b/test/bg-recovery-1020fix.test.js @@ -0,0 +1,111 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const { loadAppFunctions } = require('./app-source'); +const bg = require('../src/main/bg-agents/bg-agents'); +const handoff = require('../src/main/bg-agents/bg-handoff'); +const SID = '11111111-1111-4111-8111-111111111111'; +const NEXT = '22222222-2222-4222-8222-222222222222'; +const line = value => JSON.stringify(value) + '\n'; + +function setup(t, { forkAt = 0, exitAt = Infinity, descriptor = true, unknown = false } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-main-1020fix-')); + const jobsDir = path.join(root, 'jobs'); + fs.mkdirSync(jobsDir); fs.mkdirSync(path.join(root, 'folder')); fs.mkdirSync(path.join(root, 'daemon')); + fs.writeFileSync(path.join(root, 'daemon', 'roster.json'), line({ proto: 1, updatedAt: '2000-01-01T00:00:00Z', workers: {} })); + const writeJob = (id, sessionId, state) => { + fs.mkdirSync(path.join(jobsDir, id)); + fs.writeFileSync(path.join(jobsDir, id, 'state.json'), line({ state, sessionId, resumeSessionId: sessionId })); + }; + writeJob('aaaaaaaa', SID, 'stopped'); + const file = path.join(root, 'folder', SID + '.jsonl'); + fs.writeFileSync(file, line({ type: 'user', sessionId: SID })); + let now = 0, forked = false; + const descriptors = [], writes = [], respawns = [], kills = []; + const session = { kind: 'local-pty', realSessionId: SID, projectFolder: 'folder', cwd: root, + composerState: require('../src/main/terminals/composer-state').createComposerState(), pty: { pid: 4242, onData: () => ({ dispose() {} }) } }; + const advance = async ms => { + now += ms; + if (!forked && now >= forkAt) { + forked = true; + fs.appendFileSync(file, line({ type: 'continued-in', sessionId: SID, continuedInSessionId: NEXT })); + fs.writeFileSync(path.join(root, 'folder', NEXT + '.jsonl'), line({ type: 'user', sessionId: NEXT })); + writeJob('bbbbbbbb', NEXT, 'working'); + if (descriptor) descriptors.push({ kind: 'bg', jobId: 'bbbbbbbb', sessionId: NEXT, cwd: root }); + } + if (now >= exitAt) session.exited = true; + }; + const cli = { getStatus: () => ({ status: 'idle' }), readAllDescriptors: () => descriptors, + onDescriptorsChanged: () => () => {}, liveElsewhereChecked: async () => ({ known: true, live: null }), + refreshProcessTable: async () => {}, readWorkerProcessEvidence: () => ({ processTableFresh: !unknown, tableEntry: () => undefined }) }; + bg.init({ jobsDir, homeDir: root, cliSessionState: cli, resolveTranscriptPath: id => path.join(root, 'folder', id + '.jsonl') }); + t.after(() => { bg.stop(); fs.rmSync(root, { recursive: true, force: true }); }); + const ctx = vm.createContext({ fs, path, require: createRequire(path.join(__dirname, '../main.js')), PROJECTS_DIR: root, + Date: { now: () => now }, Promise, setTimeout, clearTimeout, + activeSessions: new Map([['tab', session]]), cliSessionState: cli, bgAgents: bg, + sessionHasPty: () => false, ptyPids: () => [], mainWindow: null, log: { warn() {} }, + writePty: (_, text) => { writes.push(text); return true; }, killPty: () => kills.push(true), + detectSessionTransitions() {}, sessionCache: { resolveSessionContinuations: async () => ({}) }, + writer: { refreshFolder: async () => {} }, notifyRendererProjectsChanged() {}, + unsubscribeBgAgents: { respawnForHandoff: async () => { respawns.push(true); return { ok: true, attached: true }; } }, + ...handoff, + sendToBackground: (id, deps) => handoff.sendToBackground(id, { ...deps, delay: advance, waitForChange: advance }), + }); + const { handleSendToBackground } = loadAppFunctions(ctx, { declarations: ['backgroundHandoffs'], + functions: ['handleSendToBackground', 'persistBackgroundHandoff', 'readBackgroundHandoffStamp'], sourcePath: path.join(__dirname, '../main.js') }); + return { send: async () => { await advance(0); return handleSendToBackground({}, 'tab'); }, writes, respawns, kills, session, time: () => now }; +} + +test('1020fix shipped handler adopts an earlier live fork before typing exit', async t => { + const h = setup(t); + const result = await h.send(); + assert.equal(result.ok, true, 'an earlier live continuation must be adopted'); + assert.equal(result.sessionId, NEXT); + assert.equal(result.jobId, 'bbbbbbbb'); + assert.notEqual(result.respawned, true); + assert.deepEqual(h.writes, [], 'adoption must leave the source terminal untouched'); + assert.deepEqual(h.respawns, []); + assert.deepEqual(h.kills, []); +}); + +test('1020fix shipped handler refuses unknown continuation liveness before typing exit', async t => { + const h = setup(t, { descriptor: false, unknown: true }); + const result = await h.send(); + assert.deepEqual(h.writes, [], 'unknown continuation ownership must not close the tab'); + assert.equal(result.ok, false); + assert.match(result.error, /check|retry|try again/i); + assert.deepEqual(h.respawns, []); +}); + +test('1020fix shipped handler adopts a fork at the exit timeout without respawning', async t => { + const h = setup(t, { forkAt: 15400 }); + const result = await h.send(); + assert.equal(result.ok, true, 'exit timeout must still observe the continuation'); + assert.equal(result.sessionId, NEXT); + assert.equal(result.jobId, 'bbbbbbbb'); + assert.deepEqual(h.writes, ['/exit', '\r']); + assert.deepEqual(h.respawns, []); + assert.deepEqual(h.kills, []); + assert.ok(h.time() >= 15400 && h.time() <= 25300, 'keep the 15 second exit budget and 10 second observation bound'); +}); + +test('1020fix exit timeout without a fork never respawns the live source', async t => { + const h = setup(t, { forkAt: Infinity }); + const result = await h.send(); + assert.equal(result.code, 'exit-timeout'); + assert.deepEqual(h.respawns, []); + assert.deepEqual(h.kills, []); +}); + +test('1020fix an exit after the budget cannot authorize respawn before a delayed fork', async t => { + const h = setup(t, { exitAt: 20000, forkAt: 25500 }); + const result = await h.send(); + assert.deepEqual(h.respawns, [], 'an exit after the budget must not authorize an early respawn'); + assert.equal(result.code, 'exit-timeout'); + assert.deepEqual(h.kills, []); +}); From 1e74d3aca96898cd0a1a62bd919cd3ca5686914f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 07:58:28 +0100 Subject: [PATCH 4/7] (bg-agents): refuse a live continuation and keep a half-written exit record from respawning A conversation already running in the background is refused instead of adopted; an unterminated continued-in record no longer counts as proof, and dead failed jobs restore automatically like done and running ones. --- .ai/contexts/bg-agents.md | 27 +++++++++++++--------- CHANGELOG.md | 2 +- docs/background-agents.md | 7 ++++-- docs/session-restore.md | 4 ++-- main.js | 5 +--- src/main/bg-agents/bg-agents-ipc.js | 2 +- src/main/bg-agents/bg-agents.js | 1 + src/main/bg-agents/bg-handoff.js | 10 +------- test/bg-agents-ipc.test.js | 2 +- test/bg-duplicate-1020.test.js | 36 ++++++++++++++++++++++++++--- test/bg-recovery-1020fix.test.js | 16 +++++++------ 11 files changed, 71 insertions(+), 41 deletions(-) diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index 39a004c91..f6d959555 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -50,10 +50,10 @@ every matching id. A missing record releases the held entry, with a notice; a later sidebar click follows the ordinary guarded resume path. `conversationCheck(id, { workerLiveness: 'all' })` requests S1a's classifier -for terminal states too. Live attaches; dead `working`, `done` and `running` jobs go through +for terminal states too. Live attaches; dead `working`, `done`, `running` and `failed` jobs go through the existing `checkVerb('respawn')` and `runVerb` guards, preserving #796 recovery after a reboot or daemon death. A live continuation prevents a duplicate respawn. -Dead `blocked`, `failed` and `stopped` jobs require explicit Respawn through the +Dead `blocked` and `stopped` jobs require explicit Respawn through the existing button. Unknown states or worker liveness refuse. Restore and manual Respawn pass their classified worker evidence to `liveJobCheck`, keyed by job id; Delete retains its conservative default. Unknown never respawns. A per-session promise @@ -97,8 +97,8 @@ primary checkout's `.work-files/m1-785-report.md`, not in the repository) measur respawn cold-starting the daemon, retaining job/session/name, then producing a usable background descriptor. This implementation uses those recorded facts; it does not repeat the live acceptance run. The #1020 policy retains #796's -automatic recovery for dead `working`, `done` and `running`, with manual recovery -for dead `blocked`, `failed` and `stopped`. +automatic recovery for dead `working`, `done`, `running` and `failed`, with manual recovery +for dead `blocked` and `stopped`. ## Invariants @@ -1079,9 +1079,12 @@ eligibility recheck. The same command-parameterized composer/300 ms/Enter guard handles `/bg` and `/exit`. The respawn path waits up to 15 seconds for `session.exited`, then observes the fresh transcript for another 10 seconds. Before writing either command, main checks existing continuations with worker -evidence and excludes the source job. A unique live fork with one matching bg -descriptor is adopted without writing or killing the source PTY; unknown or -ambiguous ownership refuses before `/exit`. At the 15-second exit timeout the +evidence and excludes the source job. An existing live continuation refuses +with a short notice directing the user to the sidebar, leaving the tab and +foreground CLI untouched. Unknown or ambiguous ownership also refuses before +`/exit`. Stopped and failed continuation jobs cannot be hosted and skip worker +liveness sampling; they do not prevent manual Respawn while the daemon runs. +At the 15-second exit timeout the same 10-second continuation observation still runs. It can adopt a confirmed fork even if the source has not exited. Once the original exit budget expires, this path can only adopt or refuse; even a later source exit cannot authorize @@ -1096,9 +1099,11 @@ Observed links remain held even if a later rewrite drops them. Only a complete final read with no new continuation permits the checked respawn. This is a bounded observation, not a CLI-level atomic writer lock; the shared guard re-reads continuations immediately before the CLI verb as another check. -The reader ignores an unparsable final unterminated line, retaining sealed -continuation records and reconsidering the tail when the file grows. A malformed -sealed continuation record still refuses. +The reader ignores an unparsable final unterminated ordinary line, retaining +sealed continuation records and reconsidering the tail when the file grows. +An unresolved `continued-in` tail returns uncertainty and prevents respawn; +the same reader recognizes the continuation once the record is completed. +A malformed sealed continuation record still refuses. The helper exposed as `unsubscribeBgAgents.respawnForHandoff(job, extraSessionIds)` is shared with S1b's restore IPC: checked respawn, runVerb, then descriptor wait using the full job identity (including descriptors without jobId). S2 passes @@ -1106,7 +1111,7 @@ the source id and raw resumeTarget as extra ids; after exit, the verb checks their lowercased, deduplicated union with fresh check.sessionIds. The optional extra ids default to empty, preserving ordinary Agents-view Respawn/Delete and S1b restore identity guards. Restore retains its action shapes and automatic -recovery for dead `working`, `done` and `running`; dead `blocked` waits for Respawn. +recovery for dead `working`, `done`, `running` and `failed`; dead `blocked` waits for Respawn. Respawn success sets `backgrounded` and returns the same conversation id with `respawned:true` and `attached`. Main skips transition detection and diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b3495f5..2ace82818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed -- Sending a resumed session to the background adopts its live continuation without restarting another copy. After a reboot or daemon death, working, done and running jobs still recover automatically when no live continuation holds them; blocked jobs wait for the notice's Respawn button. (#1020) +- Sending a resumed session to the background leaves its tab untouched when the conversation already runs in the background, with a message directing you to the sidebar. After a reboot or daemon death, working, done, running and failed jobs recover automatically when no live continuation holds them; blocked jobs wait for the notice's Respawn button. (#1020) - A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017) ## v0.0.103 — 2026-11-03 diff --git a/docs/background-agents.md b/docs/background-agents.md index ea3ae31a9..5beede924 100644 --- a/docs/background-agents.md +++ b/docs/background-agents.md @@ -10,7 +10,7 @@ Switchboard, and acts on them without a terminal. ## Restoring saved background tabs After a restart or reboot, startup restore reopens only background jobs whose -attach tabs were saved, including tabs opened from the Agents view. A live worker is attached. A dead working, done or running job is +attach tabs were saved, including tabs opened from the Agents view. A live worker is attached. A dead working, done, running or failed job is respawned as the same job and conversation, retaining its name, then attached, unless a live continuation already holds the conversation. Automatic mode does this without a click, Ask waits for Restore, Restore on @@ -18,7 +18,7 @@ click waits for the saved row, and Don't restore starts nothing. A persistent Restore notice names the job and reason when its worker cannot be classified or a respawn is refused. Conflicting legacy records list their -ids. Dead blocked, failed and stopped jobs offer **Respawn** and are never restarted automatically. +ids. Dead blocked and stopped jobs offer **Respawn** and are never restarted automatically. Respawn checks worker liveness and continuations again; a deleted, unowned continuation does not prevent recovery. **Respawned, not attached** means the job started but its live background descriptor did not appear within 15 seconds; **Attach** retries attachment @@ -222,6 +222,9 @@ Switchboard asks before sending: Claude may stop them during the move. If Claude asks for confirmation in the terminal, answer Background or Stay there; Switchboard does not answer that dialog for you. +If a live continuation already runs in the background, Switchboard refuses the +move and asks you to open it from the sidebar, leaving this tab and its CLI untouched. + Before typing, Switchboard refuses an ambiguous or unreadable job lookup, a job that would resume another conversation, an unknown conversation or job owner, a worker that is live (or may be live in a running, working or blocked diff --git a/docs/session-restore.md b/docs/session-restore.md index c355d81fb..0b643ca19 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -33,10 +33,10 @@ never added just because they exist in the Agents view. nothing. **Restore on click** waits until the saved row is opened. **Don't restore** starts nothing, including for the remembered active background tab. -A live worker is attached. A working, done or running job proven dead is respawned as the same job, +A live worker is attached. A working, done, running or failed job proven dead is respawned as the same job, with the same conversation and name, then attached, unless a live continuation holds it. Background restore never opens an interactive `--resume` tab or sends `/bg`. A stopped job is not -respawned automatically, and neither is a dead blocked or failed job: its notice offers **Respawn**, which checks that the +respawned automatically, and neither is a dead blocked job: its notice offers **Respawn**, which checks that the conversation can be restarted before doing so. Unknown worker liveness, conflicting records and a refused diff --git a/main.js b/main.js index 1696d1258..d37e7aec8 100644 --- a/main.js +++ b/main.js @@ -1937,10 +1937,7 @@ async function handleSendToBackground(_event, sessionId) { if (continuation.job) continuations.set(continuation.job.id, continuation.job); } if (continuations.size) { - const job = continuations.values().next().value; - const matches = cliSessionState.readAllDescriptors().filter(d => d.kind === 'bg' && d.jobId === job.id && d.sessionId.toLowerCase() === job.sessionId.toLowerCase()); - if (continuations.size === 1 && matches.length === 1) return { route: 'adopt', continuation: matches[0] }; - return { route: 'refuse', error: 'A continuation already runs in the background. Attach to it from the Agents view.' }; + return { route: 'refuse', error: 'This conversation already runs in the background; open it from the sidebar.' }; } if (!lookup.job) return respawnRoute({ oldId: id, lookup }); let owned = { known: true, job: null }; diff --git a/src/main/bg-agents/bg-agents-ipc.js b/src/main/bg-agents/bg-agents-ipc.js index e58eb8d86..7b20f6c86 100644 --- a/src/main/bg-agents/bg-agents-ipc.js +++ b/src/main/bg-agents/bg-agents-ipc.js @@ -129,7 +129,7 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio if (check.workerLiveness !== 'live' && check.workerLiveness !== 'dead') return refuse('worker liveness unknown — not respawned'); if (check.workerLiveness === 'dead') { if (attachOnly) return refuse('worker is not live — not attached'); - if (!['working', 'done', 'running'].includes(check.state)) return refuse(check.state + ' job was not respawned automatically', 'respawn'); + if (!['working', 'done', 'running', 'failed'].includes(check.state)) return refuse(check.state + ' job was not respawned automatically', 'respawn'); const result = await respawnForHandoff(job); if (!result.ok) return refuse(result.error); return result.attached ? { action: 'attach', sessionId, jobId: job.id } diff --git a/src/main/bg-agents/bg-agents.js b/src/main/bg-agents/bg-agents.js index 05769883e..d9bdb1eb6 100644 --- a/src/main/bg-agents/bg-agents.js +++ b/src/main/bg-agents/bg-agents.js @@ -416,6 +416,7 @@ async function continuationJobCheck(sessionId, { excludeJobId, workerLiveness = try { job = parseJobState(fs.readFileSync(path.join(jobsDir, jobId, 'state.json'), 'utf8')); } catch {} const sid = job?.sessionId || roster.find(entry => entry.id === jobId)?.sessionId; if (sid?.toLowerCase() !== id) continue; + if (['stopped', 'failed'].includes(job?.state)) continue; const liveness = await workerEvidence({ ...job, id: jobId, sessionId: sid }, true); if (liveness !== 'live' && liveness !== 'dead') return { known: false, reason: 'worker liveness unknown for continuation job ' + jobId }; evidence.set(jobId, liveness); diff --git a/src/main/bg-agents/bg-handoff.js b/src/main/bg-agents/bg-handoff.js index 854f83037..d5aeb483c 100644 --- a/src/main/bg-agents/bg-handoff.js +++ b/src/main/bg-agents/bg-handoff.js @@ -56,7 +56,7 @@ function createContinuationReader(file, id) { index = JSON.parse(serialized); if (index.bytes < index.size) await new Promise(resolve => setImmediate(resolve)); } while (index.bytes < index.size); - if (!index.complete && index.pending && !index.invalid) { + if (!index.complete && index.pending && !index.invalid && !index.unresolved) { try { JSON.parse(Buffer.from(index.pending, 'base64').toString('utf8')); } catch { return index.sealedIds; } } @@ -173,14 +173,6 @@ async function sendToBackground(sessionId, deps) { const afterRoute = backgroundRefusal({ ...session, backgrounding: false }, deps.getStatus(oldId)); if (afterRoute || !bound() || deps.now() >= deadline) return { ok: false, code: 'refused', error: afterRoute || 'The tab changed or its handoff deadline passed. Check the terminal, then try again.' }; if (route.route === 'refuse') return { ok: false, code: 'refused', error: route.error }; - if (route.route === 'adopt') { - const continuation = route.continuation; - session.backgrounded = true; - await releaseWindowsPty(session.pty); - deps.onConfirmed?.(continuation); - return { ok: true, oldSessionId: sessionId, sessionId: continuation.sessionId, - jobId: continuation.jobId, cwd: continuation.cwd || session.cwd }; - } const command = route.route === 'respawn' ? '/exit' : '/bg'; dispose = deps.tapOutput(session, text => { output = (output + text).slice(-16384); }); const composer = session.composerState; diff --git a/test/bg-agents-ipc.test.js b/test/bg-agents-ipc.test.js index 505f7a208..682569acf 100644 --- a/test/bg-agents-ipc.test.js +++ b/test/bg-agents-ipc.test.js @@ -73,7 +73,7 @@ for (const state of ['working', 'blocked', 'running', 'done', 'failed', 'stopped test(`B main restore ${state}/${liveness} follows the classifier and checked verb`, async t => { const h = restoreFixture(t, { state, liveness, descriptors: [{ kind: 'bg', jobId: 'aaaaaaaa', sessionId: 's1' }] }); const result = await h.restore(); - const shouldRespawn = liveness === 'dead' && ['working', 'running', 'done'].includes(state); + const shouldRespawn = liveness === 'dead' && ['working', 'running', 'done', 'failed'].includes(state); const shouldAttach = liveness === 'live' || shouldRespawn; assert.equal(result.action, shouldAttach ? 'attach' : 'refuse'); assert.deepEqual(h.seen.filter(call => call[0] === 'respawn'), shouldRespawn ? [['respawn', 'aaaaaaaa']] : []); diff --git a/test/bg-duplicate-1020.test.js b/test/bg-duplicate-1020.test.js index 14fefc3a1..d71818bce 100644 --- a/test/bg-duplicate-1020.test.js +++ b/test/bg-duplicate-1020.test.js @@ -127,7 +127,7 @@ test('1020 restore refuses a live continuation of a dead working job', async t = assert.equal(result.action, 'refuse'); }); -for (const state of ['blocked', 'failed']) { +for (const state of ['blocked']) { test('1020 dead ' + state + ' needs explicit Respawn', async t => { const h = fixture(t, state); const result = await h.handlers.get('restore-background-job')({}, SID); @@ -137,7 +137,7 @@ for (const state of ['blocked', 'failed']) { }); } -for (const state of ['working', 'running', 'done']) { +for (const state of ['working', 'running', 'done', 'failed']) { test('1020fix restore respawns and attaches dead ' + state, async t => { const h = fixture(t, state); h.delivery.autoAttach = true; @@ -161,7 +161,7 @@ for (const state of ['blocked', 'working', 'running', 'done', 'failed', 'stopped }); } -for (const tail of ['{"type":"assistant","mess', '{"type":"continued-in","sessionId":"' + SID + '","continuedInSessionId":"']) { +for (const tail of ['{"type":"assistant","mess']) { test('1020fix torn final line permits manual recovery ' + tail, async t => { const h = fixture(t, 'done'); fs.appendFileSync(h.file, tail); @@ -171,6 +171,36 @@ for (const tail of ['{"type":"assistant","mess', '{"type":"continued-in","sessio }); } +test('1020fix2 unresolved continued-in tail refuses recovery until completed', async t => { + const h = fixture(t, 'done'); + const record = link(NEXT); + fs.appendFileSync(h.file, record.slice(0, 40)); + const read = createContinuationReader(h.file, SID); + assert.equal(await read(), null, 'a partial continued-in record is uncertain'); + for (const channel of ['check-bg-agent-verb', 'bg-agent-verb']) { + assert.equal((await h.handlers.get(channel)({}, 'respawn', 'aaaaaaaa')).ok, false, 'uncertainty must refuse Respawn'); + } + assert.deepEqual(h.calls, [], 'uncertainty must never respawn'); + fs.appendFileSync(h.file, record.slice(40)); + assert.deepEqual(await read(), [NEXT], 'the same reader must recognize the completed continuation'); +}); + +for (const state of ['stopped', 'failed']) { + test('1020fix2 ' + state + ' continuation permits Respawn while daemon runs', async t => { + const h = fixture(t); + h.fork(); h.descriptors.length = 0; + h.writeJob('bbbbbbbb', NEXT, state); + fs.writeFileSync(path.join(h.root, 'daemon', 'roster.json'), line({ proto: 1, + updatedAt: new Date().toISOString(), supervisorPid: 4242, workers: {} })); + h.cli.readWorkerProcessEvidence = () => ({ processTableFresh: true, + tableEntry: pid => pid === 4242 ? { created: 'daemon' } : undefined }); + for (const channel of ['check-bg-agent-verb', 'bg-agent-verb']) { + assert.equal((await h.handlers.get(channel)({}, 'respawn', 'aaaaaaaa')).ok, true, 'terminal continuations cannot host workers'); + } + assert.deepEqual(h.calls, [['respawn', 'aaaaaaaa']]); + }); +} + for (const missing of ['resolved-path', 'dangling-link']) { test('1020fix deleted continuation permits recovery ' + missing, async t => { const h = fixture(t); diff --git a/test/bg-recovery-1020fix.test.js b/test/bg-recovery-1020fix.test.js index 804f966c6..5a6a44fd5 100644 --- a/test/bg-recovery-1020fix.test.js +++ b/test/bg-recovery-1020fix.test.js @@ -58,19 +58,21 @@ function setup(t, { forkAt = 0, exitAt = Infinity, descriptor = true, unknown = }); const { handleSendToBackground } = loadAppFunctions(ctx, { declarations: ['backgroundHandoffs'], functions: ['handleSendToBackground', 'persistBackgroundHandoff', 'readBackgroundHandoffStamp'], sourcePath: path.join(__dirname, '../main.js') }); - return { send: async () => { await advance(0); return handleSendToBackground({}, 'tab'); }, writes, respawns, kills, session, time: () => now }; + return { send: async () => { await advance(0); return handleSendToBackground({}, 'tab'); }, writes, respawns, kills, session, activeSessions: ctx.activeSessions, time: () => now }; } -test('1020fix shipped handler adopts an earlier live fork before typing exit', async t => { +test('1020fix2 shipped handler refuses an earlier live fork and leaves the tab untouched', async t => { const h = setup(t); + const before = { ...h.session, composerState: { ...h.session.composerState }, backgrounding: false }; const result = await h.send(); - assert.equal(result.ok, true, 'an earlier live continuation must be adopted'); - assert.equal(result.sessionId, NEXT); - assert.equal(result.jobId, 'bbbbbbbb'); - assert.notEqual(result.respawned, true); - assert.deepEqual(h.writes, [], 'adoption must leave the source terminal untouched'); + assert.equal(result.ok, false, 'an earlier live continuation must refuse the handoff'); + assert.equal(result.code, 'refused'); + assert.equal(result.error, 'This conversation already runs in the background; open it from the sidebar.'); + assert.deepEqual(h.writes, [], 'refusal must write neither /exit nor /bg'); assert.deepEqual(h.respawns, []); assert.deepEqual(h.kills, []); + assert.equal(h.activeSessions.get('tab'), h.session, 'the foreground tab must remain registered'); + assert.deepEqual(h.session, before, 'the foreground CLI and composer must remain unchanged'); }); test('1020fix shipped handler refuses unknown continuation liveness before typing exit', async t => { From 005a25896ec379fbcc311cac47827ab141591987 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 13:30:14 +0100 Subject: [PATCH 5/7] (bg-agents): treat any torn exit record as uncertain and close the source after adopting its fork A continued-in record cut before its type tag no longer counts as proof that no continuation exists; adopting a fork after the exit timeout now stops the old CLI; concurrent respawns of one job run once. --- .ai/contexts/bg-agents.md | 22 ++++++++----- .ai/contexts/cli-session-state.md | 6 ++-- .ai/contexts/session-cache.md | 5 +++ CHANGELOG.md | 2 +- session-continuations.js | 5 +++ src/main/bg-agents/bg-agents-ipc.js | 18 ++++++++--- src/main/bg-agents/bg-handoff.js | 1 + test/bg-duplicate-1020.test.js | 48 +++++++++++++++++++++++++++-- test/bg-recovery-1020fix.test.js | 11 ++++--- 9 files changed, 98 insertions(+), 20 deletions(-) diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index f6d959555..c1457a04e 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -56,8 +56,12 @@ after a reboot or daemon death. A live continuation prevents a duplicate respawn Dead `blocked` and `stopped` jobs require explicit Respawn through the existing button. Unknown states or worker liveness refuse. Restore and manual Respawn pass their classified worker evidence to `liveJobCheck`, keyed by job id; -Delete retains its conservative default. Unknown never respawns. A per-session promise -coalesces overlapping requests. Descriptor observation subscribes before its + Delete retains its conservative default. Unknown never respawns. A per-session promise + coalesces overlapping restore requests. A per-job promise covers the check and CLI + respawn shared by manual Respawn, restore and handoff, releasing on success or failure. + This does not coalesce distinct dead jobs in one continuation chain: selecting its + newest saved member requires a proven chain lookup before choosing the job. + Descriptor observation subscribes before its initial read, matches this job through `liveWorkerDescriptor`, waits at most 15 seconds and always releases the listener. A successful respawn without a descriptor returns `respawned-not-attached`. Attach retries set `attachOnly` @@ -1085,8 +1089,10 @@ foreground CLI untouched. Unknown or ambiguous ownership also refuses before `/exit`. Stopped and failed continuation jobs cannot be hosted and skip worker liveness sampling; they do not prevent manual Respawn while the daemon runs. At the 15-second exit timeout the -same 10-second continuation observation still runs. It can adopt a confirmed -fork even if the source has not exited. Once the original exit budget expires, + same 10-second continuation observation still runs. It can adopt a confirmed + fork even if the source has not exited. Adoption schedules the same 15-second + cleanup as `/bg`, killing the old source only if it still has not exited. + Once the original exit budget expires, this path can only adopt or refuse; even a later source exit cannot authorize respawn before its own post-exit grace. No exit budget was increased. The CLI fork link was measured at 5.5 seconds after `/exit` on 2026-11-07; @@ -1099,9 +1105,11 @@ Observed links remain held even if a later rewrite drops them. Only a complete final read with no new continuation permits the checked respawn. This is a bounded observation, not a CLI-level atomic writer lock; the shared guard re-reads continuations immediately before the CLI verb as another check. -The reader ignores an unparsable final unterminated ordinary line, retaining -sealed continuation records and reconsidering the tail when the file grows. -An unresolved `continued-in` tail returns uncertainty and prevents respawn; +The reader retains sealed continuation evidence for an unparsable final line +only when its leading complete `type` value proves it is not `continued-in`. +A tail cut before that type is known returns uncertainty and prevents respawn, +as does an unresolved `continued-in` tail. A nested type tag is not proof. +The tail is reconsidered when the file grows; the same reader recognizes the continuation once the record is completed. A malformed sealed continuation record still refuses. The helper exposed as `unsubscribeBgAgents.respawnForHandoff(job, extraSessionIds)` diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 138d0d5b8..444fd7c4d 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -736,8 +736,10 @@ not CLI drift; the test suite does not sample real conversation transcripts. `session-continuations` IPC calls `sessionCache.resolveSessionContinuations`. It traverses indexed links in record order, deduplicates terminal ids and bounds traversal to 32 edges / 128 node visits. Cycles, missing continuation rows/files -and malformed continuation records return `unresolved`. Unrelated JSON parse -failures and incomplete tails do not block resume. File scanning yields between +and malformed continuation records return `unresolved`. Unrelated sealed JSON +parse failures do not block resume. An unparsable unterminated tail is unresolved +unless its leading complete type proves it is not `continued-in`. +File scanning yields between chunks and has no total byte cap; chunk size only controls pacing. The traversal also returns `missing: true` when an all-missing branch was seen diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 3964d0389..86b964999 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -2498,6 +2498,11 @@ Coverage: `test/continuation-rows-564.test.js` (graph and bounds), `test/continu ## Continuation index (#518B) +An unparsable unterminated tail remains unresolved until its leading complete +`type` value proves it is an ordinary record. A cut before the type is known +cannot establish that no continuation exists. Completing the same tail clears +this transient uncertainty; sealed malformed continuation records remain invalid. + Changed cached local parent rows with format/version 3 and known links receive `prepareContinuationRefresh` before synchronous refresh. The shared yielded scanner uses at most 1 MiB per chunk, exact BigInt stamps, persisted identity/head diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ace82818..c15168b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed -- Sending a resumed session to the background leaves its tab untouched when the conversation already runs in the background, with a message directing you to the sidebar. After a reboot or daemon death, working, done, running and failed jobs recover automatically when no live continuation holds them; blocked jobs wait for the notice's Respawn button. (#1020) +- Sending a resumed background conversation to the background no longer starts a second copy. After a reboot or daemon death, working, done, running and failed jobs recover automatically when no live continuation holds them; blocked jobs wait for the notice's Respawn button. (#1020) - A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017) ## v0.0.103 — 2026-11-03 diff --git a/session-continuations.js b/session-continuations.js index a9ee4304b..7acbbb1df 100644 --- a/session-continuations.js +++ b/session-continuations.js @@ -88,6 +88,11 @@ function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024 const before = invalid; tailValid = consume(pending); tailUnresolved = invalid && !before; + if (!tailValid) { + const type = pending.toString('utf8').match(/^\s*\{\s*"type"\s*:\s*("(?:[^"\\]|\\.)*")/); + try { tailUnresolved ||= !type || JSON.parse(type[1]) === 'continued-in'; } + catch { tailUnresolved = true; } + } invalid = before; } return JSON.stringify({ format: 3, version: 3, ids: [...ids], sealedIds, bytes: position, diff --git a/src/main/bg-agents/bg-agents-ipc.js b/src/main/bg-agents/bg-agents-ipc.js index 7b20f6c86..c263f333c 100644 --- a/src/main/bg-agents/bg-agents-ipc.js +++ b/src/main/bg-agents/bg-agents-ipc.js @@ -75,7 +75,19 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio if (typeof id !== 'string' || !JOB_ID_RE.test(id)) return { ok: false, error: 'invalid background session id' }; return checkVerb(verb, id); }); + const respawning = new Map(); + const runRespawn = (id, restoring = false, extraSessionIds = []) => { + const key = String(id).toLowerCase(); + if (respawning.has(key)) return respawning.get(key); + const pending = (async () => { + const check = await checkVerb('respawn', id, restoring, extraSessionIds); + return { result: check.ok ? await bgAgents.runVerb('respawn', id) : check, refused: !check.ok }; + })().finally(() => respawning.delete(key)); + respawning.set(key, pending); + return pending; + }; ipcMain.handle('bg-agent-verb', async (_event, verb, id) => { + if (verb === 'respawn') return (await runRespawn(id)).result; const check = await checkVerb(verb, id); return check.ok ? bgAgents.runVerb(verb, id) : check; }); @@ -104,10 +116,8 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio }); const respawnForHandoff = async (job, extraSessionIds = []) => { try { - const permission = await checkVerb('respawn', job.id, true, extraSessionIds); - if (!permission.ok) return { ok: false, refused: true, error: permission.error }; - const result = await bgAgents.runVerb('respawn', job.id); - if (!result?.ok) return { ok: false, error: result?.error || 'respawn returned no result' }; + const { result, refused } = await runRespawn(job.id, true, extraSessionIds); + if (!result?.ok) return { ok: false, ...(refused ? { refused: true } : {}), error: result?.error || 'respawn returned no result' }; return { ok: true, attached: await waitForDescriptor(job) }; } catch (err) { return { ok: false, error: err.message || String(err) }; } }; diff --git a/src/main/bg-agents/bg-handoff.js b/src/main/bg-agents/bg-handoff.js index d5aeb483c..195c8843d 100644 --- a/src/main/bg-agents/bg-handoff.js +++ b/src/main/bg-agents/bg-handoff.js @@ -120,6 +120,7 @@ async function respawnHandoff(session, sessionId, oldId, job, previous, deps) { session.backgrounded = true; await releaseWindowsPty(session.pty); deps.onConfirmed?.(continuation); + deps.schedule?.(() => { if (!session.exited) deps.kill(session); }, deps.exitTimeoutMs ?? BG_HANDOFF_EXIT_TIMEOUT_MS); return { ok: true, oldSessionId: sessionId, jobId: continuation.jobId, sessionId: continuation.sessionId, cwd: continuation.cwd || session.cwd }; } diff --git a/test/bg-duplicate-1020.test.js b/test/bg-duplicate-1020.test.js index d71818bce..cecce0377 100644 --- a/test/bg-duplicate-1020.test.js +++ b/test/bg-duplicate-1020.test.js @@ -40,9 +40,9 @@ function fixture(t, state = 'stopped') { writeJob('bbbbbbbb', NEXT, 'working'); descriptors.push({ kind: 'bg', sessionId: NEXT, jobId: 'bbbbbbbb', cwd: root }); }; - const calls = [], handlers = new Map(), delivery = { autoAttach: false }; + const calls = [], handlers = new Map(), delivery = { autoAttach: false, wait: null }; init({ ipcMain: { handle: (name, fn) => handlers.set(name, fn) }, - bgAgents: { ...bg, runVerb: async (...args) => { calls.push(args); if (delivery.autoAttach) descriptors.push({ kind: 'bg', jobId: 'aaaaaaaa', sessionId: bg.findRestoreJob(SID, 'aaaaaaaa').job.sessionId }); return { ok: true }; } }, + bgAgents: { ...bg, runVerb: async (...args) => { calls.push(args); if (delivery.wait) await delivery.wait; if (delivery.autoAttach) descriptors.push({ kind: 'bg', jobId: 'aaaaaaaa', sessionId: bg.findRestoreJob(SID, 'aaaaaaaa').job.sessionId }); return { ok: true }; } }, cliSessionState: cli, getMainWindow: () => null, log: { warn() {} }, activeSessions: new Map(), sessionHasPty: () => false, ptyPids: () => [], getSetting: () => ({ openWorkingSet: [{ sessionId: SID, bg: true, attachJobId: 'aaaaaaaa' }] }) }); return { root, file, descriptors, fork, calls, handlers, writeJob, cli, delivery }; @@ -185,6 +185,50 @@ test('1020fix2 unresolved continued-in tail refuses recovery until completed', a assert.deepEqual(await read(), [NEXT], 'the same reader must recognize the completed continuation'); }); +test('1020fix3 every continued-in byte cut stays uncertain until completion', async t => { + const h = fixture(t, 'done'); + const sealed = line({ type: 'user', sessionId: SID }); + const record = JSON.stringify({ type: 'continued-in', sessionId: SID, + continuedInSessionId: NEXT, timestamp: '2026-11-08T00:00:00Z' }); + const { scanContinuationIndex } = require('../session-continuations'); + for (let cut = 1; cut <= Buffer.byteLength(record); cut++) { + fs.writeFileSync(h.file, sealed + record.slice(0, cut)); + const read = createContinuationReader(h.file, SID); + assert.deepEqual(await read(), cut === record.length ? [NEXT] : null, 'continuation uncertainty at byte ' + cut); + if (cut < record.length) { + assert.equal(JSON.parse(scanContinuationIndex(h.file, SID)).unresolved, true, 'index uncertainty at byte ' + cut); + for (const channel of ['check-bg-agent-verb', 'bg-agent-verb']) { + assert.equal((await h.handlers.get(channel)({}, 'respawn', 'aaaaaaaa')).ok, false, 'refuse recovery at byte ' + cut); + } + } + fs.appendFileSync(h.file, record.slice(cut) + '\r\n'); + assert.deepEqual(await read(), [NEXT], 'recover completed CRLF record at byte ' + cut); + } + assert.deepEqual(h.calls, [], 'no torn continuation may respawn'); +}); + +for (const mode of ['clicks', 'click and restore', 'restore and click']) { + test('1020fix3 concurrent ' + mode + ' invoke one respawn per job', async t => { + const h = fixture(t, 'done'); + h.delivery.autoAttach = true; + let release; + h.delivery.wait = new Promise(resolve => { release = resolve; }); + const click = () => h.handlers.get('bg-agent-verb')({}, 'respawn', 'aaaaaaaa'); + const restore = () => h.handlers.get('restore-background-job')({}, SID); + const first = mode === 'restore and click' ? restore() : click(); + const second = mode === 'click and restore' ? restore() : click(); + await new Promise(resolve => setImmediate(resolve)); + const count = h.calls.length; + release(); + const results = await Promise.all([first, second]); + assert.equal(count, 1, 'concurrent requests must invoke ONE respawn'); + assert.ok(results.every(result => result.ok || result.action === 'attach')); + h.descriptors.length = 0; + assert.equal((await click()).ok, true, 'a completed flight must allow a later retry'); + assert.equal(h.calls.length, 2); + }); +} + for (const state of ['stopped', 'failed']) { test('1020fix2 ' + state + ' continuation permits Respawn while daemon runs', async t => { const h = fixture(t); diff --git a/test/bg-recovery-1020fix.test.js b/test/bg-recovery-1020fix.test.js index 5a6a44fd5..f40dc8e14 100644 --- a/test/bg-recovery-1020fix.test.js +++ b/test/bg-recovery-1020fix.test.js @@ -26,7 +26,7 @@ function setup(t, { forkAt = 0, exitAt = Infinity, descriptor = true, unknown = const file = path.join(root, 'folder', SID + '.jsonl'); fs.writeFileSync(file, line({ type: 'user', sessionId: SID })); let now = 0, forked = false; - const descriptors = [], writes = [], respawns = [], kills = []; + const descriptors = [], writes = [], respawns = [], kills = [], scheduled = []; const session = { kind: 'local-pty', realSessionId: SID, projectFolder: 'folder', cwd: root, composerState: require('../src/main/terminals/composer-state').createComposerState(), pty: { pid: 4242, onData: () => ({ dispose() {} }) } }; const advance = async ms => { @@ -46,7 +46,7 @@ function setup(t, { forkAt = 0, exitAt = Infinity, descriptor = true, unknown = bg.init({ jobsDir, homeDir: root, cliSessionState: cli, resolveTranscriptPath: id => path.join(root, 'folder', id + '.jsonl') }); t.after(() => { bg.stop(); fs.rmSync(root, { recursive: true, force: true }); }); const ctx = vm.createContext({ fs, path, require: createRequire(path.join(__dirname, '../main.js')), PROJECTS_DIR: root, - Date: { now: () => now }, Promise, setTimeout, clearTimeout, + Date: { now: () => now }, Promise, setTimeout: (fn, ms) => { scheduled.push({ fn, ms }); return { unref() {} }; }, clearTimeout, activeSessions: new Map([['tab', session]]), cliSessionState: cli, bgAgents: bg, sessionHasPty: () => false, ptyPids: () => [], mainWindow: null, log: { warn() {} }, writePty: (_, text) => { writes.push(text); return true; }, killPty: () => kills.push(true), @@ -58,7 +58,7 @@ function setup(t, { forkAt = 0, exitAt = Infinity, descriptor = true, unknown = }); const { handleSendToBackground } = loadAppFunctions(ctx, { declarations: ['backgroundHandoffs'], functions: ['handleSendToBackground', 'persistBackgroundHandoff', 'readBackgroundHandoffStamp'], sourcePath: path.join(__dirname, '../main.js') }); - return { send: async () => { await advance(0); return handleSendToBackground({}, 'tab'); }, writes, respawns, kills, session, activeSessions: ctx.activeSessions, time: () => now }; + return { send: async () => { await advance(0); return handleSendToBackground({}, 'tab'); }, writes, respawns, kills, scheduled, session, activeSessions: ctx.activeSessions, time: () => now }; } test('1020fix2 shipped handler refuses an earlier live fork and leaves the tab untouched', async t => { @@ -92,7 +92,10 @@ test('1020fix shipped handler adopts a fork at the exit timeout without respawni assert.equal(result.jobId, 'bbbbbbbb'); assert.deepEqual(h.writes, ['/exit', '\r']); assert.deepEqual(h.respawns, []); - assert.deepEqual(h.kills, []); + assert.equal(h.scheduled.length, 1, 'adoption must schedule cleanup of the old CLI'); + assert.equal(h.scheduled[0].ms, 15000); + h.scheduled[0].fn(); + assert.deepEqual(h.kills, [true], 'the old-id CLI must be killed after adoption'); assert.ok(h.time() >= 15400 && h.time() <= 25300, 'keep the 15 second exit budget and 10 second observation bound'); }); From 86209ffb3250859baf182c3f8114d1f446ef2d55 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 22:28:03 +0100 Subject: [PATCH 6/7] (bg-agents): keep ordinary torn transcript lines from blocking respawn A torn user or assistant line, whose first key is not type, no longer counts as a possible continuation; a joining respawn with different parameters runs its own check. --- .ai/contexts/bg-agents.md | 36 +++++++++----- .ai/contexts/cli-session-state.md | 5 +- CHANGELOG.md | 2 +- session-continuations.js | 11 ++++- src/main/bg-agents/bg-agents-ipc.js | 10 +++- test/bg-agents-ipc.test.js | 68 ++++++++++++++++++++++++++ test/bg-duplicate-1020.test.js | 76 ++++++++++++++++++++++++++++- 7 files changed, 188 insertions(+), 20 deletions(-) diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index c1457a04e..78fd3fd2d 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -56,12 +56,15 @@ after a reboot or daemon death. A live continuation prevents a duplicate respawn Dead `blocked` and `stopped` jobs require explicit Respawn through the existing button. Unknown states or worker liveness refuse. Restore and manual Respawn pass their classified worker evidence to `liveJobCheck`, keyed by job id; - Delete retains its conservative default. Unknown never respawns. A per-session promise - coalesces overlapping restore requests. A per-job promise covers the check and CLI - respawn shared by manual Respawn, restore and handoff, releasing on success or failure. - This does not coalesce distinct dead jobs in one continuation chain: selecting its - newest saved member requires a proven chain lookup before choosing the job. - Descriptor observation subscribes before its +Delete retains its conservative default. Unknown never respawns. A per-session promise +coalesces overlapping restore requests. A per-job promise covers the check and CLI +respawn shared by manual Respawn, restore and handoff, releasing on success or failure. +Callers share that result only with the same restore mode and a subset of the +flight's lowercased extra session ids. Other callers wait for it to settle, then +run their own checked respawn, including after a rejected flight. +This does not coalesce distinct dead jobs in one continuation chain: selecting its +newest saved member requires a proven chain lookup before choosing the job. +Descriptor observation subscribes before its initial read, matches this job through `liveWorkerDescriptor`, waits at most 15 seconds and always releases the listener. A successful respawn without a descriptor returns `respawned-not-attached`. Attach retries set `attachOnly` @@ -1089,10 +1092,10 @@ foreground CLI untouched. Unknown or ambiguous ownership also refuses before `/exit`. Stopped and failed continuation jobs cannot be hosted and skip worker liveness sampling; they do not prevent manual Respawn while the daemon runs. At the 15-second exit timeout the - same 10-second continuation observation still runs. It can adopt a confirmed - fork even if the source has not exited. Adoption schedules the same 15-second - cleanup as `/bg`, killing the old source only if it still has not exited. - Once the original exit budget expires, +same 10-second continuation observation still runs. It can adopt a confirmed +fork even if the source has not exited. Adoption schedules the same 15-second +cleanup as `/bg`, killing the old source only if it still has not exited. +Once the original exit budget expires, this path can only adopt or refuse; even a later source exit cannot authorize respawn before its own post-exit grace. No exit budget was increased. The CLI fork link was measured at 5.5 seconds after `/exit` on 2026-11-07; @@ -1106,9 +1109,16 @@ final read with no new continuation permits the checked respawn. This is a bounded observation, not a CLI-level atomic writer lock; the shared guard re-reads continuations immediately before the CLI verb as another check. The reader retains sealed continuation evidence for an unparsable final line -only when its leading complete `type` value proves it is not `continued-in`. -A tail cut before that type is known returns uncertainty and prevents respawn, -as does an unresolved `continued-in` tail. A nested type tag is not proof. +when its first complete key (including the colon) is not `type`, it carries a +complete `parentUuid` or `message` key, or its leading complete `type` value +proves it is not `continued-in`. CLI continuation records are type-first +(`type`, `sessionId`, `continuedInSessionId`, `timestamp`); ordinary user and +assistant records can start with `parentUuid` and put `type` after `message`. +A tail cut inside the first key, such as `{"par`, stays uncertain until that +key is complete. Every cut of a type-first continuation stays uncertain until +the record is complete. Uncertainty prevents respawn; a nested type tag alone +is not proof of an ordinary record. +A nested `continued-in` tag cannot invalidate that ordinary-record proof. The tail is reconsidered when the file grows; the same reader recognizes the continuation once the record is completed. A malformed sealed continuation record still refuses. diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 444fd7c4d..31a0a32a8 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -738,7 +738,10 @@ It traverses indexed links in record order, deduplicates terminal ids and bounds traversal to 32 edges / 128 node visits. Cycles, missing continuation rows/files and malformed continuation records return `unresolved`. Unrelated sealed JSON parse failures do not block resume. An unparsable unterminated tail is unresolved -unless its leading complete type proves it is not `continued-in`. +unless its first complete key (including the colon) is not `type`, it carries +a complete `parentUuid` or `message` key, or its leading complete type proves +it is not `continued-in`. A cut inside the first key stays uncertain; see +bg-agents.md, "Background continuations", for the record-order contract. File scanning yields between chunks and has no total byte cap; chunk size only controls pacing. diff --git a/CHANGELOG.md b/CHANGELOG.md index c15168b1f..ef505228c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed -- Sending a resumed background conversation to the background no longer starts a second copy. After a reboot or daemon death, working, done, running and failed jobs recover automatically when no live continuation holds them; blocked jobs wait for the notice's Respawn button. (#1020) +- Sending a resumed background conversation to the background no longer starts a second copy. When it already runs in the background, the tab is left untouched with a pointer to the sidebar; after a reboot or daemon death, working, done, running and failed jobs recover automatically when no live continuation holds them, while blocked jobs wait for the notice's Respawn button. (#1020) - A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017) ## v0.0.103 — 2026-11-03 diff --git a/session-continuations.js b/session-continuations.js index 7acbbb1df..163904635 100644 --- a/session-continuations.js +++ b/session-continuations.js @@ -89,8 +89,15 @@ function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024 tailValid = consume(pending); tailUnresolved = invalid && !before; if (!tailValid) { - const type = pending.toString('utf8').match(/^\s*\{\s*"type"\s*:\s*("(?:[^"\\]|\\.)*")/); - try { tailUnresolved ||= !type || JSON.parse(type[1]) === 'continued-in'; } + const text = pending.toString('utf8'); + const firstKey = text.match(/^\s*\{\s*("(?:[^"\\]|\\.)*")\s*:/); + const type = text.match(/^\s*\{\s*"type"\s*:\s*("(?:[^"\\]|\\.)*")/); + try { + const ordinary = (firstKey && JSON.parse(firstKey[1]) !== 'type') + || /"(?:parentUuid|message)"\s*:/.test(text) + || (type && JSON.parse(type[1]) !== 'continued-in'); + tailUnresolved = !ordinary; + } catch { tailUnresolved = true; } } invalid = before; diff --git a/src/main/bg-agents/bg-agents-ipc.js b/src/main/bg-agents/bg-agents-ipc.js index c263f333c..750210ea0 100644 --- a/src/main/bg-agents/bg-agents-ipc.js +++ b/src/main/bg-agents/bg-agents-ipc.js @@ -78,12 +78,18 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio const respawning = new Map(); const runRespawn = (id, restoring = false, extraSessionIds = []) => { const key = String(id).toLowerCase(); - if (respawning.has(key)) return respawning.get(key); + const extraIds = new Set(extraSessionIds.map(sid => sid.toLowerCase())); + const flight = respawning.get(key); + if (flight) { + if (flight.restoring === restoring && [...extraIds].every(sid => flight.extraIds.has(sid))) return flight.pending; + const retry = () => runRespawn(id, restoring, extraSessionIds); + return flight.pending.then(retry, retry); + } const pending = (async () => { const check = await checkVerb('respawn', id, restoring, extraSessionIds); return { result: check.ok ? await bgAgents.runVerb('respawn', id) : check, refused: !check.ok }; })().finally(() => respawning.delete(key)); - respawning.set(key, pending); + respawning.set(key, { pending, restoring, extraIds }); return pending; }; ipcMain.handle('bg-agent-verb', async (_event, verb, id) => { diff --git a/test/bg-agents-ipc.test.js b/test/bg-agents-ipc.test.js index 682569acf..0548ad8c9 100644 --- a/test/bg-agents-ipc.test.js +++ b/test/bg-agents-ipc.test.js @@ -415,3 +415,71 @@ test('S2d checked handoff lowercases and deduplicates extra conversation ids', a { ok: true, attached: true }); assert.deepEqual(checked, ['s-1', 'extra-id']); }); + +for (const scenario of ['manual with extra ids', 'manual without extra ids', 'handoff with new extra ids']) { + test('1020fix4 joining ' + scenario + ' waits then runs its own guard', async () => { + const { handlers, ipcMain } = fakeIpc(); + const bg = fakeBgAgents(); + const checks = [], ids = []; + let release, started; + const running = new Promise(resolve => { started = resolve; }); + const wait = new Promise(resolve => { release = resolve; }); + bg.conversationCheck = (_id, options) => { + checks.push(options.workerLiveness); + return { known: true, state: 'done', hosted: false, workerLiveness: 'dead', sessionIds: ['s1'] }; + }; + bg.runVerb = async () => { bg.calls.push(['verb']); started(); await wait; return { ok: true }; }; + let extraLive = false; + const unsubscribe = init({ ipcMain, bgAgents: bg, getMainWindow: () => null, log: { warn() {} }, + activeSessions: new Map(), sessionHasPty: () => false, ptyPids: () => [], + cliSessionState: { readAllDescriptors: () => [{ kind: 'bg', jobId: 'aaaaaaaa', sessionId: 's1' }], + onDescriptorsChanged: () => () => {}, + liveElsewhereChecked: async sid => { ids.push(sid); return { known: true, live: extraLive && sid === 'extra' ? { pid: 777 } : null }; } }, + }); + const job = { id: 'aaaaaaaa', sessionId: 's1' }; + const first = scenario.startsWith('manual') ? handlers.get('bg-agent-verb')({}, 'respawn', job.id) + : unsubscribe.respawnForHandoff(job, ['s1']); + await running; + const second = unsubscribe.respawnForHandoff(job, scenario === 'manual without extra ids' ? [] : ['EXTRA']); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(checks.length, 1, 'the joiner must wait for the flight'); + extraLive = true; + release(); + await first; + const result = await second; + assert.equal(checks.length, 2, 'different parameters must run their own check'); + assert.deepEqual(checks, scenario.startsWith('manual') ? [true, 'all'] : ['all', 'all']); + assert.equal(result.ok, scenario === 'manual without extra ids'); + if (scenario !== 'manual without extra ids') { + assert.equal(result.refused, true); + assert.ok(ids.includes('extra'), 'the new extra id must be checked'); + assert.equal(bg.calls.filter(call => call[0] === 'verb').length, 1, 'refused joiner cannot respawn'); + } + }); +} + +test('1020fix4 compatible subset joins the same respawn result', async () => { + const { ipcMain } = fakeIpc(); + const bg = fakeBgAgents(); + let release, started, checks = 0; + const running = new Promise(resolve => { started = resolve; }); + const wait = new Promise(resolve => { release = resolve; }); + bg.conversationCheck = () => { + checks++; + return { known: true, state: 'done', hosted: false, workerLiveness: 'dead', sessionIds: ['s1'] }; + }; + bg.runVerb = async () => { bg.calls.push(['verb']); started(); await wait; return { ok: true }; }; + const unsubscribe = init({ ipcMain, bgAgents: bg, getMainWindow: () => null, log: { warn() {} }, + activeSessions: new Map(), sessionHasPty: () => false, ptyPids: () => [], + cliSessionState: { readAllDescriptors: () => [{ kind: 'bg', jobId: 'aaaaaaaa', sessionId: 's1' }], + onDescriptorsChanged: () => () => {}, liveElsewhereChecked: async () => ({ known: true, live: null }) }, + }); + const job = { id: 'aaaaaaaa', sessionId: 's1' }; + const first = unsubscribe.respawnForHandoff(job, ['EXTRA', 'other']); + await running; + const second = unsubscribe.respawnForHandoff(job, ['extra', 'EXTRA']); + release(); + assert.deepEqual(await Promise.all([first, second]), [{ ok: true, attached: true }, { ok: true, attached: true }]); + assert.equal(checks, 1); + assert.deepEqual(bg.calls.filter(call => call[0] === 'verb'), [['verb']]); +}); diff --git a/test/bg-duplicate-1020.test.js b/test/bg-duplicate-1020.test.js index cecce0377..c72339e39 100644 --- a/test/bg-duplicate-1020.test.js +++ b/test/bg-duplicate-1020.test.js @@ -207,6 +207,71 @@ test('1020fix3 every continued-in byte cut stays uncertain until completion', as assert.deepEqual(h.calls, [], 'no torn continuation may respawn'); }); +for (const consumer of ['handoff reader', 'session-cache traversal']) { +for (const role of ['assistant', 'user']) { + for (const cutAt of ['first key', 'half', 'last byte']) { + test(`1020fix4 real ${role} torn after ${cutAt} retains sealed evidence through ${consumer}`, async t => { + const h = fixture(t, 'done'); + const record = JSON.stringify({ parentUuid: PRIOR, isSidechain: false, sessionId: SID, + message: { ...(role === 'assistant' ? { model: 'claude' } : {}), role, content: 'hello from the CLI' }, + type: role, uuid: NEXT, timestamp: '2026-11-08T00:00:00Z' }); + const cut = cutAt === 'first key' ? '{"parentUuid":'.length + : cutAt === 'half' ? Math.floor(record.length / 2) : record.length - 1; + fs.writeFileSync(h.file, link(NEXT) + record.slice(0, cut)); + fs.writeFileSync(path.join(h.root, NEXT + '.jsonl'), line({ type: 'user', sessionId: NEXT })); + const read = createContinuationReader(h.file, SID); + const { scanContinuationIndex, resolveContinuations } = require('../session-continuations'); + const walk = () => resolveContinuations(SID, id => ({ index: JSON.parse(scanContinuationIndex(path.join(h.root, id + '.jsonl'), id)) })); + if (consumer === 'handoff reader') assert.deepEqual(await read(), [NEXT], 'handoff reader must retain sealed evidence'); + assert.equal((await walk()).unresolved, false, 'session-cache traversal must retain sealed evidence'); + assert.deepEqual((await walk()).candidates.map(row => row.sessionId), [NEXT]); + assert.deepEqual(await read(), [NEXT], 'handoff reader must retain sealed evidence'); + fs.appendFileSync(h.file, record.slice(cut) + '\r\n'); + assert.deepEqual(await read(), [NEXT], 'completed ordinary CRLF tail retains the link'); + assert.equal((await walk()).unresolved, false); + }); + } +} +} + +for (const tail of ['{"uuid":', '{"type":null,"parentUuid":', '{"type":null,"message":']) { + test('1020fix4 ordinary key proves a non-continuation tail ' + tail, async t => { + const h = fixture(t); + fs.appendFileSync(h.file, tail); + assert.deepEqual(await createContinuationReader(h.file, SID)(), []); + }); +} + +test('1020fix4 tail inside the first key stays uncertain and recovers', async t => { + const h = fixture(t, 'done'); + fs.writeFileSync(h.file, link(NEXT) + '{"par'); + const read = createContinuationReader(h.file, SID); + const { scanContinuationIndex, resolveContinuations } = require('../session-continuations'); + assert.equal(await read(), null); + const result = await resolveContinuations(SID, id => id === SID + ? { index: JSON.parse(scanContinuationIndex(h.file, SID)) } : { index: { ids: [] } }); + assert.equal(result.unresolved, true); + fs.appendFileSync(h.file, 'entUuid":'); + assert.deepEqual(await read(), [NEXT], 'a complete first key proves an ordinary record'); +}); + +for (const prefix of ['{"parentUuid":null,"message":', '{"type":"assistant","message":', '{"type":null,"message":']) { + for (const consumer of ['handoff reader', 'session-cache traversal']) { + test('1020fix4 ordinary proof overrides a nested continuation tag through ' + consumer + ' ' + prefix, async t => { + const h = fixture(t, 'done'); + fs.writeFileSync(h.file, link(NEXT) + prefix + '{"input":{"type":"continued-in"}}'); + fs.writeFileSync(path.join(h.root, NEXT + '.jsonl'), line({ type: 'user', sessionId: NEXT })); + if (consumer === 'handoff reader') assert.deepEqual(await createContinuationReader(h.file, SID)(), [NEXT], 'an ordinary outer record cannot be a continuation'); + else { + const { scanContinuationIndex, resolveContinuations } = require('../session-continuations'); + const result = await resolveContinuations(SID, id => ({ index: JSON.parse(scanContinuationIndex(path.join(h.root, id + '.jsonl'), id)) })); + assert.equal(result.unresolved, false, 'a nested tag cannot override the ordinary record proof'); + assert.deepEqual(result.candidates.map(row => row.sessionId), [NEXT]); + } + }); + } +} + for (const mode of ['clicks', 'click and restore', 'restore and click']) { test('1020fix3 concurrent ' + mode + ' invoke one respawn per job', async t => { const h = fixture(t, 'done'); @@ -216,13 +281,22 @@ for (const mode of ['clicks', 'click and restore', 'restore and click']) { const click = () => h.handlers.get('bg-agent-verb')({}, 'respawn', 'aaaaaaaa'); const restore = () => h.handlers.get('restore-background-job')({}, SID); const first = mode === 'restore and click' ? restore() : click(); + await new Promise(resolve => setImmediate(resolve)); const second = mode === 'click and restore' ? restore() : click(); await new Promise(resolve => setImmediate(resolve)); const count = h.calls.length; release(); const results = await Promise.all([first, second]); assert.equal(count, 1, 'concurrent requests must invoke ONE respawn'); - assert.ok(results.every(result => result.ok || result.action === 'attach')); + assert.ok(results[0].ok || results[0].action === 'attach'); + if (mode === 'clicks') assert.equal(results[1].ok, true); + else if (mode === 'click and restore') { + assert.equal(results[1].action, 'refuse', 'restore rechecks the worker started by the manual flight'); + assert.match(results[1].reason, /cannot respawn.*session; stop it first/); + } else { + assert.equal(results[1].ok, false, 'manual joiner rechecks the worker started by restore'); + assert.match(results[1].error, /cannot respawn.*session; stop it first/); + } h.descriptors.length = 0; assert.equal((await click()).ok, true, 'a completed flight must allow a later retry'); assert.equal(h.calls.length, 2); From 52d6b7703a30673e1a46088ce0be193a7edb960c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 8 Nov 2026 23:57:59 +0100 Subject: [PATCH 7/7] (bg-agents): share a successful respawn with every caller that joined it A restore or click that overlaps a respawn which already succeeded takes its result instead of respawning again while the daemon has not yet published the new worker. --- .ai/contexts/bg-agents.md | 14 +++++++----- .ai/contexts/cli-session-state.md | 5 +++-- src/main/bg-agents/bg-agents-ipc.js | 2 +- test/bg-agents-ipc.test.js | 35 +++++++++++++++++++++++------ test/bg-duplicate-1020.test.js | 26 +++++++++++++-------- 5 files changed, 58 insertions(+), 24 deletions(-) diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index 78fd3fd2d..60a0e3f33 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -59,9 +59,12 @@ Respawn pass their classified worker evidence to `liveJobCheck`, keyed by job id Delete retains its conservative default. Unknown never respawns. A per-session promise coalesces overlapping restore requests. A per-job promise covers the check and CLI respawn shared by manual Respawn, restore and handoff, releasing on success or failure. -Callers share that result only with the same restore mode and a subset of the -flight's lowercased extra session ids. Other callers wait for it to settle, then -run their own checked respawn, including after a rejected flight. +All joiners share a successful respawn, even with different restore modes or extra +session ids and before the daemon publishes its new worker. Restore then waits +for its descriptor; manual Respawn returns success. Same-mode callers with a subset +of the flight's lowercased extra session ids also share refusals and failures. +Other callers wait for settlement and run their own checked respawn only if the +flight refused, failed or rejected. This does not coalesce distinct dead jobs in one continuation chain: selecting its newest saved member requires a proven chain lookup before choosing the job. Descriptor observation subscribes before its @@ -1111,8 +1114,9 @@ re-reads continuations immediately before the CLI verb as another check. The reader retains sealed continuation evidence for an unparsable final line when its first complete key (including the colon) is not `type`, it carries a complete `parentUuid` or `message` key, or its leading complete `type` value -proves it is not `continued-in`. CLI continuation records are type-first -(`type`, `sessionId`, `continuedInSessionId`, `timestamp`); ordinary user and +proves it is not `continued-in`. CLI continuation records are type-first; the +remaining key order varies by CLI version (2.1.302: `type`, `timestamp`, `sessionId`, +`continuedInSessionId`). The torn-tail rule depends only on `type` being first; ordinary user and assistant records can start with `parentUuid` and put `type` after `message`. A tail cut inside the first key, such as `{"par`, stays uncertain until that key is complete. Every cut of a type-first continuation stays uncertain until diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 31a0a32a8..50e0c2526 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -726,8 +726,9 @@ No-link `/clear` and explicit fork still use their existing owner checks. See bg-agents.md, "Background continuations", for the transactional metadata migration and renderer state. -CLI 2.1.289 and 2.1.295 append `{type:"continued-in", sessionId:, -continuedInSessionId:, timestamp:...}` to the old transcript. These records +CLI continuation records appended to the old transcript are type-first; the +remaining key order varies by CLI version (2.1.302: `type`, `timestamp`, `sessionId`, +`continuedInSessionId`). The torn-tail rule depends only on `type` being first. These records can be mid-file and repeated; the new transcript has no back-link. This is an observed interface, pinned by an always-running synthetic fixture in `test/session-continuation-index.test.js`. That fixture proves parser agreement, diff --git a/src/main/bg-agents/bg-agents-ipc.js b/src/main/bg-agents/bg-agents-ipc.js index 750210ea0..250b2b0fa 100644 --- a/src/main/bg-agents/bg-agents-ipc.js +++ b/src/main/bg-agents/bg-agents-ipc.js @@ -83,7 +83,7 @@ function init({ ipcMain, bgAgents, getMainWindow, log, activeSessions, cliSessio if (flight) { if (flight.restoring === restoring && [...extraIds].every(sid => flight.extraIds.has(sid))) return flight.pending; const retry = () => runRespawn(id, restoring, extraSessionIds); - return flight.pending.then(retry, retry); + return flight.pending.then(value => !value.refused && value.result?.ok ? value : retry(), retry); } const pending = (async () => { const check = await checkVerb('respawn', id, restoring, extraSessionIds); diff --git a/test/bg-agents-ipc.test.js b/test/bg-agents-ipc.test.js index 0548ad8c9..3d853995a 100644 --- a/test/bg-agents-ipc.test.js +++ b/test/bg-agents-ipc.test.js @@ -416,19 +416,29 @@ test('S2d checked handoff lowercases and deduplicates extra conversation ids', a assert.deepEqual(checked, ['s-1', 'extra-id']); }); +for (const outcome of ['succeeded', 'refused', 'rejected']) { for (const scenario of ['manual with extra ids', 'manual without extra ids', 'handoff with new extra ids']) { - test('1020fix4 joining ' + scenario + ' waits then runs its own guard', async () => { + test('1020fix5 joining ' + scenario + ' after flight ' + outcome, async () => { const { handlers, ipcMain } = fakeIpc(); const bg = fakeBgAgents(); const checks = [], ids = []; let release, started; const running = new Promise(resolve => { started = resolve; }); const wait = new Promise(resolve => { release = resolve; }); - bg.conversationCheck = (_id, options) => { + bg.conversationCheck = async (_id, options) => { checks.push(options.workerLiveness); + if (checks.length === 1 && outcome === 'refused') { + started(); + await wait; + return { known: false, reason: 'first flight refused' }; + } return { known: true, state: 'done', hosted: false, workerLiveness: 'dead', sessionIds: ['s1'] }; }; - bg.runVerb = async () => { bg.calls.push(['verb']); started(); await wait; return { ok: true }; }; + bg.runVerb = async () => { + bg.calls.push(['verb']); started(); await wait; + if (outcome === 'rejected' && bg.calls.filter(call => call[0] === 'verb').length === 1) throw Error('first flight rejected'); + return { ok: true }; + }; let extraLive = false; const unsubscribe = init({ ipcMain, bgAgents: bg, getMainWindow: () => null, log: { warn() {} }, activeSessions: new Map(), sessionHasPty: () => false, ptyPids: () => [], @@ -437,26 +447,37 @@ for (const scenario of ['manual with extra ids', 'manual without extra ids', 'ha liveElsewhereChecked: async sid => { ids.push(sid); return { known: true, live: extraLive && sid === 'extra' ? { pid: 777 } : null }; } }, }); const job = { id: 'aaaaaaaa', sessionId: 's1' }; - const first = scenario.startsWith('manual') ? handlers.get('bg-agent-verb')({}, 'respawn', job.id) - : unsubscribe.respawnForHandoff(job, ['s1']); + const first = (scenario.startsWith('manual') ? handlers.get('bg-agent-verb')({}, 'respawn', job.id) + : unsubscribe.respawnForHandoff(job, ['s1'])).catch(error => ({ ok: false, error: error.message })); await running; const second = unsubscribe.respawnForHandoff(job, scenario === 'manual without extra ids' ? [] : ['EXTRA']); await new Promise(resolve => setImmediate(resolve)); assert.equal(checks.length, 1, 'the joiner must wait for the flight'); extraLive = true; release(); - await first; + const firstResult = await first; + assert.equal(firstResult.ok, outcome === 'succeeded'); const result = await second; + if (outcome === 'succeeded') { + assert.equal(checks.length, 1, 'a successful flight must satisfy every joiner'); + assert.equal(result.ok, true); + assert.equal(result.attached, true); + assert.equal(ids.includes('extra'), false, 'success must not run a second guard'); + assert.equal(bg.calls.filter(call => call[0] === 'verb').length, 1); + return; + } assert.equal(checks.length, 2, 'different parameters must run their own check'); assert.deepEqual(checks, scenario.startsWith('manual') ? [true, 'all'] : ['all', 'all']); assert.equal(result.ok, scenario === 'manual without extra ids'); if (scenario !== 'manual without extra ids') { assert.equal(result.refused, true); assert.ok(ids.includes('extra'), 'the new extra id must be checked'); - assert.equal(bg.calls.filter(call => call[0] === 'verb').length, 1, 'refused joiner cannot respawn'); } + assert.equal(bg.calls.filter(call => call[0] === 'verb').length, + (outcome === 'rejected' ? 1 : 0) + (scenario === 'manual without extra ids' ? 1 : 0), 'only a permitted joiner may retry'); }); } +} test('1020fix4 compatible subset joins the same respawn result', async () => { const { ipcMain } = fakeIpc(); diff --git a/test/bg-duplicate-1020.test.js b/test/bg-duplicate-1020.test.js index c72339e39..d4b1b826e 100644 --- a/test/bg-duplicate-1020.test.js +++ b/test/bg-duplicate-1020.test.js @@ -28,8 +28,8 @@ function fixture(t, state = 'stopped') { fs.writeFileSync(path.join(jobsDir, id, 'state.json'), line({ state: jobState, sessionId, resumeSessionId: sessionId, linkScanPath: path.join(root, sessionId + '.jsonl') })); }; writeJob('aaaaaaaa', SID, state); - const descriptors = []; - const cli = { readAllDescriptors: () => descriptors, onDescriptorsChanged: () => () => {}, + const descriptors = [], descriptorListeners = new Set(); + const cli = { readAllDescriptors: () => descriptors, onDescriptorsChanged: fn => { descriptorListeners.add(fn); return () => descriptorListeners.delete(fn); }, liveElsewhereChecked: async () => ({ known: true, live: null }), refreshProcessTable: async () => {}, readWorkerProcessEvidence: () => ({ processTableFresh: true, tableEntry: () => undefined }) }; bg.init({ jobsDir, homeDir: root, cliSessionState: cli, resolveTranscriptPath: id => path.join(root, id + '.jsonl') }); @@ -45,7 +45,11 @@ function fixture(t, state = 'stopped') { bgAgents: { ...bg, runVerb: async (...args) => { calls.push(args); if (delivery.wait) await delivery.wait; if (delivery.autoAttach) descriptors.push({ kind: 'bg', jobId: 'aaaaaaaa', sessionId: bg.findRestoreJob(SID, 'aaaaaaaa').job.sessionId }); return { ok: true }; } }, cliSessionState: cli, getMainWindow: () => null, log: { warn() {} }, activeSessions: new Map(), sessionHasPty: () => false, ptyPids: () => [], getSetting: () => ({ openWorkingSet: [{ sessionId: SID, bg: true, attachJobId: 'aaaaaaaa' }] }) }); - return { root, file, descriptors, fork, calls, handlers, writeJob, cli, delivery }; + const publishWorker = () => { + descriptors.push({ kind: 'bg', jobId: 'aaaaaaaa', sessionId: SID }); + for (const fn of descriptorListeners) fn(); + }; + return { root, file, descriptors, fork, calls, handlers, writeJob, cli, delivery, publishWorker }; } function handoff(h, { exitAt = 600, forkAt = Infinity, uncertain = false, descriptor = true, prior = false } = {}) { @@ -272,10 +276,11 @@ for (const prefix of ['{"parentUuid":null,"message":', '{"type":"assistant","mes } } +for (const publication of ['immediate', 'delayed']) { for (const mode of ['clicks', 'click and restore', 'restore and click']) { - test('1020fix3 concurrent ' + mode + ' invoke one respawn per job', async t => { + test('1020fix5 concurrent ' + mode + ' with ' + publication + ' worker publication invoke one respawn per job', async t => { const h = fixture(t, 'done'); - h.delivery.autoAttach = true; + h.delivery.autoAttach = publication === 'immediate'; let release; h.delivery.wait = new Promise(resolve => { release = resolve; }); const click = () => h.handlers.get('bg-agent-verb')({}, 'respawn', 'aaaaaaaa'); @@ -286,22 +291,25 @@ for (const mode of ['clicks', 'click and restore', 'restore and click']) { await new Promise(resolve => setImmediate(resolve)); const count = h.calls.length; release(); + await new Promise(resolve => setImmediate(resolve)); + const settledCount = h.calls.length; + if (publication === 'delayed') h.publishWorker(); const results = await Promise.all([first, second]); assert.equal(count, 1, 'concurrent requests must invoke ONE respawn'); + assert.equal(settledCount, 1, 'joiners must not respawn again before worker publication'); assert.ok(results[0].ok || results[0].action === 'attach'); if (mode === 'clicks') assert.equal(results[1].ok, true); else if (mode === 'click and restore') { - assert.equal(results[1].action, 'refuse', 'restore rechecks the worker started by the manual flight'); - assert.match(results[1].reason, /cannot respawn.*session; stop it first/); + assert.equal(results[1].action, 'attach', 'restore joins the successful manual flight'); } else { - assert.equal(results[1].ok, false, 'manual joiner rechecks the worker started by restore'); - assert.match(results[1].error, /cannot respawn.*session; stop it first/); + assert.equal(results[1].ok, true, 'manual joiner shares the successful restore flight'); } h.descriptors.length = 0; assert.equal((await click()).ok, true, 'a completed flight must allow a later retry'); assert.equal(h.calls.length, 2); }); } +} for (const state of ['stopped', 'failed']) { test('1020fix2 ' + state + ' continuation permits Respawn while daemon runs', async t => {