Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
d1cebdb
docs(claude): plan a feed marker where the proxy transport lost event…
Juliusolsson05 Sep 27, 2026
91b05b0
feat(feed): a work-slot marker for a turn sealed by a lost proxy span…
Juliusolsson05 Sep 27, 2026
153f4e2
docs(claude): #1381 is option B, a durable gap row (owner-approved by…
Juliusolsson05 Sep 27, 2026
2d05819
revert: withdraw the #1381 work-slot marker (option B supersedes it)
Juliusolsson05 Sep 27, 2026
f249ae4
fix(claude): a durable feed row where the proxy transport lost live o…
Juliusolsson05 Sep 27, 2026
527bdd7
docs(claude): #1381 plan sections describe what was built (steering q…
Juliusolsson05 Sep 27, 2026
fe4e420
docs(claude): #1381 lifetime is the app run, per B6's q119/q120 answe…
Juliusolsson05 Sep 27, 2026
4ffcdef
docs(claude): comments state the gap row's app-run lifetime (#1445)
Juliusolsson05 Sep 27, 2026
3f3e5fb
docs(claude): #1381 decision 3 (idle after a seal) is owner-approved …
Juliusolsson05 Sep 27, 2026
a449513
chore(deps): bump claude-code-headless to #69's merge (0928344e) (#14…
Juliusolsson05 Sep 27, 2026
899a911
fix(claude): key a transport gap by the Claude conversation from its …
Juliusolsson05 Sep 27, 2026
5d4502d
fix(feed): one gap cap shared by main and the live feed; pin the tap'…
Juliusolsson05 Sep 27, 2026
3079e3d
test(feed): pin the gap rows' IPC delivery, the reused adapter's repa…
Juliusolsson05 Sep 27, 2026
40bf9a3
Merge remote-tracking branch 'origin/main' into fix/claude-proxy-gap-…
Juliusolsson05 Sep 27, 2026
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
335 changes: 335 additions & 0 deletions docs/plans/2026-09-27-claude-proxy-gap-marker.md

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions src/main/ipc/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ vi.mock('@main/window/windowRegistry.js', () => ({
}))

// The sub-agent watcher polls real directories; nothing here is about fleets.
// #1442 review c: the durable gap rows cross main -> renderer on `session:load-initial-history`,
// and nothing pinned that handler. The transcript read itself is historyLoader's (tested there);
// here it returns one fixed chunk so the test is about what the handler adds to it.
const history = vi.hoisted(() => ({ chunk: null as null | Record<string, unknown> }))
vi.mock('@main/sessions/historyLoader.js', async importOriginal => {
const actual = await importOriginal<typeof import('@main/sessions/historyLoader.js')>()
return {
...actual,
loadInitialHistoryChunk: async (...args: Parameters<typeof actual.loadInitialHistoryChunk>) =>
history.chunk ? { ...history.chunk } : actual.loadInitialHistoryChunk(...args),
}
})
vi.mock('@main/subagents/index.js', () => ({ SubAgentWatcherManager: class { observeParentEntry() {} stop() {} stopAll() {} } }))

const { registerSessionIpc, classifySpawnFailure } = await import('./session.js')
Expand Down Expand Up @@ -73,6 +85,27 @@ it('transports generated-task draft protection from preload through main without
expect(deliverPromptToAgent).toHaveBeenCalledExactlyOnceWith('s1', 'Restart the server', undefined, undefined, undefined, { requireEmptyNativeComposer: true })
})

describe('a conversation\'s transport-gap rows on the initial history chunk (#1381)', () => {
it('rides the chunk for the conversation that lost data, and only for it', async () => {
const { TransportGapLedger } = await import('@main/sessions/transportGapLedger.js')
const ledger = new TransportGapLedger()
const held = ledger.record('conv-1', { since: 1_000, until: 2_000, lostGenerations: 1 })
history.chunk = { entries: [{ type: 'user' }], hasMore: false }
try {
registerSessionIpc({ getTransportGaps: (id: string) => ledger.list(id) } as never, {} as never, { flushCommitted: () => {} })
// Through the real preload call, as a feed rebuild makes it.
const withGap = await sessionApi.loadInitialHistory({ kind: 'claude', cwd: '/p', providerSessionId: 'conv-1' })
expect(withGap).toEqual({ entries: [{ type: 'user' }], hasMore: false, transportGaps: [held] })
// A conversation that lost nothing gets the chunk byte-identical, with no key at all.
const without = await sessionApi.loadInitialHistory({ kind: 'claude', cwd: '/p', providerSessionId: 'conv-2' })
expect(without).toEqual({ entries: [{ type: 'user' }], hasMore: false })
expect('transportGaps' in without).toBe(false)
} finally {
history.chunk = null
}
})
})

