From 86c38cea5a2a04318d3c9048c715ac340e10bec4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 6 Sep 2026 17:53:10 +0800 Subject: [PATCH] fix(dispatch): preserve generated session titles across reloads --- docs/architecture/detached-task-dispatch.md | 8 ++ .../dispatch/DispatchJobObserver.test.ts | 77 +++++++++++++++++++ .../features/dispatch/DispatchJobObserver.ts | 28 +++++++ .../dispatch/dispatchJobStore.test.ts | 49 ++++++++++++ .../src/features/dispatch/dispatchJobStore.ts | 25 +++++- .../dispatch/dispatchTranscriptCache.ts | 2 + src/web-ui/src/features/dispatch/types.ts | 5 +- 7 files changed, 189 insertions(+), 5 deletions(-) diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index eb478ada40..b8db0d2979 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -351,6 +351,14 @@ corrupt, mismatched, or above the size ceiling replays the job from byte zero. The controller stores the projection verbatim and never interprets it; caching it creates no durable session and acquires no runtime ownership. +The controller also persists the projected session title and whether it came from +a target event or a manual rename. Submission titles in the outbound index are +fallback metadata; polling must not replace a later projected title. Manual names +win over replayed generated-title events. Transcript caches carry this metadata +with their cursor, and caches written before title projection replay once after +an upgrade to recover the title event. This changes controller presentation only; +it does not rename the target-owned job or session. + Target and outbound records are retained for 30 days after terminal state, as are the cached transcripts, which are also dropped as soon as a projection is deleted or archived. Garbage collection never removes queued or running jobs. diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index 8eb6e91737..c4df47c5a8 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -145,6 +145,7 @@ function createContext() { getState: vi.fn(() => ({ sessions })), addExternalSession: vi.fn(), updateSessionDispatchTarget: vi.fn(), + updateSessionTitle: vi.fn(), applyDispatchSnapshot: vi.fn(( sessionId: string, snapshot: { cursor: number; state: string }, @@ -359,6 +360,82 @@ describe('DispatchJobObserver', () => { vi.useRealTimers(); }); + it.each(['ssh', 'device'] as const)('persists %s generated titles and restores them after projection recreation', async (kind) => { + registerRunningJob(); + if (kind === 'device') { + const target = { kind: 'device' as const, deviceId: 'device-1', workspacePath: '/repo', displayName: 'peer' }; + dispatchJobStore.getState().registerJob({ ...dispatchJobStore.getState().jobs['job-1'], target, targetRequest: target }); + mocks.listJobs.mockResolvedValue([{ ...runningOutboundRecord(), target }]); + } + installProcessingProjection(); + const projection = flowChatStore.getState().sessions.get('session-1')!; + projection.config.dispatchTarget = dispatchJobStore.getState().jobs['job-1'].target; + mocks.status.mockResolvedValue(status({ cursor: 50, events: [{ + type: 'agentEvent', timestamp: '2026-07-28T00:00:02Z', + event: { id: 'title-1', event: { type: 'SessionTitleGenerated', session_id: 'session-1', title: 'Investigate build failure' } }, + }] })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + await vi.advanceTimersByTimeAsync(0); + expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('Investigate build failure'); + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ title: 'Investigate build failure', titleSource: 'generated' }); + cleanup(); + + // A recreated renderer resumes from its paired transcript cursor, which + // may already be past the title event, while the index still has the default. + const storage = dispatchJobStore.persist.getOptions().storage!; + const persisted = (await storage.getItem('openbitfun-dispatch-jobs-v1'))!; + dispatchJobStore.setState({ jobs: {} }); + await storage.setItem('openbitfun-dispatch-jobs-v1', persisted); + await dispatchJobStore.persist.rehydrate(); + flowChatStore.setState(() => ({ sessions: new Map(), activeSessionId: null })); + mocks.loadTranscript.mockResolvedValue(cachedTranscript({ cursor: 50 })); + mocks.status.mockResolvedValue(status({ cursor: 50, events: [] })); + const restored = installDispatchJobObserver(createTerminalContext()); + await vi.advanceTimersByTimeAsync(0); + expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('Investigate build failure'); + restored(); + }); + + it('keeps a manual name when a legacy target title is replayed', async () => { + registerRunningJob(); + dispatchJobStore.getState().updateTitle('job-1', 'My investigation'); + installProcessingProjection(); + mocks.status.mockResolvedValue(status({ cursor: 50, events: [{ + type: 'agentEvent', timestamp: '2026-07-28T00:00:02Z', + event: { frontendEventName: 'session_title_generated', frontendPayload: { sessionId: 'session-1', title: 'Old generated name' } }, + }] })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + await vi.advanceTimersByTimeAsync(0); + expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('My investigation'); + expect(dispatchJobStore.getState().jobs['job-1'].title).toBe('My investigation'); + cleanup(); + }); + + it('replays a pre-title transcript cache so the generated name is recovered after upgrade', async () => { + registerRunningJob({ cursor: 50, appliedEventIds: ['title-1'] }); + mocks.loadTranscript.mockResolvedValue(cachedTranscript({ schemaVersion: 4, cursor: 50 })); + mocks.status.mockResolvedValue(status({ cursor: 50, events: [{ + type: 'agentEvent', timestamp: '2026-07-28T00:00:02Z', + event: { id: 'title-1', event: { type: 'SessionTitleGenerated', session_id: 'session-1', title: 'Recovered title' } }, + }] })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.status).toHaveBeenCalledWith('job-1', 0); + expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('Recovered title'); + cleanup(); + }); + + it('restores title metadata from the transcript when the renderer cache is missing', async () => { + mocks.listJobs.mockResolvedValue([runningOutboundRecord()]); + mocks.loadTranscript.mockResolvedValue(cachedTranscript({ title: 'Cached investigation', titleSource: 'generated' })); + mocks.status.mockResolvedValue(status({ cursor: 50, events: [] })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + await vi.advanceTimersByTimeAsync(0); + expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('Cached investigation'); + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ title: 'Cached investigation', titleSource: 'generated' }); + cleanup(); + }); + it('projects raw target events into the existing frontend event contract', () => { const projected = projectDispatchAgentEvent({ type: 'agentEvent', diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index 3da7c11492..da94d6c77c 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -271,6 +271,16 @@ async function ensureProjection( cached.dialogTurns as DialogTurn[], ); if (hydrated && cached) { + const current = dispatchJobStore.getState().jobs[job.jobId]; + if ( + !current?.titleSource + && typeof cached.title === 'string' + && cached.title.trim() + && (cached.titleSource === 'generated' || cached.titleSource === 'manual') + ) { + dispatchJobStore.getState().updateTitle(job.jobId, cached.title, cached.titleSource); + void context.flowChatStore.updateSessionTitle(job.sessionId, cached.title, 'generated'); + } bindTarget(cached.cursor, true); // The cache, not the persisted renderer state, decides where to resume. // The two are written separately, so the renderer's own cursor can be @@ -313,6 +323,9 @@ async function ensureProjection( // The observer can start before FlowChat knows its workspace, so a legacy // outbound record may only gain its source path on a later poll. bindTarget(job.cursor); + if (job.titleSource && existing.title !== job.title) { + void context.flowChatStore.updateSessionTitle(job.sessionId, job.title, 'generated'); + } // Startup metadata can win the race and create this session before the // observer. Such a session has no turns, so merely binding the dispatch // target would leave the navigation row permanently empty and allow the @@ -608,6 +621,21 @@ function applyEvent( log.debug('Ignoring unprojectable target agent event', { event }); return true; } + if ( + projected.eventName === 'session_title_generated' + && projected.payload.sessionId === job.sessionId + && typeof projected.payload.title === 'string' + && projected.payload.title.trim() + ) { + // A title is projection metadata too: keep it with the persisted observer + // before advancing its cursor. Manual names win over replayed target events. + dispatchJobStore.getState().updateTitle(job.jobId, projected.payload.title, 'generated'); + const title = dispatchJobStore.getState().jobs[job.jobId]?.title; + if (title) { + void context.flowChatStore.updateSessionTitle(job.sessionId, title, 'generated'); + } + return true; + } const applied = agenticEventListener.dispatchExternal( projected.eventName, projected.payload, diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts index b4ebc1aff8..6a34cac232 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.test.ts @@ -42,6 +42,55 @@ describe('dispatchJobStore', () => { dispatchJobStore.getState().clear(); }); + it.each(['generated', 'manual'] as const)('preserves a %s title across reload and an old outbound payload', async (source) => { + registerJob(); + dispatchJobStore.getState().updateTitle('job-1', 'Investigate build failure', source); + const storage = dispatchJobStore.persist.getOptions().storage!; + const saved = (await storage.getItem('openbitfun-dispatch-jobs-v1'))!; + dispatchJobStore.setState({ jobs: {} }); + await storage.setItem('openbitfun-dispatch-jobs-v1', saved); + await dispatchJobStore.persist.rehydrate(); + const job = dispatchJobStore.getState().jobs['job-1']; + dispatchJobStore.getState().mergeOutboundRecords([{ + jobId: job.jobId, + sessionId: job.sessionId, + target: job.target, + sourceWorkspacePath: '/source', + workspacePath: '/repo', + promptPreview: 'Dispatch test', + title: 'Dispatch test', + lastCursor: 900, + lastState: 'running', + createdAt: '2026-07-28T00:00:00Z', + updatedAt: '2026-07-28T00:00:01Z', + }]); + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + title: 'Investigate build failure', titleSource: source, + }); + }); + + it('preserves legacy manual names without titleSource when the index still has the submission name', async () => { + registerJob(); + const storage = dispatchJobStore.persist.getOptions().storage!; + const legacy = (await storage.getItem('openbitfun-dispatch-jobs-v1'))!; + legacy.state.jobs['job-1'].title = 'My investigation'; + delete legacy.state.jobs['job-1'].titleSource; + await storage.setItem('openbitfun-dispatch-jobs-v1', legacy); + await dispatchJobStore.persist.rehydrate(); + const job = dispatchJobStore.getState().jobs['job-1']; + dispatchJobStore.getState().mergeOutboundRecords([{ + jobId: job.jobId, sessionId: job.sessionId, target: job.target, + sourceWorkspacePath: '/source', workspacePath: '/repo', + promptPreview: 'Dispatch test', title: 'Dispatch test', + lastCursor: 10, lastState: 'running', + createdAt: '2026-07-28T00:00:00Z', updatedAt: '2026-07-28T00:00:01Z', + }]); + dispatchJobStore.getState().updateTitle('job-1', 'Replayed generated name', 'generated'); + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + title: 'My investigation', titleSource: 'manual', + }); + }); + it('keeps cursors monotonic and clears terminal-drained state on progress', () => { registerJob(); dispatchJobStore.getState().updateProgress('job-1', { diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.ts index 739b534c64..24e2a3fe0e 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.ts @@ -133,6 +133,8 @@ export interface DispatchObserverJob { sourceWorkspacePath?: string; sourceWorkspaceId?: string; title: string; + /** Controller projection metadata; absent on legacy renderer snapshots. */ + titleSource?: 'generated' | 'manual'; agentType: string; approvalPolicy: DispatchApprovalPolicy; /** Baseline branch on the controller, once the backend has resolved one. */ @@ -219,7 +221,7 @@ interface DispatchJobStoreState { omittedEventCount: number; }, ) => void; - updateTitle: (jobId: string, title: string) => void; + updateTitle: (jobId: string, title: string, source?: 'generated' | 'manual') => void; updateModel: (jobId: string, model: string) => void; updateReasoningPreset: (jobId: string, preset: string) => void; updateApprovalPolicy: (jobId: string, policy: DispatchApprovalPolicy) => void; @@ -403,7 +405,16 @@ export const useDispatchJobStore = create()( sourceWorkspacePath, sourceWorkspaceId: record.sourceWorkspaceId || existing.sourceWorkspaceId, - title: record.title || existing.title, + // The index keeps the submission name. Later observer titles + // live in this persisted projection, including old manual names + // written before titleSource was recorded. + title: existing.titleSource || (existing.title && existing.title !== record.title) + ? existing.title + : record.title || existing.title, + titleSource: existing.titleSource + ?? (record.title && existing.title && existing.title !== record.title + ? 'manual' + : undefined), agentType: record.agentType || existing.agentType, approvalPolicy: record.approvalPolicy || existing.approvalPolicy, model: record.model || existing.model, @@ -629,11 +640,16 @@ export const useDispatchJobStore = create()( }); }, - updateTitle: (jobId, title) => { + updateTitle: (jobId, title, source = 'manual') => { set(state => { const current = state.jobs[jobId]; const normalizedTitle = title.trim(); - if (!current || !normalizedTitle || current.title === normalizedTitle) { + if ( + !current + || !normalizedTitle + || (source === 'generated' && current.titleSource === 'manual') + || (current.title === normalizedTitle && current.titleSource === source) + ) { return state; } return { @@ -642,6 +658,7 @@ export const useDispatchJobStore = create()( [jobId]: { ...current, title: normalizedTitle, + titleSource: source, updatedAt: Date.now(), }, }, diff --git a/src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts b/src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts index 5736b364f6..ca37d6e4cf 100644 --- a/src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts +++ b/src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts @@ -87,6 +87,8 @@ export function scheduleDispatchTranscriptSave( } pendingPayloads.set(job.jobId, { schemaVersion: DISPATCH_TRANSCRIPT_SCHEMA_VERSION, + title: job.title, + titleSource: job.titleSource, jobId: job.jobId, sessionId: job.sessionId, cursor, diff --git a/src/web-ui/src/features/dispatch/types.ts b/src/web-ui/src/features/dispatch/types.ts index a3f9e2679c..f84e135a92 100644 --- a/src/web-ui/src/features/dispatch/types.ts +++ b/src/web-ui/src/features/dispatch/types.ts @@ -272,7 +272,8 @@ export interface OutboundDispatchRecord { * the only thing standing between a projection change and a transcript rendered * by rules that no longer exist. */ -export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 4; +// v5 persists title projection metadata; old caches replay title events once. +export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 5; /** * The controller's UI cache for one observer projection. @@ -283,6 +284,8 @@ export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 4; */ export interface DispatchTranscriptCache { schemaVersion: number; + title?: string; + titleSource?: 'generated' | 'manual'; jobId: string; sessionId: string; cursor: number;