Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 80 additions & 14 deletions .ai/contexts/bg-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,24 @@ 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
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
coalesces overlapping requests. Descriptor observation subscribes before its
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` 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.
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
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`
Expand Down Expand Up @@ -94,8 +106,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. The #1020 policy retains #796's
automatic recovery for dead `working`, `done`, `running` and `failed`, with manual recovery
for dead `blocked` and `stopped`.

## Invariants

Expand Down Expand Up @@ -181,7 +194,20 @@ 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`, 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
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)

Expand Down Expand Up @@ -1061,16 +1087,55 @@ 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.
Before writing either command, main checks existing continuations with worker
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. 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;
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 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; 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
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.
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 automatic
recovery for dead `working`, `done`, `running` and `failed`; dead `blocked` waits for 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;
Expand All @@ -1082,8 +1147,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.


Expand Down
14 changes: 10 additions & 4 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<old>,
continuedInSessionId:<new>, 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,
Expand All @@ -736,8 +737,13 @@ 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 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.

The traversal also returns `missing: true` when an all-missing branch was seen
Expand Down
5 changes: 5 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
- New sandboxed sessions protect the triggers directory even when it sits inside a project or another writable directory, and refuse layouts that cannot protect it safely. This protection does not apply when Podman access is enabled. (#1022)

### Fixed
- 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
Expand Down
11 changes: 8 additions & 3 deletions docs/background-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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
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 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
Expand Down Expand Up @@ -220,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
Expand Down
6 changes: 3 additions & 3 deletions docs/session-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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: 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
Expand Down
18 changes: 15 additions & 3 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -1946,9 +1946,21 @@ 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) {
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 };
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]]) });
Expand Down
12 changes: 12 additions & 0 deletions session-continuations.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ function scanContinuationIndex(file, sessionId, previous, maxBytes = 1024 * 1024
const before = invalid;
tailValid = consume(pending);
tailUnresolved = invalid && !before;
if (!tailValid) {
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;
}
return JSON.stringify({ format: 3, version: 3, ids: [...ids], sealedIds, bytes: position,
Expand Down
Loading
Loading