describe('recovered renderer screen seed', () => {
it.each([
{ ok: true, destroyed: false, available: true, sends: 1 },
Expand Down
8 changes: 7 additions & 1 deletion src/main/ipc/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,10 +584,16 @@ export function registerSessionIpc(
limit?: number
},
) => {
return await loadInitialHistoryChunk({
const chunk = await loadInitialHistoryChunk({
...params,
limit: params.limit ?? 120,
})
// #1381: the conversation's durable "not captured" rows ride the initial chunk, the one
// request every feed rebuild makes (window reload, agent reload, resume elsewhere). Omitted
// when there are none, so the chunk is byte-identical for every conversation that lost
// nothing.
const transportGaps = manager.getTransportGaps(params.providerSessionId)
return transportGaps.length > 0 ? { ...chunk, transportGaps: [...transportGaps] } : chunk
},
)

Expand Down
3 changes: 3 additions & 0 deletions src/main/remote/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ export type OutboundFrame =
// every other v2 frame: a phone bundle that predates them finds no
// listener set for the channel and drops the frame.
| 'transcript-diagnostic'
// #1381: a durable "not captured" row's record. Additive, dropped by
// phone bundles with no listener.
| 'transport-gap'
| 'provider-session-changed'
| 'semantic-event'
| 'conditions'
Expand Down
94 changes: 91 additions & 3 deletions src/main/sessionManager.proxyGap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@ vi.mock('@main/storage/feedDebugLog.js', () => ({
forgetFeedDebugSession: vi.fn(),
}))

// Shaped like the REAL ClaudeSession (#1442 review a): it has NO getProviderSessionId. An earlier
// fake implemented one, so every test passed while a real Claude gap always took the live-only
// path and its row vanished on the first reload. Claude announces its conversation only through
// its transcript: every JSONL entry carries `sessionId`, and the file is `<sessionId>.jsonl`. That
// is also where the renderer takes the pane's providerSessionId from.
class FakeAgentSession extends EventEmitter {
/** A committed transcript entry of `conversationId`, as the Claude tailer emits it. */
entry(conversationId: string): void {
this.emit('jsonl-entry', { type: 'user', sessionId: conversationId, uuid: `u-${conversationId}-${this.listenerCount('jsonl-entry')}` }, `/home/.claude/projects/p/${conversationId}.jsonl`)
}

async start(): Promise<void> {
this.emit('started', { projectDir: '/tmp/project' })
}
Expand Down Expand Up @@ -78,17 +88,95 @@ describe('a Claude proxy transport gap', () => {
manager.on('proxy-transport-gap', gap => { gaps.push(gap) })

await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' })
session.emit('proxy-transport-gap', { lostGenerations: 3 })
session.emit('proxy-transport-gap', { lostGenerations: 3, since: 1_000, until: 9_000 })

expect(gaps).toEqual([{ sessionId: 's1', lostGenerations: 3 }])
const record = { id: expect.any(String), since: 1_000, until: 9_000, lostGenerations: 3 }
expect(gaps).toEqual([{ sessionId: 's1', gap: record }])
expect(incidents).toContainEqual(expect.objectContaining({
kind: 'claude.proxy_transport_gap',
context: { sessionId: 's1', lostGenerations: 3 },
context: { sessionId: 's1', lostGenerations: 3, since: 1_000, until: 9_000 },
}))
})

// #1381 option B (owner-approved by B6): the gap is a DURABLE feed row. A renderer that reloads
// rebuilds its feed from main, and an agent reload respawns under the same id — the record must
// survive both, which is why it is not one of the caches that die with the process.
it('is held for its conversation, surviving the respawn an agent reload does', async () => {
const { SessionManager } = await import('./sessionManager')
const first = new FakeAgentSession()
const second = new FakeAgentSession()
createSession.mockImplementationOnce(() => first).mockImplementationOnce(() => second)
const journal = { recordIncident: vi.fn(), record: vi.fn(), recordError: vi.fn() }
const manager = new SessionManager(null, null, journal as never)

expect(manager.getTransportGaps('conv-1')).toEqual([])
await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' })
first.entry('conv-1')
first.emit('proxy-transport-gap', { lostGenerations: 1, since: null, until: 5_000 })
first.emit('exit', { exitCode: 0 })
// An agent reload resumes the same conversation (`--resume conv-1`). The gap can land before
// the respawned tailer has emitted any entry: the resume id is the conversation then.
await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project', resumeSessionId: 'conv-1' })
second.emit('proxy-transport-gap', { lostGenerations: 2, since: 6_000, until: 7_000 })

const held = manager.getTransportGaps('conv-1')
expect(held.map(gap => [gap.since, gap.until, gap.lostGenerations])).toEqual([[null, 5_000, 1], [6_000, 7_000, 2]])
expect(new Set(held.map(gap => gap.id)).size).toBe(2)
expect(manager.getTransportGaps('s1')).toEqual([])
})

// A gap is a fact about one conversation. A pane that moves to a new conversation (Claude /clear)
// must not carry the old conversation's row into it.
it('does not follow the pane into a new conversation', async () => {
const { SessionManager } = await import('./sessionManager')
const session = new FakeAgentSession()
createSession.mockImplementationOnce(() => session)
const manager = new SessionManager(null, null, { recordIncident: vi.fn(), record: vi.fn(), recordError: vi.fn() } as never)
await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' })
session.entry('conv-1')
session.emit('proxy-transport-gap', { lostGenerations: 1, since: 1, until: 2 })
// Claude /clear: the same process starts writing a new conversation's transcript.
session.entry('conv-2')
session.emit('proxy-transport-gap', { lostGenerations: 1, since: 3, until: 4 })
expect(manager.getTransportGaps('conv-1').map(gap => gap.until)).toEqual([2])
expect(manager.getTransportGaps('conv-2').map(gap => gap.until)).toEqual([4])
})

// Focused review of #1376 (c): a replaced session's late gap must not be recorded against the
// session id its successor now owns.
// A fresh conversation spawned into the same pane (no resume) must not inherit the previous
// process's conversation before its own transcript has said anything.
it('does not key a fresh spawn\'s gap to the pane\'s previous conversation', async () => {
const { SessionManager } = await import('./sessionManager')
const first = new FakeAgentSession()
const second = new FakeAgentSession()
createSession.mockImplementationOnce(() => first).mockImplementationOnce(() => second)
const manager = new SessionManager(null, null, { recordIncident: vi.fn(), record: vi.fn(), recordError: vi.fn() } as never)
await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' })
first.entry('conv-1')
first.emit('exit', { exitCode: 0 })
await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' })
second.emit('proxy-transport-gap', { lostGenerations: 1, since: 1, until: 2 })
expect(manager.getTransportGaps('conv-1')).toEqual([])
})

// #1442 review a: two lost spans in ONE poll share the poll's `until`. With no conversation id
// yet, the live-only ids were `gap-live-<session>-<until>`, so the renderer's id merge kept one
// row and hid the other lost span.
it('gives two live-only gaps of one poll distinct ids', async () => {
const { SessionManager } = await import('./sessionManager')
const session = new FakeAgentSession()
createSession.mockImplementationOnce(() => session)
const manager = new SessionManager(null, null, { recordIncident: vi.fn(), record: vi.fn(), recordError: vi.fn() } as never)
const ids: string[] = []
manager.on('proxy-transport-gap', ({ gap }: { gap: { id: string } }) => { ids.push(gap.id) })
await manager.recover({ sessionId: 's1', kind: 'claude', cwd: '/tmp/project' })
session.emit('proxy-transport-gap', { lostGenerations: 1, since: 1_000, until: 1_234 })
session.emit('proxy-transport-gap', { lostGenerations: 1, since: 1_000, until: 1_234 })
expect(ids).toHaveLength(2)
expect(new Set(ids).size).toBe(2)
})

it('ignores a gap from a session that has been replaced', async () => {
const { SessionManager } = await import('./sessionManager')
const first = new FakeAgentSession()
Expand Down
69 changes: 64 additions & 5 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ import { updateToolPaths } from '@main/setup/setupState.js'
import { forgetFeedDebugSession } from '@main/storage/feedDebugLog.js'
import { TerminalReplayBuffer } from '@main/sessions/terminalReplayBuffer.js'
import { ScreenFrameGate } from '@main/sessions/screenFrameGate.js'
import { TransportGapLedger } from '@main/sessions/transportGapLedger.js'
import type { SessionTransportGapEvent } from '@shared/sessionFeed/types.js'
import type { TransportGapRecord } from '@shared/types/session.js'
import type { TransportGap } from 'claude-code-headless'
import type {
ConditionCustomAction,
ProviderConditionSnapshot,
Expand Down Expand Up @@ -195,7 +199,8 @@ type ManagerEvents = {
observation?: AgentTranscriptObservationMetadata
}]
'jsonl-error': [{ sessionId: string; error: Error }]
'proxy-transport-gap': [{ sessionId: string; lostGenerations: number }]
/** A durable feed row's record (#1381); also held for reseeds (getTransportGaps). */
'proxy-transport-gap': [SessionTransportGapEvent]
/** Durable-history generation boundary (grok). Never completion or idle;
* consumers apply renderer/session-runtime/historyBoundary.ts decisions. */
'history-boundary': [{ sessionId: string; type: 'reset' | 'caught-up'; generation: number; snapshotByteLength: number; byteOffset?: number; complete?: boolean; file: string }]
Expand Down Expand Up @@ -572,7 +577,7 @@ export type ResolveConditionResult =

/** The one Claude-only event SessionManager subscribes to (ClaudeSessionEvents declares it). */
type ProxyGapSource = {
on(event: 'proxy-transport-gap', listener: (gap: { lostGenerations: number }) => void): unknown
on(event: 'proxy-transport-gap', listener: (gap: TransportGap) => void): unknown
}

export class SessionManager extends EventEmitter {
Expand Down Expand Up @@ -665,6 +670,10 @@ export class SessionManager extends EventEmitter {
// See screenFrameGate.ts — drops spinner-only repaints before they fan out.
private readonly screenFrameGate = new ScreenFrameGate()
private readonly lastConditionsSnapshot = new Map<string, ProviderConditionSnapshot>()
// #1381: NOT one of the generation-owned caches above — the durable gap rows
// must survive the respawn an agent reload does under the same id. See
// TransportGapLedger for the lifetime and bounds.
private readonly transportGaps = new TransportGapLedger()
private readonly lastInputReadiness = new Map<string, SessionInputReadiness>()
// WHY this is one manager-global sequence instead of a bounded per-id map:
// a persisted pane may reuse its stable local id after arbitrarily many
Expand All @@ -691,6 +700,27 @@ export class SessionManager extends EventEmitter {
// every history chunk, and the phone's TranscriptStore discards a chunk
// whose file disagrees with the file its live frames carry.
private readonly lastTranscriptFile = new Map<string, string>()
/**
* The Claude conversation each live Claude session is writing (#1381, #1442 review a), from the
* `sessionId` every committed JSONL entry carries. It is the key TransportGapLedger holds a gap
* under, and the renderer sends the SAME value back as `providerSessionId` when it rebuilds a
* feed (it captures it from the same entries).
*
* WHY a map here and not `getProviderSessionId()`: ClaudeSession does not implement that getter,
* and adding it would change what backend snapshots and spawn results report for every Claude
* pane, well outside this fix. The first version relied on the getter anyway; its test fake
* implemented it, so every real Claude gap silently took the live-only path and its row was gone
* after the first reload.
*
* WHY entries and not the spawn's `--session-id`/`--resume` id alone: a `/clear` moves the same
* process to a new conversation, and only its transcript says so. The resume id is only the
* fallback before the respawned tailer has emitted anything. Cleared at teardown with the other
* per-process caches, so a fresh conversation spawned into the same pane never inherits the
* previous one's key (pinned in sessionManager.proxyGap.test.ts).
*/
private readonly claudeConversationIds = new Map<string, string>()
/** Distinct ids for live-only gap rows (see the proxy-transport-gap handler). */
private liveTransportGapSequence = 0
private readonly codexCandidateObservationEdges = new Map<
string,
{
Expand Down Expand Up @@ -1123,6 +1153,7 @@ export class SessionManager extends EventEmitter {
this.screenFrameGate.forget(sessionId)
this.lastConditionsSnapshot.delete(sessionId)
this.lastTranscriptFile.delete(sessionId)
this.claudeConversationIds.delete(sessionId)
this.codexCandidateObservationEdges.delete(sessionId)
this.codexAttachmentObservationState.delete(sessionId)
this.lastInputReadiness.delete(sessionId)
Expand Down Expand Up @@ -3315,6 +3346,10 @@ export class SessionManager extends EventEmitter {
if (!ownsEntry()) return
this.markActivity(sessionId)
this.lastTranscriptFile.set(sessionId, file)
if (kind === 'claude') {
const conversationId = (entry as { sessionId?: unknown } | null)?.sessionId
if (typeof conversationId === 'string' && conversationId) this.claudeConversationIds.set(sessionId, conversationId)
}
if (kind === 'codex' && observation) {
const rollout = entry && typeof entry === 'object'
? entry as unknown as Record<string, unknown>
Expand Down Expand Up @@ -3385,15 +3420,31 @@ export class SessionManager extends EventEmitter {
// hole in the live Claude feed is never silent.
// Claude-only (its ClaudeSessionEvents declares it; other providers have no proxy tail), so it
// is subscribed through that type rather than widening every provider's event map.
if (kind === 'claude') (session as unknown as ProxyGapSource).on('proxy-transport-gap', (gap: { lostGenerations: number }) => {
// #1381: and held as a durable feed row (option B, owner-approved by B6): the event carries
// the record, and getTransportGaps answers a renderer that reloads and rebuilds its feed.
if (kind === 'claude') (session as unknown as ProxyGapSource).on('proxy-transport-gap', (gap: TransportGap) => {
if (!ownsEntry()) return
this.journal?.recordIncident({
kind: 'claude.proxy_transport_gap',
severity: 'warn',
reason: 'events_deleted_unread',
context: { sessionId, lostGenerations: gap.lostGenerations },
context: { sessionId, lostGenerations: gap.lostGenerations, since: gap.since, until: gap.until },
})
this.emit('proxy-transport-gap', { sessionId, lostGenerations: gap.lostGenerations })
const fields = { since: gap.since, until: gap.until, lostGenerations: gap.lostGenerations }
// Held per CONVERSATION (see TransportGapLedger and claudeConversationIds). With no
// conversation id yet the row is live-only: there is no history to rebuild it from. In
// practice the id is known, since a gap needs >= 1 GiB of this session's proxy traffic,
// long after its transcript exists.
const conversationId = this.getNativeConversationId(sessionId)
?? this.claudeConversationIds.get(sessionId)
?? this.spawnInfo.get(sessionId)?.resumeSessionId
?? null
// A live-only id is a sequence, never `until`: two lost spans of one poll share the
// poll's `until`, and the renderer merges rows by id (#1442 review a).
const record = conversationId
? this.transportGaps.record(conversationId, fields)
: { id: `gap-live-${++this.liveTransportGapSequence}`, ...fields }
this.emit('proxy-transport-gap', { sessionId, gap: record })
})
session.on('transcript-diagnostic', (diagnostic: unknown) => {
if (!ownsEntry()) return
Expand Down Expand Up @@ -5760,6 +5811,14 @@ export class SessionManager extends EventEmitter {
return this.lastScreenSnapshot.get(sessionId) ?? null
}

/** Every proxy-transport gap still held for this provider conversation
* (#1381), oldest first, for a feed being rebuilt from its history. Empty
* when it never lost any — an honest answer, because the ledger is the only
* record main keeps and nothing but its bounds ever drops one. */
getTransportGaps(conversationId: string): readonly TransportGapRecord[] {
return this.transportGaps.list(conversationId)
}

getProcessStateSnapshot(sessionId: string): AgentProcessState | null {
return this.lastProcessState.get(sessionId) ?? null
}
Expand Down
Loading
Loading