Skip to content

Commit eb3962c

Browse files
authored
Merge pull request #2842 from bobleer/bob/dispatch-generated-title-stability
fix(dispatch): preserve generated session titles across reloads
2 parents b3e2d3b + 86c38ce commit eb3962c

7 files changed

Lines changed: 189 additions & 5 deletions

File tree

‎docs/architecture/detached-task-dispatch.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,14 @@ corrupt, mismatched, or above the size ceiling replays the job from byte zero.
351351
The controller stores the projection verbatim and never interprets it; caching
352352
it creates no durable session and acquires no runtime ownership.
353353

354+
The controller also persists the projected session title and whether it came from
355+
a target event or a manual rename. Submission titles in the outbound index are
356+
fallback metadata; polling must not replace a later projected title. Manual names
357+
win over replayed generated-title events. Transcript caches carry this metadata
358+
with their cursor, and caches written before title projection replay once after
359+
an upgrade to recover the title event. This changes controller presentation only;
360+
it does not rename the target-owned job or session.
361+
354362
Target and outbound records are retained for 30 days after terminal state, as
355363
are the cached transcripts, which are also dropped as soon as a projection is
356364
deleted or archived. Garbage collection never removes queued or running jobs.

‎src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ function createContext() {
145145
getState: vi.fn(() => ({ sessions })),
146146
addExternalSession: vi.fn(),
147147
updateSessionDispatchTarget: vi.fn(),
148+
updateSessionTitle: vi.fn(),
148149
applyDispatchSnapshot: vi.fn((
149150
sessionId: string,
150151
snapshot: { cursor: number; state: string },
@@ -359,6 +360,82 @@ describe('DispatchJobObserver', () => {
359360
vi.useRealTimers();
360361
});
361362

363+
it.each(['ssh', 'device'] as const)('persists %s generated titles and restores them after projection recreation', async (kind) => {
364+
registerRunningJob();
365+
if (kind === 'device') {
366+
const target = { kind: 'device' as const, deviceId: 'device-1', workspacePath: '/repo', displayName: 'peer' };
367+
dispatchJobStore.getState().registerJob({ ...dispatchJobStore.getState().jobs['job-1'], target, targetRequest: target });
368+
mocks.listJobs.mockResolvedValue([{ ...runningOutboundRecord(), target }]);
369+
}
370+
installProcessingProjection();
371+
const projection = flowChatStore.getState().sessions.get('session-1')!;
372+
projection.config.dispatchTarget = dispatchJobStore.getState().jobs['job-1'].target;
373+
mocks.status.mockResolvedValue(status({ cursor: 50, events: [{
374+
type: 'agentEvent', timestamp: '2026-07-28T00:00:02Z',
375+
event: { id: 'title-1', event: { type: 'SessionTitleGenerated', session_id: 'session-1', title: 'Investigate build failure' } },
376+
}] }));
377+
const cleanup = installDispatchJobObserver(createTerminalContext());
378+
await vi.advanceTimersByTimeAsync(0);
379+
expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('Investigate build failure');
380+
expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ title: 'Investigate build failure', titleSource: 'generated' });
381+
cleanup();
382+
383+
// A recreated renderer resumes from its paired transcript cursor, which
384+
// may already be past the title event, while the index still has the default.
385+
const storage = dispatchJobStore.persist.getOptions().storage!;
386+
const persisted = (await storage.getItem('openbitfun-dispatch-jobs-v1'))!;
387+
dispatchJobStore.setState({ jobs: {} });
388+
await storage.setItem('openbitfun-dispatch-jobs-v1', persisted);
389+
await dispatchJobStore.persist.rehydrate();
390+
flowChatStore.setState(() => ({ sessions: new Map(), activeSessionId: null }));
391+
mocks.loadTranscript.mockResolvedValue(cachedTranscript({ cursor: 50 }));
392+
mocks.status.mockResolvedValue(status({ cursor: 50, events: [] }));
393+
const restored = installDispatchJobObserver(createTerminalContext());
394+
await vi.advanceTimersByTimeAsync(0);
395+
expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('Investigate build failure');
396+
restored();
397+
});
398+
399+
it('keeps a manual name when a legacy target title is replayed', async () => {
400+
registerRunningJob();
401+
dispatchJobStore.getState().updateTitle('job-1', 'My investigation');
402+
installProcessingProjection();
403+
mocks.status.mockResolvedValue(status({ cursor: 50, events: [{
404+
type: 'agentEvent', timestamp: '2026-07-28T00:00:02Z',
405+
event: { frontendEventName: 'session_title_generated', frontendPayload: { sessionId: 'session-1', title: 'Old generated name' } },
406+
}] }));
407+
const cleanup = installDispatchJobObserver(createTerminalContext());
408+
await vi.advanceTimersByTimeAsync(0);
409+
expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('My investigation');
410+
expect(dispatchJobStore.getState().jobs['job-1'].title).toBe('My investigation');
411+
cleanup();
412+
});
413+
414+
it('replays a pre-title transcript cache so the generated name is recovered after upgrade', async () => {
415+
registerRunningJob({ cursor: 50, appliedEventIds: ['title-1'] });
416+
mocks.loadTranscript.mockResolvedValue(cachedTranscript({ schemaVersion: 4, cursor: 50 }));
417+
mocks.status.mockResolvedValue(status({ cursor: 50, events: [{
418+
type: 'agentEvent', timestamp: '2026-07-28T00:00:02Z',
419+
event: { id: 'title-1', event: { type: 'SessionTitleGenerated', session_id: 'session-1', title: 'Recovered title' } },
420+
}] }));
421+
const cleanup = installDispatchJobObserver(createTerminalContext());
422+
await vi.advanceTimersByTimeAsync(0);
423+
expect(mocks.status).toHaveBeenCalledWith('job-1', 0);
424+
expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('Recovered title');
425+
cleanup();
426+
});
427+
428+
it('restores title metadata from the transcript when the renderer cache is missing', async () => {
429+
mocks.listJobs.mockResolvedValue([runningOutboundRecord()]);
430+
mocks.loadTranscript.mockResolvedValue(cachedTranscript({ title: 'Cached investigation', titleSource: 'generated' }));
431+
mocks.status.mockResolvedValue(status({ cursor: 50, events: [] }));
432+
const cleanup = installDispatchJobObserver(createTerminalContext());
433+
await vi.advanceTimersByTimeAsync(0);
434+
expect(flowChatStore.getState().sessions.get('session-1')?.title).toBe('Cached investigation');
435+
expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ title: 'Cached investigation', titleSource: 'generated' });
436+
cleanup();
437+
});
438+
362439
it('projects raw target events into the existing frontend event contract', () => {
363440
const projected = projectDispatchAgentEvent({
364441
type: 'agentEvent',

‎src/web-ui/src/features/dispatch/DispatchJobObserver.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,16 @@ async function ensureProjection(
271271
cached.dialogTurns as DialogTurn[],
272272
);
273273
if (hydrated && cached) {
274+
const current = dispatchJobStore.getState().jobs[job.jobId];
275+
if (
276+
!current?.titleSource
277+
&& typeof cached.title === 'string'
278+
&& cached.title.trim()
279+
&& (cached.titleSource === 'generated' || cached.titleSource === 'manual')
280+
) {
281+
dispatchJobStore.getState().updateTitle(job.jobId, cached.title, cached.titleSource);
282+
void context.flowChatStore.updateSessionTitle(job.sessionId, cached.title, 'generated');
283+
}
274284
bindTarget(cached.cursor, true);
275285
// The cache, not the persisted renderer state, decides where to resume.
276286
// The two are written separately, so the renderer's own cursor can be
@@ -313,6 +323,9 @@ async function ensureProjection(
313323
// The observer can start before FlowChat knows its workspace, so a legacy
314324
// outbound record may only gain its source path on a later poll.
315325
bindTarget(job.cursor);
326+
if (job.titleSource && existing.title !== job.title) {
327+
void context.flowChatStore.updateSessionTitle(job.sessionId, job.title, 'generated');
328+
}
316329
// Startup metadata can win the race and create this session before the
317330
// observer. Such a session has no turns, so merely binding the dispatch
318331
// target would leave the navigation row permanently empty and allow the
@@ -608,6 +621,21 @@ function applyEvent(
608621
log.debug('Ignoring unprojectable target agent event', { event });
609622
return true;
610623
}
624+
if (
625+
projected.eventName === 'session_title_generated'
626+
&& projected.payload.sessionId === job.sessionId
627+
&& typeof projected.payload.title === 'string'
628+
&& projected.payload.title.trim()
629+
) {
630+
// A title is projection metadata too: keep it with the persisted observer
631+
// before advancing its cursor. Manual names win over replayed target events.
632+
dispatchJobStore.getState().updateTitle(job.jobId, projected.payload.title, 'generated');
633+
const title = dispatchJobStore.getState().jobs[job.jobId]?.title;
634+
if (title) {
635+
void context.flowChatStore.updateSessionTitle(job.sessionId, title, 'generated');
636+
}
637+
return true;
638+
}
611639
const applied = agenticEventListener.dispatchExternal(
612640
projected.eventName,
613641
projected.payload,

‎src/web-ui/src/features/dispatch/dispatchJobStore.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,55 @@ describe('dispatchJobStore', () => {
4242
dispatchJobStore.getState().clear();
4343
});
4444

45+
it.each(['generated', 'manual'] as const)('preserves a %s title across reload and an old outbound payload', async (source) => {
46+
registerJob();
47+
dispatchJobStore.getState().updateTitle('job-1', 'Investigate build failure', source);
48+
const storage = dispatchJobStore.persist.getOptions().storage!;
49+
const saved = (await storage.getItem('openbitfun-dispatch-jobs-v1'))!;
50+
dispatchJobStore.setState({ jobs: {} });
51+
await storage.setItem('openbitfun-dispatch-jobs-v1', saved);
52+
await dispatchJobStore.persist.rehydrate();
53+
const job = dispatchJobStore.getState().jobs['job-1'];
54+
dispatchJobStore.getState().mergeOutboundRecords([{
55+
jobId: job.jobId,
56+
sessionId: job.sessionId,
57+
target: job.target,
58+
sourceWorkspacePath: '/source',
59+
workspacePath: '/repo',
60+
promptPreview: 'Dispatch test',
61+
title: 'Dispatch test',
62+
lastCursor: 900,
63+
lastState: 'running',
64+
createdAt: '2026-07-28T00:00:00Z',
65+
updatedAt: '2026-07-28T00:00:01Z',
66+
}]);
67+
expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({
68+
title: 'Investigate build failure', titleSource: source,
69+
});
70+
});
71+
72+
it('preserves legacy manual names without titleSource when the index still has the submission name', async () => {
73+
registerJob();
74+
const storage = dispatchJobStore.persist.getOptions().storage!;
75+
const legacy = (await storage.getItem('openbitfun-dispatch-jobs-v1'))!;
76+
legacy.state.jobs['job-1'].title = 'My investigation';
77+
delete legacy.state.jobs['job-1'].titleSource;
78+
await storage.setItem('openbitfun-dispatch-jobs-v1', legacy);
79+
await dispatchJobStore.persist.rehydrate();
80+
const job = dispatchJobStore.getState().jobs['job-1'];
81+
dispatchJobStore.getState().mergeOutboundRecords([{
82+
jobId: job.jobId, sessionId: job.sessionId, target: job.target,
83+
sourceWorkspacePath: '/source', workspacePath: '/repo',
84+
promptPreview: 'Dispatch test', title: 'Dispatch test',
85+
lastCursor: 10, lastState: 'running',
86+
createdAt: '2026-07-28T00:00:00Z', updatedAt: '2026-07-28T00:00:01Z',
87+
}]);
88+
dispatchJobStore.getState().updateTitle('job-1', 'Replayed generated name', 'generated');
89+
expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({
90+
title: 'My investigation', titleSource: 'manual',
91+
});
92+
});
93+
4594
it('keeps cursors monotonic and clears terminal-drained state on progress', () => {
4695
registerJob();
4796
dispatchJobStore.getState().updateProgress('job-1', {

‎src/web-ui/src/features/dispatch/dispatchJobStore.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export interface DispatchObserverJob {
133133
sourceWorkspacePath?: string;
134134
sourceWorkspaceId?: string;
135135
title: string;
136+
/** Controller projection metadata; absent on legacy renderer snapshots. */
137+
titleSource?: 'generated' | 'manual';
136138
agentType: string;
137139
approvalPolicy: DispatchApprovalPolicy;
138140
/** Baseline branch on the controller, once the backend has resolved one. */
@@ -219,7 +221,7 @@ interface DispatchJobStoreState {
219221
omittedEventCount: number;
220222
},
221223
) => void;
222-
updateTitle: (jobId: string, title: string) => void;
224+
updateTitle: (jobId: string, title: string, source?: 'generated' | 'manual') => void;
223225
updateModel: (jobId: string, model: string) => void;
224226
updateReasoningPreset: (jobId: string, preset: string) => void;
225227
updateApprovalPolicy: (jobId: string, policy: DispatchApprovalPolicy) => void;
@@ -403,7 +405,16 @@ export const useDispatchJobStore = create<DispatchJobStoreState>()(
403405
sourceWorkspacePath,
404406
sourceWorkspaceId:
405407
record.sourceWorkspaceId || existing.sourceWorkspaceId,
406-
title: record.title || existing.title,
408+
// The index keeps the submission name. Later observer titles
409+
// live in this persisted projection, including old manual names
410+
// written before titleSource was recorded.
411+
title: existing.titleSource || (existing.title && existing.title !== record.title)
412+
? existing.title
413+
: record.title || existing.title,
414+
titleSource: existing.titleSource
415+
?? (record.title && existing.title && existing.title !== record.title
416+
? 'manual'
417+
: undefined),
407418
agentType: record.agentType || existing.agentType,
408419
approvalPolicy: record.approvalPolicy || existing.approvalPolicy,
409420
model: record.model || existing.model,
@@ -629,11 +640,16 @@ export const useDispatchJobStore = create<DispatchJobStoreState>()(
629640
});
630641
},
631642

632-
updateTitle: (jobId, title) => {
643+
updateTitle: (jobId, title, source = 'manual') => {
633644
set(state => {
634645
const current = state.jobs[jobId];
635646
const normalizedTitle = title.trim();
636-
if (!current || !normalizedTitle || current.title === normalizedTitle) {
647+
if (
648+
!current
649+
|| !normalizedTitle
650+
|| (source === 'generated' && current.titleSource === 'manual')
651+
|| (current.title === normalizedTitle && current.titleSource === source)
652+
) {
637653
return state;
638654
}
639655
return {
@@ -642,6 +658,7 @@ export const useDispatchJobStore = create<DispatchJobStoreState>()(
642658
[jobId]: {
643659
...current,
644660
title: normalizedTitle,
661+
titleSource: source,
645662
updatedAt: Date.now(),
646663
},
647664
},

‎src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export function scheduleDispatchTranscriptSave(
8787
}
8888
pendingPayloads.set(job.jobId, {
8989
schemaVersion: DISPATCH_TRANSCRIPT_SCHEMA_VERSION,
90+
title: job.title,
91+
titleSource: job.titleSource,
9092
jobId: job.jobId,
9193
sessionId: job.sessionId,
9294
cursor,

‎src/web-ui/src/features/dispatch/types.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,8 @@ export interface OutboundDispatchRecord {
272272
* the only thing standing between a projection change and a transcript rendered
273273
* by rules that no longer exist.
274274
*/
275-
export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 4;
275+
// v5 persists title projection metadata; old caches replay title events once.
276+
export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 5;
276277

277278
/**
278279
* The controller's UI cache for one observer projection.
@@ -283,6 +284,8 @@ export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 4;
283284
*/
284285
export interface DispatchTranscriptCache {
285286
schemaVersion: number;
287+
title?: string;
288+
titleSource?: 'generated' | 'manual';
286289
jobId: string;
287290
sessionId: string;
288291
cursor: number;

0 commit comments

Comments
 (0)