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
8 changes: 8 additions & 0 deletions docs/architecture/detached-task-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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',
Expand Down
28 changes: 28 additions & 0 deletions src/web-ui/src/features/dispatch/DispatchJobObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions src/web-ui/src/features/dispatch/dispatchJobStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
25 changes: 21 additions & 4 deletions src/web-ui/src/features/dispatch/dispatchJobStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -403,7 +405,16 @@ export const useDispatchJobStore = create<DispatchJobStoreState>()(
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,
Expand Down Expand Up @@ -629,11 +640,16 @@ export const useDispatchJobStore = create<DispatchJobStoreState>()(
});
},

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 {
Expand All @@ -642,6 +658,7 @@ export const useDispatchJobStore = create<DispatchJobStoreState>()(
[jobId]: {
...current,
title: normalizedTitle,
titleSource: source,
updatedAt: Date.now(),
},
},
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/web-ui/src/features/dispatch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down
Loading