From 7508bda04817dff3d1e7cf3d1afa8ddad4b6c692 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:16:31 -0800 Subject: [PATCH 01/26] fix(dashboard): find workspaces by task in command palette Use the existing channel registry as one source for route order, labels, and task descriptions. Search user tasks and preserve existing project name lookup, scoped navigation, and all fourteen mounted workspaces. Add behavioral search regressions for temporal replay, sessions, code exploration, delivery reviews, Work, and scheduled automation. Refs #4220 --- dashboard/src/app/channels.test.ts | 36 ++++++- dashboard/src/app/channels.ts | 103 ++++++++++++++++++--- dashboard/src/app/shell/CommandPalette.tsx | 32 ++++--- 3 files changed, 143 insertions(+), 28 deletions(-) diff --git a/dashboard/src/app/channels.test.ts b/dashboard/src/app/channels.test.ts index ed5162fbec2..a2c6eb3dd25 100644 --- a/dashboard/src/app/channels.test.ts +++ b/dashboard/src/app/channels.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { channelForPathname, channelNumber } from './channels.ts'; +import { + CHANNELS, + channelForPathname, + channelNumber, + paletteMatchesQuery, + workspaceSearchText, +} from './channels.ts'; describe('channelNumber', () => { it('never invents a channel for a path it does not have', () => { @@ -26,3 +32,31 @@ describe('channelForPathname', () => { expect(channelForPathname('/tools')).toBeNull(); }); }); + +describe('command-palette task discovery', () => { + const matches = (query: string) => + CHANNELS.filter((channel) => + paletteMatchesQuery(workspaceSearchText(channel), query), + ).map((channel) => channel.path); + + it('finds task destinations when their workspace names are unknown', () => { + expect(matches('temporal replay')).toContain('loom'); + expect(matches('transcripts')).toContain('sessions'); + expect(matches('source symbols')).toContain('code'); + expect(matches(' PR REVIEWS ')).toContain('delivery'); + expect(matches('task board')).toContain('work'); + expect(matches('scheduled jobs')).toContain('automations'); + }); + + it('keeps existing workspace-name and project-name matches', () => { + expect(matches('code')).toContain('code'); + expect(matches('code')).toContain('explorer'); + expect(paletteMatchesQuery('garden project', ' GARDEN ')).toBe(true); + expect(paletteMatchesQuery('garden project', 'temporal')).toBe(false); + }); + + it('preserves every existing route on empty input and does not invent destinations', () => { + expect(matches('')).toEqual(CHANNELS.map((channel) => channel.path)); + expect(matches('not-a-real-destination')).toEqual([]); + }); +}); diff --git a/dashboard/src/app/channels.ts b/dashboard/src/app/channels.ts index f29ed45ede8..1dbe0a68345 100644 --- a/dashboard/src/app/channels.ts +++ b/dashboard/src/app/channels.ts @@ -17,25 +17,100 @@ export interface Channel { readonly path: string; readonly label: string; + /** Visible task hint, also searchable from the command palette. */ + readonly taskHint: string; } export const CHANNELS: readonly Channel[] = [ - { path: 'brain', label: 'Brain' }, - { path: 'explorer', label: 'Explorer' }, - { path: 'loom', label: 'Loom' }, - { path: 'sessions', label: 'Sessions' }, - { path: 'agents', label: 'Agents' }, - { path: 'code', label: 'Code' }, - { path: 'knowledge', label: 'Knowledge' }, - { path: 'delivery', label: 'Delivery' }, - { path: 'automations', label: 'Automations' }, - { path: 'observatory', label: 'Observatory' }, - { path: 'costs', label: 'Costs' }, - { path: 'settings', label: 'Settings' }, - { path: 'work', label: 'Work' }, - { path: 'workflows', label: 'Workflows' }, + { + path: 'brain', + label: 'Brain', + taskHint: 'Project health, scope and relationships', + }, + { + path: 'explorer', + label: 'Explorer', + taskHint: 'Search code, sessions and knowledge together', + }, + { + path: 'loom', + label: 'Loom', + taskHint: 'Agent timelines and temporal replay', + }, + { + path: 'sessions', + label: 'Sessions', + taskHint: 'Session transcripts and history', + }, + { + path: 'agents', + label: 'Agents', + taskHint: 'Agent identities and activity', + }, + { + path: 'code', + label: 'Code', + taskHint: 'Source files, symbols and dependencies', + }, + { + path: 'knowledge', + label: 'Knowledge', + taskHint: 'Memory facts and citations', + }, + { + path: 'delivery', + label: 'Delivery', + taskHint: 'PR reviews, CI checks and releases', + }, + { + path: 'automations', + label: 'Automations', + taskHint: 'Scheduled jobs, skills and runs', + }, + { + path: 'observatory', + label: 'Observatory', + taskHint: 'Diagnostics, storage health and telemetry', + }, + { + path: 'costs', + label: 'Costs', + taskHint: 'Token usage, savings and spending', + }, + { + path: 'settings', + label: 'Settings', + taskHint: 'Configuration, integrations and permissions', + }, + { + path: 'work', + label: 'Work', + taskHint: 'Task board, plans and progress', + }, + { + path: 'workflows', + label: 'Workflows', + taskHint: 'Workflow definitions, execution and runs', + }, ] as const; +/** Search the task described by a workspace, rather than only its proper name. + * The rail continues to use the established label and route unchanged. */ +export function workspaceSearchText(channel: Channel): string { + return `${channel.label} ${channel.taskHint}`.toLowerCase(); +} + +/** Match all typed words, regardless of whitespace/case or the order in which + * the workspace's label and task description supply them. Empty queries show + * every available entry, including registered projects. */ +export function paletteMatchesQuery(searchText: string, query: string): boolean { + return query + .trim() + .toLowerCase() + .split(/\s+/) + .every((term) => searchText.includes(term)); +} + /** Zero-padded channel number for a workspace path (`code` → `06`). Unknown * paths get `--`: the instrument never invents a channel it does not have. */ export function channelNumber(path: string): string { diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index 807d9534bfd..8c79967505a 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -2,8 +2,12 @@ import * as Dialog from '@radix-ui/react-dialog'; import { Command as CommandIcon, CornerDownLeft, Search } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; -import { WORKSPACES } from '../routes'; -import { channelNumber } from '../channels.ts'; +import { + CHANNELS, + channelNumber, + paletteMatchesQuery, + workspaceSearchText, +} from '../channels.ts'; import { cn } from '../../ui/cn'; import { useProjectRegistry, projectRegistryPayload } from '../../data/query/projectRegistry.ts'; import { @@ -16,6 +20,7 @@ interface CommandPaletteRow { id: string; label: string; hint: string; + searchText: string; /** The channel number for a workspace row; project rows have none. */ channel: string | null; action: () => void; @@ -62,13 +67,14 @@ export function CommandPalette({ const projects = useProjectRegistry({ enabled: open }); const entries = useMemo(() => { - const workspaceEntries = WORKSPACES.map((w) => ({ - id: `nav:${w.path}`, - label: w.label, - hint: 'workspace', - channel: channelNumber(w.path), + const workspaceEntries = CHANNELS.map((channel) => ({ + id: `nav:${channel.path}`, + label: channel.label, + hint: channel.taskHint, + searchText: workspaceSearchText(channel), + channel: channelNumber(channel.path), action: () => { - navigate(scopedWorkspacePath(scope, w.path)); + navigate(scopedWorkspacePath(scope, channel.path)); onOpenChange(false); }, })); @@ -80,6 +86,7 @@ export function CommandPalette({ id: `scope:${project.project_id}:${project.canonical_root}`, label: project.label, hint: `project · ${group.label}`, + searchText: project.label.toLowerCase(), channel: null, action: () => { // The listing already measured `is_active`, against the same @@ -106,11 +113,10 @@ export function CommandPalette({ return [...workspaceEntries, ...projectEntries]; }, [navigate, onOpenChange, projects.data, scope, selectProject]); - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return entries; - return entries.filter((entry) => entry.label.toLowerCase().includes(q)); - }, [entries, query]); + const filtered = useMemo( + () => entries.filter((entry) => paletteMatchesQuery(entry.searchText, query)), + [entries, query], + ); useEffect(() => setActive(0), [query, open]); useEffect(() => { From 2d1d1390589a9ba052ca4437e1a5db929354b5ce Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:23:12 -0800 Subject: [PATCH 02/26] fix(dashboard): prioritize workspace names in palette search Rank exact and partial labels ahead of task-only matches while keeping stable channel order. Give workspace labels priority over truncatable hints on narrow screens. Exercise real keyboard Enter navigation for workspace names that Explorer mentions in its task hint. Refs #4814 --- .../src/app/shell/CommandPalette.dom.test.tsx | 25 ++++++++++++++++- dashboard/src/app/shell/CommandPalette.tsx | 28 +++++++++++++------ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index fd49e43461c..ce243dfa7ef 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -20,7 +20,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router'; +import { MemoryRouter, useLocation } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CommandPalette } from './CommandPalette.tsx'; @@ -110,6 +110,10 @@ function stubListing(payload: ProjectsPayloadV1) { ); } +function PaletteLocation() { + return {useLocation().pathname}; +} + function renderPalette() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return { @@ -118,6 +122,7 @@ function renderPalette() { {}} /> + , ), @@ -153,6 +158,24 @@ afterEach(() => { }); describe('CommandPalette keyboard operation', () => { + it.each([ + ['code', 'Code'], + ['sessions', 'Sessions'], + ['knowledge', 'Knowledge'], + ])('opens the exact %s workspace instead of a matching task hint', async (query, label) => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette(); + await findByRole('listbox'); + + // Explorer occurs earlier in CHANNELS and mentions each term in its + // hint. Enter must still navigate to the exact named workspace. + await user.keyboard(query); + await waitFor(() => expect(activeOptionLabel()).toBe(label)); + await user.keyboard('{Enter}'); + expect(getByTestId('palette-location').textContent).toBe(`/${query}`); + }); + it('focuses the search box on open, so the first keystroke reaches it', async () => { stubListing(listing([])); renderPalette(); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index 8c79967505a..e2c944856f1 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -113,10 +113,20 @@ export function CommandPalette({ return [...workspaceEntries, ...projectEntries]; }, [navigate, onOpenChange, projects.data, scope, selectProject]); - const filtered = useMemo( - () => entries.filter((entry) => paletteMatchesQuery(entry.searchText, query)), - [entries, query], - ); + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + const matchRank = (entry: CommandPaletteRow) => { + const label = entry.label.toLowerCase(); + if (label === needle) return 0; + if (label.includes(needle)) return 1; + return 2; + }; + // Preserve the rail/project order inside each rank while ensuring that + // typing a workspace's exact name selects that workspace on Enter. + return entries + .filter((entry) => paletteMatchesQuery(entry.searchText, query)) + .sort((left, right) => matchRank(left) - matchRank(right)); + }, [entries, query]); useEffect(() => setActive(0), [query, open]); useEffect(() => { @@ -240,10 +250,12 @@ export function CommandPalette({ > {entry.channel ?? ''} - {entry.label} - - {entry.hint} - {i === activeIndex ? : null} + {entry.label} + + {entry.hint} + {i === activeIndex ? ( + + ) : null} )) From 7f5753ee80dd74cb912705328f8957f5c24ad107 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:17:54 -0800 Subject: [PATCH 03/26] fix(dashboard): preserve keyboard selection across late registry matches Do not move the command palette active index to -1 when ArrowDown is pressed with no results. Clamp the active index to a nonnegative row when a matching project arrives asynchronously without a query change. A DOM regression verifies an initially empty search, ArrowDown, a registered project arriving, announced active IDREF, and Enter navigation with its measured activation. The pre-fix implementation fails it. Refs #4220 Validation: exact source diff reviewed; pnpm/Vitest and browser journeys require hosted CI because dependencies are unavailable locally. --- .../src/app/shell/CommandPalette.dom.test.tsx | 33 +++++++++++++++++++ dashboard/src/app/shell/CommandPalette.tsx | 6 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index ce243dfa7ef..a420a202529 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -359,6 +359,39 @@ describe('CommandPalette long and shrinking lists', () => { expect(active?.getAttribute('aria-selected')).toBe('true'); }); + it('keeps keyboard activation when a project appears after an empty search', async () => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId, client } = renderPalette(); + await findByRole('listbox'); + + await user.keyboard('RareProject'); + await waitFor(() => expect(activeOptionLabel()).toBe('(nothing is announced as active)')); + await user.keyboard('{ArrowDown}'); + + // The registry can finish a new read without the user typing again. + // Before the fix ArrowDown wrote -1 while results were empty; the matching + // row then existed but lacked an active IDREF and Enter could not select it. + act(() => { + client.setQueryData([...projectRegistryListKey, UNSCOPED_CACHE_KEY], { + outcome: 'envelope', + envelope: fixtureEnvelope(listing([registryEntry('proj-new', 'RareProject', true)])), + }); + }); + await waitFor(() => expect(activeOptionLabel()).toBe('RareProject')); + const id = combobox().getAttribute('aria-activedescendant'); + expect(id).toBeTruthy(); + expect(document.getElementById(id!)?.getAttribute('aria-selected')).toBe('true'); + + await user.keyboard('{Enter}'); + expect(useScope.getState().scope).toMatchObject({ + kind: 'project', + projectId: 'proj-new', + activation: 'active', + }); + expect(getByTestId('palette-location').textContent).toBe('/brain'); + }); + it('clamps the active row when the listing shrinks, and Enter still fires', async () => { const user = userEvent.setup(); stubListing(listing(MANY)); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index e2c944856f1..13274629a78 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -146,7 +146,7 @@ export function CommandPalette({ * inert. Clamped on read rather than corrected in an effect, so there is no * render in which the index is out of range. */ - const activeIndex = filtered.length === 0 ? 0 : Math.min(active, filtered.length - 1); + const activeIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(active, filtered.length - 1)); const activeEntry = filtered[activeIndex]; const listRef = useRef(null); @@ -170,7 +170,9 @@ export function CommandPalette({ e.preventDefault(); // From the clamped index, so a shrunk list steps from its last real row // rather than from a stale index further down. - setActive(Math.min(activeIndex + 1, filtered.length - 1)); + // An empty result set has no final row. Setting -1 here would leave + // a newly fetched matching project invisible to Enter and screen readers. + if (filtered.length > 0) setActive(Math.min(activeIndex + 1, filtered.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(Math.max(activeIndex - 1, 0)); From d9288b2ade653d2fddb2acb7fc0afa3fb02596dc Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:13:21 -0800 Subject: [PATCH 04/26] fix(dashboard): scope register to consumed channel fields The task-hint addition made Channel require an extra property, but the scope register consumes only path and label. Keep its prop a structural projection of those fields so existing register fixtures compile and full channel values remain accepted without weakening the palette's required task hint. Refs #4220 --- dashboard/src/app/shell/ScopeBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/app/shell/ScopeBar.tsx b/dashboard/src/app/shell/ScopeBar.tsx index 180f11c8484..5e791524c45 100644 --- a/dashboard/src/app/shell/ScopeBar.tsx +++ b/dashboard/src/app/shell/ScopeBar.tsx @@ -34,7 +34,7 @@ export function ScopeBar({ channel, onOpenPalette, }: { - channel?: Channel | null; + channel?: Pick | null; onOpenPalette?: () => void; }) { const scope = useScope((s) => s.scope); From dccdaf9e8d3fd6682b8cf88ca8341f8a3e0f4557 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:19:39 -0800 Subject: [PATCH 05/26] fix(dashboard): keep selected view on palette reselection Reselecting a workspace without changing project scope must not reset Loom/Brain/Cortex renderer and selected-entity query state. Preserve the current location, but let real workspace or scope changes use canonical navigation. Add six keyboard-driven DOM cases covering view links, scope, and cross-workspace selection. Refs #4220 --- .../src/app/shell/CommandPalette.dom.test.tsx | 57 ++++++++++++++++++- dashboard/src/app/shell/CommandPalette.tsx | 15 ++++- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index a420a202529..bb1333b79f0 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -111,16 +111,17 @@ function stubListing(payload: ProjectsPayloadV1) { } function PaletteLocation() { - return {useLocation().pathname}; + const location = useLocation(); + return {location.pathname}{location.search}; } -function renderPalette() { +function renderPalette(initialEntry = '/') { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return { client, ...render( - + {}} /> @@ -158,6 +159,56 @@ afterEach(() => { }); describe('CommandPalette keyboard operation', () => { + it.each([ + ['/loom?loomRenderer=rails&session=run-7', 'Loom'], + ['/brain?field=sigma&node=node-3', 'Brain'], + ['/code?render=relief&symbol=symbol-2', 'Code'], + ])('reselects %s without resetting its active view', async (entry, label) => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette(entry); + await findByRole('listbox'); + + await user.keyboard(label); + await waitFor(() => expect(activeOptionLabel()).toBe(label)); + await user.keyboard('{Enter}'); + + expect(getByTestId('palette-location').textContent).toBe(entry); + }); + + it('keeps the selected-project view only for the same scoped route', async () => { + const user = userEvent.setup(); + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + stubListing(listing([])); + const original = '/loom?scope=project-1&scopeLabel=Alpha&loomRenderer=strata'; + const { findByRole, getByTestId } = renderPalette(original); + await findByRole('listbox'); + + await user.keyboard('loom{Enter}'); + expect(getByTestId('palette-location').textContent).toBe(original); + }); + + it('does not carry a workspace view into a different workspace', async () => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette('/loom?loomRenderer=rails'); + await findByRole('listbox'); + + await user.keyboard('code{Enter}'); + expect(getByTestId('palette-location').textContent).toBe('/code'); + }); + + it('updates project scope rather than retaining an unscoped view', async () => { + const user = userEvent.setup(); + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette('/loom?loomRenderer=rails'); + await findByRole('listbox'); + + await user.keyboard('loom{Enter}'); + expect(getByTestId('palette-location').textContent).toBe('/loom?scope=project-1&scopeLabel=Alpha'); + }); + it.each([ ['code', 'Code'], ['sessions', 'Sessions'], diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index 13274629a78..a6f8d0e748d 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -1,7 +1,7 @@ import * as Dialog from '@radix-ui/react-dialog'; import { Command as CommandIcon, CornerDownLeft, Search } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; import { CHANNELS, channelNumber, @@ -54,6 +54,7 @@ export function CommandPalette({ onOpenChange: (open: boolean) => void; }) { const navigate = useNavigate(); + const location = useLocation(); const scope = useScope((state) => state.scope); const selectProject = useScope((s) => s.selectProject); const [query, setQuery] = useState(''); @@ -74,7 +75,15 @@ export function CommandPalette({ searchText: workspaceSearchText(channel), channel: channelNumber(channel.path), action: () => { - navigate(scopedWorkspacePath(scope, channel.path)); + // Reselecting this workspace is not a renderer or selection reset. + // Only an actual workspace/scope transition replaces view-owned state. + const params = new URLSearchParams(location.search); + const sameScope = scope.kind === 'all' + ? !params.has('scope') && !params.has('scopeLabel') + : params.get('scope') === scope.projectId && params.get('scopeLabel') === scope.label; + if (location.pathname !== `/${channel.path}` || !sameScope) { + navigate(scopedWorkspacePath(scope, channel.path)); + } onOpenChange(false); }, })); @@ -111,7 +120,7 @@ export function CommandPalette({ ) : []; return [...workspaceEntries, ...projectEntries]; - }, [navigate, onOpenChange, projects.data, scope, selectProject]); + }, [navigate, location.pathname, location.search, onOpenChange, projects.data, scope, selectProject]); const filtered = useMemo(() => { const needle = query.trim().toLowerCase(); From feeef1812484bff589d3b2ec4a2a71898c75c8fe Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:15:33 -0800 Subject: [PATCH 06/26] test(dashboard): assert scoped palette project navigation Match the project-selection destination to its canonical scoped URL. The hosted DOM regression already proved the activation succeeded; its old unscoped-path expectation was the only failing assertion. Preserve the behavioral test instead of removing or weakening it. Refs #4220 --- dashboard/src/app/shell/CommandPalette.dom.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index bb1333b79f0..7a6b65638fc 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -440,7 +440,10 @@ describe('CommandPalette long and shrinking lists', () => { projectId: 'proj-new', activation: 'active', }); - expect(getByTestId('palette-location').textContent).toBe('/brain'); + // Project activation is a scoped navigation, not a plain workspace visit. + expect(getByTestId('palette-location').textContent).toBe( + '/brain?scope=proj-new&scopeLabel=RareProject', + ); }); it('clamps the active row when the listing shrinks, and Enter still fires', async () => { From a66ccf689915c7776a79e346ec5d8c63ebe74549 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:15:03 -0800 Subject: [PATCH 07/26] fix(dashboard): retain palette selection across registry reorder --- .../src/app/shell/CommandPalette.dom.test.tsx | 33 +++++++++++++ dashboard/src/app/shell/CommandPalette.tsx | 47 ++++++++++++------- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index 7a6b65638fc..c7ce300603f 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -446,6 +446,39 @@ describe('CommandPalette long and shrinking lists', () => { ); }); + it('keeps the selected project identity when a registry refresh reorders results', async () => { + const user = userEvent.setup(); + const alpha = registryEntry('proj-alpha', 'Rare Alpha', false); + const beta = registryEntry('proj-beta', 'Rare Beta', true); + stubListing(listing([alpha, beta])); + const { findByText, client } = renderPalette(); + await findByText('Rare Beta'); + + await user.keyboard('Rare'); + await waitFor(() => expect(activeOptionLabel()).toBe('Rare Alpha')); + await user.keyboard('{ArrowDown}'); + expect(activeOptionLabel()).toBe('Rare Beta'); + + // Reordered registration results must not silently select a different + // project while the keyboard is stationary. + act(() => { + client.setQueryData([...projectRegistryListKey, UNSCOPED_CACHE_KEY], { + outcome: 'envelope', + envelope: fixtureEnvelope(listing([beta, alpha])), + }); + }); + await waitFor(() => expect(activeOptionLabel()).toBe('Rare Beta')); + const id = combobox().getAttribute('aria-activedescendant'); + expect(document.getElementById(id ?? '')?.getAttribute('aria-selected')).toBe('true'); + + await user.keyboard('{Enter}'); + expect(useScope.getState().scope).toMatchObject({ + kind: 'project', + projectId: 'proj-beta', + activation: 'active', + }); + }); + it('clamps the active row when the listing shrinks, and Enter still fires', async () => { const user = userEvent.setup(); stubListing(listing(MANY)); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index a6f8d0e748d..414cd9cf541 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -58,7 +58,11 @@ export function CommandPalette({ const scope = useScope((state) => state.scope); const selectProject = useScope((s) => s.selectProject); const [query, setQuery] = useState(''); - const [active, setActive] = useState(0); + const [selection, setSelection] = useState<{ + query: string; + index: number; + rowId: string | null; + }>({ query: '', index: 0, rowId: null }); // Entities: registered projects become scope-setting results. Fetched only // while the palette is open; a failed registry read simply yields no // project rows (workspace navigation never depends on it). @@ -137,26 +141,33 @@ export function CommandPalette({ .sort((left, right) => matchRank(left) - matchRank(right)); }, [entries, query]); - useEffect(() => setActive(0), [query, open]); useEffect(() => { - if (open) setQuery(''); + if (open) { + setQuery(''); + setSelection({ query: '', index: 0, rowId: null }); + } }, [open]); /** - * The row actually being pointed at, which is not always the one `active` - * holds. - * - * `active` is only reset by a query change or a reopen, and the list has a - * second input: the registry read behind the project rows. A listing that - * shrinks, a refetch returning fewer projects, or an `ok` result becoming a - * failure, which drops every project row at once, leaves the stored index - * past the end. That row does not exist, so `aria-activedescendant` referred - * to nothing and Enter fired nothing: a palette that looked navigable and was - * inert. Clamped on read rather than corrected in an effect, so there is no - * render in which the index is out of range. + * Preserve the selected row's identity when a registry refresh reorders + * entries. A numeric index alone can silently activate a different project + * after the registry changes. When that identity disappears, clamp its last + * position to an extant row. Query changes reset selection synchronously. */ - const activeIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(active, filtered.length - 1)); + const currentSelection = + selection.query === query ? selection : { query, index: 0, rowId: null }; + const matchingIndex = currentSelection.rowId === null + ? -1 + : filtered.findIndex((entry) => entry.id === currentSelection.rowId); + const activeIndex = filtered.length === 0 + ? 0 + : matchingIndex >= 0 + ? matchingIndex + : Math.max(0, Math.min(currentSelection.index, filtered.length - 1)); const activeEntry = filtered[activeIndex]; + const selectIndex = (index: number) => { + setSelection({ query, index, rowId: filtered[index]?.id ?? null }); + }; const listRef = useRef(null); useEffect(() => { @@ -181,10 +192,10 @@ export function CommandPalette({ // rather than from a stale index further down. // An empty result set has no final row. Setting -1 here would leave // a newly fetched matching project invisible to Enter and screen readers. - if (filtered.length > 0) setActive(Math.min(activeIndex + 1, filtered.length - 1)); + if (filtered.length > 0) selectIndex(Math.min(activeIndex + 1, filtered.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setActive(Math.max(activeIndex - 1, 0)); + if (filtered.length > 0) selectIndex(Math.max(activeIndex - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); activeEntry?.action(); @@ -239,7 +250,7 @@ export function CommandPalette({ id={optionId(i)} role="option" aria-selected={i === activeIndex} - onMouseEnter={() => setActive(i)} + onMouseEnter={() => selectIndex(i)} onClick={entry.action} className={cn( // The same selection grammar as the rail: a cyan position From 5d71622809f4be7231e06681e04a734c5997bcbb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:16:20 -0800 Subject: [PATCH 08/26] fix(dashboard): preserve Brain index deep links from palette --- dashboard/src/app/shell/CommandPalette.dom.test.tsx | 1 + dashboard/src/app/shell/CommandPalette.tsx | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index c7ce300603f..5a91b232ee0 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -162,6 +162,7 @@ describe('CommandPalette keyboard operation', () => { it.each([ ['/loom?loomRenderer=rails&session=run-7', 'Loom'], ['/brain?field=sigma&node=node-3', 'Brain'], + ['/?field=sigma&node=node-3', 'Brain'], ['/code?render=relief&symbol=symbol-2', 'Code'], ])('reselects %s without resetting its active view', async (entry, label) => { const user = userEvent.setup(); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index 414cd9cf541..ad6b0016964 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -85,7 +85,11 @@ export function CommandPalette({ const sameScope = scope.kind === 'all' ? !params.has('scope') && !params.has('scopeLabel') : params.get('scope') === scope.projectId && params.get('scopeLabel') === scope.label; - if (location.pathname !== `/${channel.path}` || !sameScope) { + // The index route mounts Brain too. Reopening it through the palette + // must preserve field=sigma and entity selection just like /brain. + const sameWorkspace = location.pathname === `/${channel.path}` || + (channel.path === 'brain' && location.pathname === '/'); + if (!sameWorkspace || !sameScope) { navigate(scopedWorkspacePath(scope, channel.path)); } onOpenChange(false); From ece64fc8ad6e9c5cadd9bc63f2963fb96803d58c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:22:34 -0800 Subject: [PATCH 09/26] fix(dashboard): reset palette selection on query edits Preserve row identity across registry refreshes, but never restore a previous selection after the user edits and reverts the search. Exercise actual keyboard navigation and activation through the DOM palette. --- .../src/app/shell/CommandPalette.dom.test.tsx | 28 +++++++++++++++++++ dashboard/src/app/shell/CommandPalette.tsx | 8 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index 5a91b232ee0..9f90394c311 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -480,6 +480,34 @@ describe('CommandPalette long and shrinking lists', () => { }); }); + it('forgets a chosen project when the search is edited and then restored', async () => { + const user = userEvent.setup(); + const alpha = registryEntry('proj-alpha', 'Rare Alpha', false); + const beta = registryEntry('proj-beta', 'Rare Beta', true); + stubListing(listing([alpha, beta])); + const { findByText } = renderPalette(); + await findByText('Rare Beta'); + + await user.keyboard('Rare'); + await waitFor(() => expect(activeOptionLabel()).toBe('Rare Alpha')); + await user.keyboard('{ArrowDown}'); + expect(activeOptionLabel()).toBe('Rare Beta'); + + await user.keyboard('x'); + await waitFor(() => + expect(activeOptionLabel()).toBe('(nothing is announced as active)'), + ); + await user.keyboard('{Backspace}'); + await waitFor(() => expect(activeOptionLabel()).toBe('Rare Alpha')); + + await user.keyboard('{Enter}'); + expect(useScope.getState().scope).toMatchObject({ + kind: 'project', + projectId: 'proj-alpha', + activation: 'selected', + }); + }); + it('clamps the active row when the listing shrinks, and Enter still fires', async () => { const user = userEvent.setup(); stubListing(listing(MANY)); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index ad6b0016964..93fd976eb1d 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -227,7 +227,13 @@ export function CommandPalette({ setQuery(e.target.value)} + onChange={(e) => { + const nextQuery = e.target.value; + // A query edit starts a new search even if the text later + // returns to an earlier value. Registry refreshes do not. + setSelection({ query: nextQuery, index: 0, rowId: null }); + setQuery(nextQuery); + }} placeholder="Go to workspace or project…" className="min-h-[var(--touch-target-min)] w-full bg-transparent text-body outline-none placeholder:text-text-muted" role="combobox" From 7dbe8ff20e73255f8a00ca2f1c054ef34773d039 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:11:43 -0800 Subject: [PATCH 10/26] fix(dashboard): preserve scoped views across label reconciliation Treat project ID as navigation identity and reconcile stale or missing scopeLabel in-place without resetting renderer, replay or selection state. Cover same-project recovery and cross-project isolation in palette DOM tests. --- .../src/app/shell/CommandPalette.dom.test.tsx | 39 +++++++++++++++++++ dashboard/src/app/shell/CommandPalette.tsx | 13 ++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index 9f90394c311..33af1eb485a 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -189,6 +189,45 @@ describe('CommandPalette keyboard operation', () => { expect(getByTestId('palette-location').textContent).toBe(original); }); + it.each([ + [ + '/loom?scope=project-1&loomRenderer=strata&session=run-4', + 'Loom', + '/loom?scope=project-1&loomRenderer=strata&session=run-4&scopeLabel=Alpha', + ], + [ + '/brain?scope=project-1&scopeLabel=OldName&field=sigma&node=node-3', + 'Brain', + '/brain?scope=project-1&scopeLabel=Alpha&field=sigma&node=node-3', + ], + ])('reconciles the label in %s without dropping its selected view', async (url, label, expected) => { + const user = userEvent.setup(); + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette(url); + await findByRole('listbox'); + + await user.keyboard(label); + await waitFor(() => expect(activeOptionLabel()).toBe(label)); + await user.keyboard('{Enter}'); + expect(getByTestId('palette-location').textContent).toBe(expected); + }); + + it('does not retain another project\'s view when switching to the selected scope', async () => { + const user = userEvent.setup(); + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette( + '/loom?scope=project-2&scopeLabel=Beta&loomRenderer=rails', + ); + await findByRole('listbox'); + + await user.keyboard('loom{Enter}'); + expect(getByTestId('palette-location').textContent).toBe( + '/loom?scope=project-1&scopeLabel=Alpha', + ); + }); + it('does not carry a workspace view into a different workspace', async () => { const user = userEvent.setup(); stubListing(listing([])); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index 93fd976eb1d..bf2fca71bbc 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -82,15 +82,26 @@ export function CommandPalette({ // Reselecting this workspace is not a renderer or selection reset. // Only an actual workspace/scope transition replaces view-owned state. const params = new URLSearchParams(location.search); + // A deep link's scopeLabel is only a display claim. Project identity + // is the admitted scope ID; a missing/stale label must not erase the + // selected renderer, replay window, or entity in that same project. const sameScope = scope.kind === 'all' ? !params.has('scope') && !params.has('scopeLabel') - : params.get('scope') === scope.projectId && params.get('scopeLabel') === scope.label; + : params.get('scope') === scope.projectId; // The index route mounts Brain too. Reopening it through the palette // must preserve field=sigma and entity selection just like /brain. const sameWorkspace = location.pathname === `/${channel.path}` || (channel.path === 'brain' && location.pathname === '/'); if (!sameWorkspace || !sameScope) { navigate(scopedWorkspacePath(scope, channel.path)); + } else if (scope.kind === 'project' && params.get('scopeLabel') !== scope.label) { + // Correct the unverified label in place, without carrying it forward + // as an authority or clearing workspace-owned query parameters. + params.set('scopeLabel', scope.label); + navigate( + { pathname: location.pathname, search: `?${params.toString()}` }, + { replace: true }, + ); } onOpenChange(false); }, From a7eb64960b274c73058cdec7b398e71b4e08a3ab Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:06:55 -0800 Subject: [PATCH 11/26] fix(dashboard): retain workspace deep links on palette selection --- dashboard/src/app/shell/CommandPalette.dom.test.tsx | 7 ++++--- dashboard/src/app/shell/CommandPalette.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index 33af1eb485a..0e7b585eb96 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -112,7 +112,7 @@ function stubListing(payload: ProjectsPayloadV1) { function PaletteLocation() { const location = useLocation(); - return {location.pathname}{location.search}; + return {location.pathname}{location.search}{location.hash}; } function renderPalette(initialEntry = '/') { @@ -161,6 +161,7 @@ afterEach(() => { describe('CommandPalette keyboard operation', () => { it.each([ ['/loom?loomRenderer=rails&session=run-7', 'Loom'], + ['/Loom/?loomRenderer=rails&session=run-7', 'Loom'], ['/brain?field=sigma&node=node-3', 'Brain'], ['/?field=sigma&node=node-3', 'Brain'], ['/code?render=relief&symbol=symbol-2', 'Code'], @@ -196,9 +197,9 @@ describe('CommandPalette keyboard operation', () => { '/loom?scope=project-1&loomRenderer=strata&session=run-4&scopeLabel=Alpha', ], [ - '/brain?scope=project-1&scopeLabel=OldName&field=sigma&node=node-3', + '/brain?scope=project-1&scopeLabel=OldName&field=sigma&node=node-3#selection', 'Brain', - '/brain?scope=project-1&scopeLabel=Alpha&field=sigma&node=node-3', + '/brain?scope=project-1&scopeLabel=Alpha&field=sigma&node=node-3#selection', ], ])('reconciles the label in %s without dropping its selected view', async (url, label, expected) => { const user = userEvent.setup(); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index bf2fca71bbc..21d29e3a2bb 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -1,7 +1,7 @@ import * as Dialog from '@radix-ui/react-dialog'; import { Command as CommandIcon, CornerDownLeft, Search } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router'; +import { matchPath, useLocation, useNavigate } from 'react-router'; import { CHANNELS, channelNumber, @@ -90,7 +90,7 @@ export function CommandPalette({ : params.get('scope') === scope.projectId; // The index route mounts Brain too. Reopening it through the palette // must preserve field=sigma and entity selection just like /brain. - const sameWorkspace = location.pathname === `/${channel.path}` || + const sameWorkspace = matchPath(`/${channel.path}`, location.pathname) !== null || (channel.path === 'brain' && location.pathname === '/'); if (!sameWorkspace || !sameScope) { navigate(scopedWorkspacePath(scope, channel.path)); @@ -99,7 +99,7 @@ export function CommandPalette({ // as an authority or clearing workspace-owned query parameters. params.set('scopeLabel', scope.label); navigate( - { pathname: location.pathname, search: `?${params.toString()}` }, + { pathname: location.pathname, search: `?${params.toString()}`, hash: location.hash }, { replace: true }, ); } @@ -139,7 +139,7 @@ export function CommandPalette({ ) : []; return [...workspaceEntries, ...projectEntries]; - }, [navigate, location.pathname, location.search, onOpenChange, projects.data, scope, selectProject]); + }, [navigate, location.pathname, location.search, location.hash, onOpenChange, projects.data, scope, selectProject]); const filtered = useMemo(() => { const needle = query.trim().toLowerCase(); From f3c070035f14bc0af01f94856fe55d54b70f7206 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:12:08 -0800 Subject: [PATCH 12/26] fix(dashboard): retain views with orphaned scope labels --- .../src/app/shell/CommandPalette.dom.test.tsx | 23 +++++++++++++++++++ dashboard/src/app/shell/CommandPalette.tsx | 15 ++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index 0e7b585eb96..c4a69255c7d 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -229,6 +229,29 @@ describe('CommandPalette keyboard operation', () => { ); }); + it.each([ + [ + '/?scopeLabel=Ghost&field=sigma&node=node-3#selection', + 'Brain', + '/?field=sigma&node=node-3#selection', + ], + [ + '/Loom/?scopeLabel=Ghost&loomRenderer=strata&session=run-4#replay', + 'Loom', + '/Loom/?loomRenderer=strata&session=run-4#replay', + ], + ])('removes a stray label from %s without resetting the view', async (url, label, expected) => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette(url); + await findByRole('listbox'); + + await user.keyboard(label); + await waitFor(() => expect(activeOptionLabel()).toBe(label)); + await user.keyboard('{Enter}'); + expect(getByTestId('palette-location').textContent).toBe(expected); + }); + it('does not carry a workspace view into a different workspace', async () => { const user = userEvent.setup(); stubListing(listing([])); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index 21d29e3a2bb..aa61c76a331 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -86,7 +86,7 @@ export function CommandPalette({ // is the admitted scope ID; a missing/stale label must not erase the // selected renderer, replay window, or entity in that same project. const sameScope = scope.kind === 'all' - ? !params.has('scope') && !params.has('scopeLabel') + ? !params.has('scope') : params.get('scope') === scope.projectId; // The index route mounts Brain too. Reopening it through the palette // must preserve field=sigma and entity selection just like /brain. @@ -94,10 +94,15 @@ export function CommandPalette({ (channel.path === 'brain' && location.pathname === '/'); if (!sameWorkspace || !sameScope) { navigate(scopedWorkspacePath(scope, channel.path)); - } else if (scope.kind === 'project' && params.get('scopeLabel') !== scope.label) { - // Correct the unverified label in place, without carrying it forward - // as an authority or clearing workspace-owned query parameters. - params.set('scopeLabel', scope.label); + } else if ( + (scope.kind === 'all' && params.has('scopeLabel')) || + (scope.kind === 'project' && params.get('scopeLabel') !== scope.label) + ) { + // A scopeLabel without a scope ID is not a project selection. Remove + // its unverified claim without resetting the current view or entity. + // For a real project, refresh only the display label. + if (scope.kind === 'project') params.set('scopeLabel', scope.label); + else params.delete('scopeLabel'); navigate( { pathname: location.pathname, search: `?${params.toString()}`, hash: location.hash }, { replace: true }, From 9df52b37c44a89fd536b5544fa85e09131ddd41e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:52:34 -0800 Subject: [PATCH 13/26] test(automation): bind scheduler fixtures to backend identities Repair three hosted automation_runner_test failures by supplying the configured executable identity required by the production prefilter. Use the same identity for the prefilter and retained runner, preserving manual-schedule, exact-ledger, live-lock and lock-race assertions. Add a production-prefilter regression for unconfigured refusal, deduplication and same-occurrence recovery after binding, with zero backend calls and no effectful terminal. Do not relax backend admission, change timeouts, or alter the graph publication changes. Refs #4532 --- .../tests/automation_runner_test/jobs.rs | 124 ++++++++++++++++-- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay/tests/automation_runner_test/jobs.rs b/crates/tracedecay/tests/automation_runner_test/jobs.rs index 740283d56e8..7342786381b 100644 --- a/crates/tracedecay/tests/automation_runner_test/jobs.rs +++ b/crates/tracedecay/tests/automation_runner_test/jobs.rs @@ -86,6 +86,7 @@ fn enabled_job_config() -> AutomationSettingsV1 { struct ContentBackend { calls: AtomicUsize, content: &'static str, + executable: Option, } impl ContentBackend { @@ -93,9 +94,15 @@ impl ContentBackend { Self { calls: AtomicUsize::new(0), content, + executable: None, } } + fn with_executable(mut self, executable: &Path) -> Self { + self.executable = Some(executable.to_path_buf()); + self + } + fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } @@ -126,7 +133,7 @@ impl AgentTaskBackend for ContentBackend { } fn executable(&self) -> Option<&std::path::Path> { - None + self.executable.as_deref() } } @@ -988,6 +995,10 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let mut job = sample_job("sched-job"); job.schedule = Some("manual".to_string()); let config = enabled_job_config(); + // Reach the schedule gate with a bound identity, not the earlier + // backend-unconfigured refusal. This gate never invokes the backend. + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); // Manual schedule: every scheduler tick of the same occurrence reaches // the prefilter, which records the skip once. @@ -996,7 +1007,7 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let run = evaluate_and_record_scheduler_skip( &dashboard_root, &config, - None, + backend.executable(), &job, "sched-occurrence", None, @@ -1019,7 +1030,7 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let repeated = evaluate_and_record_scheduler_skip( &dashboard_root, &config, - None, + backend.executable(), &job, "sched-occurrence", None, @@ -1031,6 +1042,76 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { repeated.ledger_record, records[0], "an immediate repeat skip must return the exact durable prior row" ); + assert_eq!(backend.calls(), 0); +} + +#[tokio::test] +async fn scheduler_prefilter_rejects_unconfigured_backend_and_recovers_without_effect() { + let temp = tempdir().unwrap(); + let dashboard_root = temp.path().join("dashboard"); + let job = sample_job("backend-recovery"); + let config = enabled_job_config(); + let occurrence = "backend-recovery-occurrence"; + let backend = ContentBackend::new("unused"); + + let first = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .expect("a due job still requires a configured backend"); + assert_eq!(first.report["reason"], json!("backend_unconfigured")); + assert_eq!(first.ledger_record.status, AutomationRunStatus::Skipped); + assert_ne!(first.run_id, occurrence); + let repeated = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(repeated.ledger_record, first.ledger_record); + + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = backend.with_executable(executable.path()); + assert!( + evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .is_none(), + "binding the backend must admit the same due occurrence without cooldown" + ); + assert_eq!(backend.calls(), 0); + assert_eq!( + load_run_records(&dashboard_root, 10).await.unwrap(), + vec![first.ledger_record], + "advisory admission must not append an effectful terminal" + ); + assert!( + tracedecay_automation_runtime::automation::run_ledger::find_run_record_exact_bounded( + &dashboard_root, + occurrence, + ) + .await + .unwrap() + .is_none() + ); } #[tokio::test] @@ -1206,15 +1287,22 @@ async fn scheduler_prefilter_live_lock_wins_over_not_due_summary() { let config = enabled_job_config(); let occurrence = "locked-prefilter-occurrence"; - let prefilter = - evaluate_and_record_scheduler_skip(&dashboard_root, &config, None, &job, occurrence, None) - .await - .unwrap() - .expect("the live job lock must produce a diagnostic"); + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); + let prefilter = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .expect("the live job lock must produce a diagnostic"); assert_eq!(prefilter.report["reason"], json!("scheduler_lock_active")); assert_ne!(prefilter.run_id, occurrence); - let backend = ContentBackend::new("unused"); let admitted_job = tracedecay_automation_runtime::automation::jobs::admit_job(&job).unwrap(); let retained = run_user_job_with_backend_for_retained_settlement( &dashboard_root, @@ -1262,12 +1350,21 @@ async fn retained_scheduler_runner_reacquires_after_due_prefilter() { job.schedule = Some("every 1h".to_owned()); let config = enabled_job_config(); let occurrence = "prefilter-gap-occurrence"; + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); assert!( - evaluate_and_record_scheduler_skip(&dashboard_root, &config, None, &job, occurrence, None) - .await - .unwrap() - .is_none(), + evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .is_none(), "a new interval job is advisory-due before effect admission" ); let _raced_in = @@ -1276,7 +1373,6 @@ async fn retained_scheduler_runner_reacquires_after_due_prefilter() { .unwrap() .expect("the advisory prefilter must release its reservation lock"); - let backend = ContentBackend::new("unused"); let admitted_job = tracedecay_automation_runtime::automation::jobs::admit_job(&job).unwrap(); let retained = run_user_job_with_backend_for_retained_settlement( &dashboard_root, From 43f4ef6a1be3863a30def54449a4e4e5d88a4fa1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:55:25 -0800 Subject: [PATCH 14/26] fix(sessions): serve live projection during historical ingest --- .../worker.rs | 73 +++++++++++-------- .../worker_tests.rs | 44 ++++++----- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker.rs b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker.rs index 5bc41596610..87611566be5 100644 --- a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker.rs +++ b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker.rs @@ -7,6 +7,7 @@ use std::sync::PoisonError; use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; +use futures_util::future::{FusedFuture, FutureExt}; use tracedecay_lcm::LcmError; use tracedecay_store::{ SessionRefreshBeginOrJoinRequestV1, SessionRefreshCompletionRequestV1, @@ -246,7 +247,7 @@ pub(super) async fn run_session_temporal_refresh_scheduler( let mut history_release = None; let mut granted_history_admission = None; let mut projection_joins: Vec = Vec::new(); - let live_projection = LiveProjectionDuringSummary { + let live_projection = LiveProjection { database: &database, state: &state, projector: projector.as_ref(), @@ -264,16 +265,18 @@ pub(super) async fn run_session_temporal_refresh_scheduler( // Busy before any wake is consumed: a waiter must never see its // wake taken while the worker still reads idle. state.mark_worker_busy(); - let mut projection_requested = state.take_dirty(); let mut history_requested = state.take_historical_dirty(); // A wake that arrives while the admitted window is still - // unpublished must not start the next history window. That window - // is the out-of-scope cursor sweep, and it holds this worker until - // it returns, so the newest day never leaves its building generation. - if state.history_held_for_projection() { + // unpublished must not start the next history window. Publish + // the newest day before admitting the out-of-scope cursor sweep. + let holding_projection = state.history_held_for_projection(); + if holding_projection { history_requested = false; - projection_requested = true; } + // A history window polls live projection alongside ingest, so leave + // its dirty flag available until that work can actually start. + let mut projection_requested = + !history_requested && (state.take_dirty() || holding_projection); if !projection_requested && !history_requested && !summary_recheck { break; } @@ -289,6 +292,7 @@ pub(super) async fn run_session_temporal_refresh_scheduler( let granted = granted_history_admission .take() .filter(|_| history_requested && !history_suppressed); + let mut report = SessionTemporalRefreshPassReport::default(); let history_outcome = if history_requested && !history_suppressed { let history_pass: Pin + Send + '_>> = Box::pin(session_history_refresh( @@ -297,11 +301,25 @@ pub(super) async fn run_session_temporal_refresh_scheduler( granted, &state, )); - let (outcome, release) = tracing::Instrument::instrument( + let history_pass = tracing::Instrument::instrument( history_pass, tracing::trace_span!("daemon.scheduler.session_temporal.history"), ) - .await; + .fuse(); + tokio::pin!(history_pass); + // A hook can arrive before or during this source window. Keep the + // admitted ingestor running while publishing its already-durable input. + let Some(((outcome, release), _)) = live_projection + .wait_for(&mut history_pass, &mut projection_joins, &mut report, false) + .await + else { + // Retirement owns this join. Cancellation has signalled the + // ingestor, but its store and admission stay leased until it exits. + if !history_pass.is_terminated() { + history_pass.await; + } + return; + }; history_release = release; Some(outcome) } else { @@ -371,10 +389,7 @@ pub(super) async fn run_session_temporal_refresh_scheduler( blocked_history_attempts = 0; blocked_history_until = None; } - let mut report = if projection_requested - || state.has_requests() - || history_requires_projection - { + if projection_requested || state.has_requests() || history_requires_projection { let projection: Pin< Box + Send + '_>, > = Box::pin(session_projection_refresh( @@ -389,14 +404,12 @@ pub(super) async fn run_session_temporal_refresh_scheduler( tracing::trace_span!("daemon.scheduler.session_temporal.projection"), ); tokio::pin!(pass); - tokio::select! { + report = tokio::select! { biased; () = tracing::Instrument::instrument(state.wait_for_cancellation(), tracing::trace_span!("daemon.scheduler.session_temporal.projection_cancel")) => return, report = &mut pass => report, - } - } else { - SessionTemporalRefreshPassReport::default() - }; + }; + } if state.cancelled.load(Ordering::Acquire) { return; } @@ -937,17 +950,16 @@ async fn settle_projection_joins( } } -/// Keeps one fair summary admission or in-flight summary page alive while the -/// same worker services live temporal projection. Both futures keep polling: -/// suspending a summary while it owns a writer could block the projection. -struct LiveProjectionDuringSummary<'a> { +/// Keeps history, summary admission, or a summary page in flight while serving +/// live projection. A stage holding a writer must keep polling so it can release it. +struct LiveProjection<'a> { database: &'a RegisteredGlobalDbLeaseV1, state: &'a Arc, projector: &'a dyn SessionTemporalRefreshProjector, policy: SessionTemporalRefreshPolicy, } -impl LiveProjectionDuringSummary<'_> { +impl LiveProjection<'_> { async fn wait_for( &self, stage: F, @@ -971,7 +983,7 @@ impl LiveProjectionDuringSummary<'_> { self.state.requeue_projection(); retry_at = Some(Instant::now() + session_refresh_retry_delay(class, retry_attempt)); } - let mut summary_pass_owed = false; + let mut projected_during_stage = false; let result = loop { let projection_wake = async { let wake = self.state.wake.notified(); @@ -999,7 +1011,7 @@ impl LiveProjectionDuringSummary<'_> { if !self.state.take_dirty() { continue; } - summary_pass_owed |= !waiting_for_admission; + projected_during_stage |= !waiting_for_admission; self.state.mark_worker_busy(); self.state.begin_pass(); self.state.pass_count.fetch_add(1, Ordering::AcqRel); @@ -1064,10 +1076,9 @@ impl LiveProjectionDuringSummary<'_> { self.state.idle.notify_waiters(); } }; - // A running page may have read its inputs before the consumed wake. - // Its follow-up needs summary convergence, not another projection of - // the frontier this wake already served. Admission has no page yet. - Some((result, summary_pass_owed)) + // A summary page may have read its inputs before this projection, + // while a queued admission still has its first page ahead of it. + Some((result, projected_during_stage)) } } @@ -2277,7 +2288,7 @@ mod tests { entered.notify_one(); finish.notified().await; }; - let live_projection = LiveProjectionDuringSummary { + let live_projection = LiveProjection { database: &database, state: &state, projector: &CanonicalSessionTemporalProjector, @@ -2360,7 +2371,7 @@ mod tests { finish.notified().await; 17usize }; - let live_projection = LiveProjectionDuringSummary { + let live_projection = LiveProjection { database: &database, state: &state, projector: &CanonicalSessionTemporalProjector, diff --git a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker_tests.rs b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker_tests.rs index bf9b23cf40e..ad619330ce0 100644 --- a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker_tests.rs +++ b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker_tests.rs @@ -333,12 +333,9 @@ impl SessionHistoricalIngestor for GatedHistory { fn cancel(&self) {} } -/// A hook ingest joins the refresh worker with a wake. When that wake lands -/// while a history pass is running, the pass's projection serves it and no -/// further pass starts, so the join must end with that pass rather than wait -/// out its deadline. +/// An already admitted history scan must not hold a live hook behind its window. #[tokio::test] -async fn a_wake_served_by_the_running_pass_joins_that_pass() { +async fn a_live_session_publishes_while_a_history_window_is_running() { let harness = RegisteredGlobalDbHarness::open("refresh-wake-served-mid-pass").await; let state = Arc::new(SessionTemporalRefreshWakeState::default()); let history = Arc::new(GatedHistory { @@ -357,23 +354,36 @@ async fn a_wake_served_by_the_running_pass_joins_that_pass() { )); history.entered.notified().await; + let session_id = SessionId::new("live-during-history").expect("session id"); + let store = SessionTemporalStore::new(harness.registered.as_ref()); + let begun = store + .begin_or_join_session_refresh(SessionRefreshBeginOrJoinRequestV1::new( + session_id.clone(), + SessionRefreshFrontierV1::new(0, 0).expect("empty frontier"), + )) + .await + .expect("running refresh"); let handle = state.handle(); - let join = handle.wake_and_wait_for_projection(Duration::from_secs(5)); - tokio::pin!(join); - // The first poll registers the join and delivers the wake. - assert!( - std::future::poll_fn(|cx| std::task::Poll::Ready(join.as_mut().poll(cx))) - .await - .is_pending() - ); + let published = handle + .wake_and_wait_for_session_projection(&session_id, Duration::from_secs(5)) + .await; history.release.notify_one(); + state.cancel(); + worker.await.expect("refresh worker exits on cancellation"); assert!( - join.await, - "the pass that served the wake must end the join" + published, + "the live session must publish while history is held" ); - state.cancel(); - worker.await.expect("refresh worker exits on cancellation"); + let receipt = store + .session_refresh_receipt(SessionRefreshReceiptRequestV1::new( + begun.operation_id().clone(), + begun.session_id().clone(), + )) + .await + .expect("read publication receipt") + .expect("the live generation must be durable"); + assert_eq!(receipt.state(), SessionRefreshTerminalStateV1::Complete); } /// Every history window advances but waits for the test before returning. From f81ee089938bddd681e01da7526387452024869e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:55:27 -0800 Subject: [PATCH 15/26] fix(lcm): serialize summary publication per store and session --- .../src/lcm_effects.rs | 45 ++++++++++++++++++- .../src/lcm_effects/tests.rs | 20 +++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-session-runtime/src/lcm_effects.rs b/crates/tracedecay-session-runtime/src/lcm_effects.rs index 678df440765..b58bade23c9 100644 --- a/crates/tracedecay-session-runtime/src/lcm_effects.rs +++ b/crates/tracedecay-session-runtime/src/lcm_effects.rs @@ -1,9 +1,13 @@ use std::future::Future; +use std::path::PathBuf; use std::pin::Pin; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::Duration; +use tokio::sync::{Mutex, OwnedMutexGuard}; use tracedecay_contracts::{CancellationSignal, Deadline}; +use tracedecay_runtime_core::native_runtime::{self, TimeoutError}; +use tracedecay_runtime_core::weak_registry::WeakRegistry; use tracedecay_temporal_query::execution::ExecutionControl; use tracedecay_domain::SessionId; @@ -110,10 +114,48 @@ impl DaemonLcmEffectService { } } + /// Planning and publishing use separate transactions around the provider + /// call. A foreground hook and retained convergence for the same session + /// must re-plan after the preceding publication before launching a model. + async fn compression_turn( + &self, + request: &LcmCompressionRequest, + ) -> Result, LcmError> { + type SessionTurns = WeakRegistry<(PathBuf, String, String), Mutex<()>>; + static TURNS: LazyLock = LazyLock::new(WeakRegistry::new); + let key = ( + self.db.db_path().to_path_buf(), + request.provider.clone(), + request.session_id.clone(), + ); + let (turn, _) = TURNS.get_or_insert_with(key, || Arc::new(Mutex::new(()))); + let remaining = self.control.remaining()?; + if let Ok(guard) = Arc::clone(&turn).try_lock_owned() { + return Ok(guard); + } + let cancelled = async { + match &self.control.cancellation { + Some(signal) => signal.cancelled().await, + None => std::future::pending().await, + } + }; + tokio::select! { + biased; + () = cancelled => Err(LcmError::Cancelled), + result = native_runtime::timeout(remaining, turn.lock_owned()) => result.map_err(|error| { + match error { + TimeoutError::Elapsed => LcmError::DeadlineExceeded, + TimeoutError::TimerUnavailable(reason) => LcmError::Io(reason), + } + }), + } + } + pub(super) async fn compress( &self, mut request: LcmCompressionRequest, ) -> Result { + let _turn = self.compression_turn(&request).await?; // Observation-projected sessions land raw rows without ingest // protection; the verified raw loads inside compression reject them. // Hydrate the canonical sanitization receipts first so the daemon @@ -197,6 +239,7 @@ impl DaemonLcmEffectService { mut request: LcmCompressionRequest, convergence_candidate: &tracedecay_lcm::summary_convergence::LcmSummaryConvergenceCandidate, ) -> Result { + let _turn = self.compression_turn(&request).await?; if request.summarizer.has_nonempty_provided_summary() { return self .commit_retained_compression(&request, Some(convergence_candidate), None) diff --git a/crates/tracedecay-session-runtime/src/lcm_effects/tests.rs b/crates/tracedecay-session-runtime/src/lcm_effects/tests.rs index ee3ab998e87..c268d65ea18 100644 --- a/crates/tracedecay-session-runtime/src/lcm_effects/tests.rs +++ b/crates/tracedecay-session-runtime/src/lcm_effects/tests.rs @@ -91,6 +91,26 @@ fn compression_request(session_id: &str) -> LcmCompressionRequest { } } +#[tokio::test] +async fn a_queued_compression_honors_cancellation_without_blocking_other_sessions() { + let harness = RegisteredGlobalDbHarness::open("lcm-session-compression-turn").await; + let signal = CancellationSignal::active("queued-compression").unwrap(); + let owner = DaemonLcmEffectService::new(harness.registered.clone(), None, None, None); + let caller = DaemonLcmEffectService::new(harness.registered.clone(), None, Some(&signal), None); + let first = compression_request("first-session"); + let _held = owner.compression_turn(&first).await.unwrap(); + let waiting = caller.compress(first); + tokio::pin!(waiting); + assert!(futures_util::poll!(&mut waiting).is_pending()); + + let _independent = owner + .compression_turn(&compression_request("other-session")) + .await + .unwrap(); + signal.cancel(UtcMicros(1)); + assert!(matches!(waiting.await, Err(LcmError::Cancelled))); +} + fn provided_summary(summary_text: impl Into) -> LcmSummarizerMode { LcmSummarizerMode::Provided { summary_text: summary_text.into(), From ef851d84cc966514434eed8dbce9a3f28d9319d7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:12:39 -0800 Subject: [PATCH 16/26] test(mcp): receive connected host replies before closing stdin --- crates/tracedecay/tests/mcp_suite/serve_harness.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay/tests/mcp_suite/serve_harness.rs b/crates/tracedecay/tests/mcp_suite/serve_harness.rs index 69cb5fa007e..8ab7e1afbc4 100644 --- a/crates/tracedecay/tests/mcp_suite/serve_harness.rs +++ b/crates/tracedecay/tests/mcp_suite/serve_harness.rs @@ -105,8 +105,10 @@ pub fn run_serve_after_ready( "params": initialize_params })); session.await_project_ready(); + // This is a connected host journey. Let each operation deliver its reply + // before timing the proxy's exit on EOF. for request in requests { - session.send(request); + session.request(request.clone()); } session.finish(SERVE_CHILD_TIMEOUT) } From 6b76f00341bb278b1a350139d38c57060499935c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:47:10 -0800 Subject: [PATCH 17/26] fix(sessions): reserve history preemption for waiting readers --- .../wake.rs | 8 ++ .../worker.rs | 102 ++++++++++++++---- .../temporal_projection/activation.rs | 12 ++- 3 files changed, 99 insertions(+), 23 deletions(-) diff --git a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/wake.rs b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/wake.rs index d4eea3369fb..95bc1c443f5 100644 --- a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/wake.rs +++ b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/wake.rs @@ -378,6 +378,14 @@ impl SessionTemporalRefreshWakeState { Some(receiver) } + pub(super) fn has_pending_projection_join(&self) -> bool { + self.projection_joins + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .any(|join| !join.completion.is_closed()) + } + /// Take the joins covered by the projection about to start. A join that /// arrives during that projection remains queued for the next pass. pub(super) fn take_projection_joins(&self) -> Vec { diff --git a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker.rs b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker.rs index 87611566be5..57a7a987f49 100644 --- a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker.rs +++ b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/worker.rs @@ -310,7 +310,12 @@ pub(super) async fn run_session_temporal_refresh_scheduler( // A hook can arrive before or during this source window. Keep the // admitted ingestor running while publishing its already-durable input. let Some(((outcome, release), _)) = live_projection - .wait_for(&mut history_pass, &mut projection_joins, &mut report, false) + .wait_for( + &mut history_pass, + &mut projection_joins, + &mut report, + BackgroundStage::History, + ) .await else { // Retirement owns this join. Cancellation has signalled the @@ -478,7 +483,12 @@ pub(super) async fn run_session_temporal_refresh_scheduler( state.mark_worker_idle(); state.idle.notify_waiters(); let Some((permit, _)) = live_projection - .wait_for(&mut admission, &mut projection_joins, &mut report, true) + .wait_for( + &mut admission, + &mut projection_joins, + &mut report, + BackgroundStage::SummaryAdmission, + ) .await else { return; @@ -511,7 +521,12 @@ pub(super) async fn run_session_temporal_refresh_scheduler( ); tokio::pin!(page); let Some((result, recheck)) = live_projection - .wait_for(&mut page, &mut projection_joins, &mut report, false) + .wait_for( + &mut page, + &mut projection_joins, + &mut report, + BackgroundStage::SummaryPage, + ) .await else { return; @@ -950,6 +965,13 @@ async fn settle_projection_joins( } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum BackgroundStage { + History, + SummaryAdmission, + SummaryPage, +} + /// Keeps history, summary admission, or a summary page in flight while serving /// live projection. A stage holding a writer must keep polling so it can release it. struct LiveProjection<'a> { @@ -960,12 +982,22 @@ struct LiveProjection<'a> { } impl LiveProjection<'_> { + fn projection_due(&self, stage: BackgroundStage, joins: &[SessionProjectionJoin]) -> bool { + // An admitted history window owns background recovery until its SQL + // source frontier settles. Only a waiting reader needs to publish + // ahead of that window. + self.state.dirty.load(Ordering::Acquire) + && (stage != BackgroundStage::History + || joins.iter().any(|join| !join.completion.is_closed()) + || self.state.has_pending_projection_join()) + } + async fn wait_for( &self, stage: F, joins: &mut Vec, report: &mut SessionTemporalRefreshPassReport, - waiting_for_admission: bool, + background: BackgroundStage, ) -> Option<(F::Output, bool)> where F: Future + Send, @@ -983,19 +1015,20 @@ impl LiveProjection<'_> { self.state.requeue_projection(); retry_at = Some(Instant::now() + session_refresh_retry_delay(class, retry_attempt)); } - let mut projected_during_stage = false; + let mut recheck_summary = false; let result = loop { let projection_wake = async { let wake = self.state.wake.notified(); + if !self.projection_due(background, joins) { + wake.await; + return true; + } if let Some(deadline) = retry_at { tokio::select! { () = wake => true, fired = native_runtime::sleep_until(deadline) => !stop_without_timer(fired), } } else { - if !self.state.dirty.load(Ordering::Acquire) { - wake.await; - } true } }; @@ -1008,10 +1041,10 @@ impl LiveProjection<'_> { if !woke { return None; } - if !self.state.take_dirty() { + if !self.projection_due(background, joins) || !self.state.take_dirty() { continue; } - projected_during_stage |= !waiting_for_admission; + recheck_summary |= background == BackgroundStage::SummaryPage; self.state.mark_worker_busy(); self.state.begin_pass(); self.state.pass_count.fetch_add(1, Ordering::AcqRel); @@ -1071,14 +1104,14 @@ impl LiveProjection<'_> { if let Some(result) = completed_stage { break result; } - if waiting_for_admission { + if background == BackgroundStage::SummaryAdmission { self.state.mark_worker_idle(); self.state.idle.notify_waiters(); } }; // A summary page may have read its inputs before this projection, // while a queued admission still has its first page ahead of it. - Some((result, projected_during_stage)) + Some((result, recheck_summary)) } } @@ -2267,12 +2300,45 @@ mod tests { use tracedecay_runtime_core::db::engine::params; use tracedecay_store::{SessionRefreshReceiptRequestV1, SessionRefreshTerminalStateV1}; + #[tokio::test] + async fn a_history_window_leaves_background_projection_queued() { + let harness = RegisteredGlobalDbHarness::open("history-retains-background-work").await; + let state = Arc::new(SessionTemporalRefreshWakeState::default()); + state.wake(); + let live = LiveProjection { + database: &harness.registered, + state: &state, + projector: &CanonicalSessionTemporalProjector, + policy: SessionTemporalRefreshPolicy::default(), + }; + let mut joins = Vec::new(); + let mut report = SessionTemporalRefreshPassReport::default(); + live.wait_for( + tokio::task::yield_now(), + &mut joins, + &mut report, + BackgroundStage::History, + ) + .await + .expect("the history window finishes"); + assert_eq!(state.pass_count.load(Ordering::Acquire), 0); + assert!( + state.take_dirty(), + "background recovery still owns its turn" + ); + } + #[tokio::test] async fn a_live_wake_during_summary_retains_one_convergence_pass() { - for (name, waiting_for_admission, cancel, owes_convergence) in [ - ("completed-page", false, false, true), - ("completed-admission", true, false, false), - ("cancelled-page", false, true, false), + for (name, background, cancel, owes_convergence) in [ + ("completed-page", BackgroundStage::SummaryPage, false, true), + ( + "completed-admission", + BackgroundStage::SummaryAdmission, + false, + false, + ), + ("cancelled-page", BackgroundStage::SummaryPage, true, false), ] { let harness = RegisteredGlobalDbHarness::open(name).await; let state = Arc::new(SessionTemporalRefreshWakeState::default()); @@ -2297,7 +2363,7 @@ mod tests { let mut joins = Vec::new(); let mut report = SessionTemporalRefreshPassReport::default(); live_projection - .wait_for(stage, &mut joins, &mut report, waiting_for_admission) + .wait_for(stage, &mut joins, &mut report, background) .await } }); @@ -2380,7 +2446,7 @@ mod tests { let mut joins = Vec::new(); let mut report = SessionTemporalRefreshPassReport::default(); live_projection - .wait_for(stage, &mut joins, &mut report, false) + .wait_for(stage, &mut joins, &mut report, BackgroundStage::SummaryPage) .await } }); diff --git a/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs b/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs index 28a1f8d0d5c..340c44db1f5 100644 --- a/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs +++ b/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs @@ -1,5 +1,5 @@ use std::future::Future; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use tracedecay_session_runtime::session_temporal_refresh_scheduler::SessionTemporalRefreshSchedulerRegistry; @@ -328,14 +328,16 @@ async fn activation_is_pinned_to_the_generation_the_refresh_extends() { #[derive(Default)] struct GatedHistory { entered: tokio::sync::Notify, - release: tokio::sync::Notify, + release: OnceLock<()>, } impl SessionHistoricalIngestor for GatedHistory { fn run_pass(&self) -> SessionHistoricalIngestPass<'_> { Box::pin(async move { self.entered.notify_one(); - self.release.notified().await; + // Both readers must register before the native worker can capture + // the superseded candidate. A yielding history pass serves live joins. + self.release.wait(); SessionHistoricalIngestOutcome::Complete.into() }) } @@ -345,7 +347,7 @@ impl SessionHistoricalIngestor for GatedHistory { } fn cancel(&self) { - self.release.notify_one(); + let _ = self.release.set(()); } } @@ -409,7 +411,7 @@ async fn reset_requested_during_a_refresh_supersedes_its_candidate() { .await, (true, true) ); - history.release.notify_one(); + history.release.set(()).unwrap(); assert_eq!( tokio::join!(named_join, aggregate_join), (false, false), From dafd46336f9a5abd0f048417b028fffedaaba765 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:48:35 -0800 Subject: [PATCH 18/26] test(sessions): yield refresh history gate instead of blocking worker A synchronous OnceLock wait inside the asynchronous historical-ingest fixture blocked the worker that must poll live projection concurrently. Use a durable async watch latch to preserve release ordering while exercising the real interleaving and supersession journey. Refs #4481 --- .../temporal_projection/activation.rs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs b/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs index 340c44db1f5..e4562af19a8 100644 --- a/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs +++ b/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs @@ -1,5 +1,5 @@ use std::future::Future; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; use std::time::Duration; use tracedecay_session_runtime::session_temporal_refresh_scheduler::SessionTemporalRefreshSchedulerRegistry; @@ -325,19 +325,31 @@ async fn activation_is_pinned_to_the_generation_the_refresh_extends() { ); } -#[derive(Default)] struct GatedHistory { entered: tokio::sync::Notify, - release: OnceLock<()>, + release: tokio::sync::watch::Sender, +} + +impl Default for GatedHistory { + fn default() -> Self { + Self { + entered: tokio::sync::Notify::new(), + release: tokio::sync::watch::Sender::new(false), + } + } } impl SessionHistoricalIngestor for GatedHistory { fn run_pass(&self) -> SessionHistoricalIngestPass<'_> { + let mut release = self.release.subscribe(); Box::pin(async move { self.entered.notify_one(); - // Both readers must register before the native worker can capture - // the superseded candidate. A yielding history pass serves live joins. - self.release.wait(); + // The watch latch preserves release even when it precedes this poll, + // while yielding the native worker to concurrent live projection. + release + .wait_for(|released| *released) + .await + .expect("history release sender remains open"); SessionHistoricalIngestOutcome::Complete.into() }) } @@ -347,7 +359,7 @@ impl SessionHistoricalIngestor for GatedHistory { } fn cancel(&self) { - let _ = self.release.set(()); + let _ = self.release.send_replace(true); } } @@ -411,7 +423,7 @@ async fn reset_requested_during_a_refresh_supersedes_its_candidate() { .await, (true, true) ); - history.release.set(()).unwrap(); + assert!(!history.release.send_replace(true)); assert_eq!( tokio::join!(named_join, aggregate_join), (false, false), From 17bf3813b9635a9dac3ceef402a9e31d430e2740 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:15:53 -0800 Subject: [PATCH 19/26] test(sessions): join each superseded publication independently --- .../temporal_projection/activation.rs | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs b/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs index e4562af19a8..a7bc3b04760 100644 --- a/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs +++ b/crates/tracedecay/tests/session_suite/temporal_projection/activation.rs @@ -1,4 +1,3 @@ -use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -363,8 +362,22 @@ impl SessionHistoricalIngestor for GatedHistory { } } +#[derive(Clone, Copy, Debug)] +enum PublicationScope { + Session, + Profile, +} + #[tokio::test] async fn reset_requested_during_a_refresh_supersedes_its_candidate() { + // Each request wakes the worker immediately. A second request may already + // belong to the replacement pass, so exercise each scope on its own reset. + for scope in [PublicationScope::Session, PublicationScope::Profile] { + assert_superseded_publication(scope).await; + } +} + +async fn assert_superseded_publication(scope: PublicationScope) { let tmp = TempDir::new().unwrap(); let (runtime, path, stale) = frontier_digest_fixture(&tmp, true).await; let session_id = stale.session_id().clone(); @@ -412,23 +425,28 @@ async fn reset_requested_during_a_refresh_supersedes_its_candidate() { ) .await; history.entered.notified().await; - let named_join = wake.wake_and_wait_for_session_projection(&session_id, Duration::from_secs(5)); - let aggregate_join = wake.wake_and_wait_for_projection(Duration::from_secs(5)); - tokio::pin!(named_join, aggregate_join); + let timeout = Duration::from_secs(5); + let published = match scope { + PublicationScope::Session => { + wake.wake_and_wait_for_session_projection(&session_id, timeout) + .await + } + PublicationScope::Profile => wake.wake_and_wait_for_projection(timeout).await, + }; + assert!( + !published, + "the superseded operation published for {scope:?}" + ); assert_eq!( - std::future::poll_fn(|cx| std::task::Poll::Ready(( - named_join.as_mut().poll(cx).is_pending(), - aggregate_join.as_mut().poll(cx).is_pending(), - ))) + rows( + &path, + "SELECT operation_id FROM session_refresh_operations WHERE state = 'failed'" + ) .await, - (true, true) + vec![stale.operation_id().as_str()], + "{scope:?} must observe the failed operation while history remains parked" ); assert!(!history.release.send_replace(true)); - assert_eq!( - tokio::join!(named_join, aggregate_join), - (false, false), - "the superseded operation must refuse both waiting publication joins" - ); registry.wait_profile_idle(&path).await; assert_eq!( From 69101f64d7d1d2eb1377615936e15f86a9dc1648 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:12:39 -0800 Subject: [PATCH 20/26] test(mcp): receive connected host replies before closing stdin --- crates/tracedecay/tests/mcp_suite/serve_harness.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay/tests/mcp_suite/serve_harness.rs b/crates/tracedecay/tests/mcp_suite/serve_harness.rs index 69cb5fa007e..8ab7e1afbc4 100644 --- a/crates/tracedecay/tests/mcp_suite/serve_harness.rs +++ b/crates/tracedecay/tests/mcp_suite/serve_harness.rs @@ -105,8 +105,10 @@ pub fn run_serve_after_ready( "params": initialize_params })); session.await_project_ready(); + // This is a connected host journey. Let each operation deliver its reply + // before timing the proxy's exit on EOF. for request in requests { - session.send(request); + session.request(request.clone()); } session.finish(SERVE_CHILD_TIMEOUT) } From b85c6862303280e57b954b77639a8a0babde0c36 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:17:23 -0800 Subject: [PATCH 21/26] fix(dashboard): expose unqualified session links to Loom Loom already resolves bare session IDs against the loaded page and reports ambiguous matches instead of choosing a provider. Restore that explicit cross-view navigation for sessions without recorded provider identity. Add DOM navigation regressions for unknown provider, scoped known provider, and self-link exclusion. Refs #4220. --- .../sessions/SessionPivots.dom.test.tsx | 64 +++++++++++++++++++ .../src/workspaces/sessions/SessionPivots.tsx | 4 +- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/workspaces/sessions/SessionPivots.dom.test.tsx diff --git a/dashboard/src/workspaces/sessions/SessionPivots.dom.test.tsx b/dashboard/src/workspaces/sessions/SessionPivots.dom.test.tsx new file mode 100644 index 00000000000..259584aa188 --- /dev/null +++ b/dashboard/src/workspaces/sessions/SessionPivots.dom.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, useLocation } from 'react-router'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useScope } from '../../data/scope/store.ts'; +import { SessionPivots, type SessionWorkspace } from './SessionPivots.tsx'; +import type { SessionSelection } from './model.ts'; + +function CurrentLocation() { + const location = useLocation(); + return {location.pathname}{location.search}; +} + +function renderPivots(session: SessionSelection, from: SessionWorkspace = 'sessions') { + return render( + + + + , + ); +} + +beforeEach(() => { + useScope.getState().selectAllProjects(); +}); + +describe('session cross-workspace navigation', () => { + it('opens Loom by a bare session ID when the recorded provider is unknown', async () => { + const user = userEvent.setup(); + renderPivots({ provider: null, sessionId: 'session.alpha.1' }); + + const loom = screen.getByRole('link', { name: 'Open in Loom' }); + expect(loom.getAttribute('href')).toBe('/loom?loomSession=session.alpha.1'); + await user.click(loom); + expect(screen.getByTestId('session-pivot-location').textContent).toBe( + '/loom?loomSession=session.alpha.1', + ); + }); + + it('keeps an admitted project scope and a provider-qualified Loom identity', () => { + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + renderPivots({ provider: 'codex', sessionId: 'shared' }); + + const link = screen.getByRole('link', { name: 'Open in Loom' }); + const href = link.getAttribute('href'); + expect(href).not.toBeNull(); + const destination = new URL(href!, 'http://dashboard.test'); + expect(destination.pathname).toBe('/loom'); + expect(destination.searchParams.get('scope')).toBe('project-1'); + expect(destination.searchParams.get('scopeLabel')).toBe('Alpha'); + expect(destination.searchParams.get('loomSession')).toBe('["codex","shared"]'); + }); + + it('does not offer a self-link or guess a provider for the other destinations', () => { + renderPivots({ provider: null, sessionId: 'shared' }, 'loom'); + + expect(screen.queryByRole('link', { name: 'Open in Loom' })).toBeNull(); + const sessions = screen.getByRole('link', { name: 'Open in Sessions' }); + const destination = new URL(sessions.getAttribute('href')!, 'http://dashboard.test'); + expect(destination.searchParams.get('sessionId')).toBe('shared'); + expect(destination.searchParams.has('sessionProvider')).toBe(false); + }); +}); diff --git a/dashboard/src/workspaces/sessions/SessionPivots.tsx b/dashboard/src/workspaces/sessions/SessionPivots.tsx index 111d99cbc3c..c514677d750 100644 --- a/dashboard/src/workspaces/sessions/SessionPivots.tsx +++ b/dashboard/src/workspaces/sessions/SessionPivots.tsx @@ -39,8 +39,10 @@ export function SessionPivots({ from: SessionWorkspace; }) { const scope = useScope((state) => state.scope); + // Loom resolves provider-less IDs without inventing a lane; ambiguous + // matches are reported rather than silently selecting another provider. const targets = (Object.keys(PIVOTS) as SessionWorkspace[]).filter( - (target) => target !== from && (target !== 'loom' || session.provider !== null), + (target) => target !== from, ); return (