Fix release E2E flakes and gate publish

Gate release publishing on tag-scoped E2E, harden Droid agent-status routing against renderer layout races, and stabilize the affected E2E setup helpers.
This commit is contained in:
Neil 2026-05-21 14:16:01 -07:00 committed by GitHub
parent f36d9a58fe
commit 0322083c88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 346 additions and 113 deletions

View File

@ -441,10 +441,9 @@ jobs:
--generate-notes \
--prerelease="$is_rc"
# Why: E2E runs alongside the release for visibility (failures surface as a
# red check on the tag), but is NOT in `publish-release`'s needs list.
# Releases already take a while and the suite is already a required check
# on PRs, so gating here would mostly delay shipping without adding signal.
# Why: release-cut is the last gate before a tag becomes public. PR checks
# catch most regressions, but tag-scoped E2E must pass before publish-release
# flips the draft visible.
e2e:
needs: cut
if: needs.cut.outputs.should_release == 'true'
@ -695,6 +694,7 @@ jobs:
needs:
- cut
- build
- e2e
runs-on: ubuntu-latest
permissions:
contents: write

View File

@ -2181,6 +2181,114 @@ describe('useIpcEvents agent status snapshot integration', () => {
)
})
it('buffers ready push events until the pane leaf resolves in renderer layout', async () => {
const setAgentStatus = vi.fn()
const track = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
const storeState: StoreLike = buildStoreState({
setAgentStatus,
workspaceSessionReady: true,
settings: { terminalFontSize: 13, notifications: { enabled: false } },
tabsByWorktree: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
},
terminalLayoutsByTabId: {}
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn((listener: StoreSubscribeListener) => {
subscribeListenerRef.current = listener
return () => {
subscribeListenerRef.current = null
}
}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.doMock('@/lib/telemetry', () => ({ track }))
vi.stubGlobal(
'window',
buildWindowApi({
onSet: (cb) => {
onSetListenerRef.current = cb
return () => {}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof onSetListenerRef.current !== 'function') {
throw new Error('Expected agentStatus.onSet listener to be registered')
}
onSetListenerRef.current({
paneKey: FUTURE_PANE_KEY,
state: 'working',
prompt: 'queued prompt',
agentType: 'codex',
receivedAt: 1_700_000_000_100,
stateStartedAt: 1_699_999_999_100
})
onSetListenerRef.current({
paneKey: FUTURE_PANE_KEY,
state: 'done',
prompt: 'queued prompt',
agentType: 'codex',
lastAssistantMessage: 'queued completion',
receivedAt: 1_700_000_000_200,
stateStartedAt: 1_699_999_999_100
})
expect(setAgentStatus).not.toHaveBeenCalled()
expect(track).toHaveBeenCalledWith('agent_hook_unattributed', {
reason: 'unknown_tab_id'
})
storeState.terminalLayoutsByTabId = {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
}
if (typeof subscribeListenerRef.current !== 'function') {
throw new Error('Expected useAppStore.subscribe listener to be registered')
}
subscribeListenerRef.current(storeState)
expect(setAgentStatus).toHaveBeenCalledTimes(2)
expect(setAgentStatus).toHaveBeenNthCalledWith(
1,
FUTURE_PANE_KEY,
expect.objectContaining({ state: 'working', prompt: 'queued prompt', agentType: 'codex' }),
'Future Tab',
{ updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 }
)
expect(setAgentStatus).toHaveBeenNthCalledWith(
2,
FUTURE_PANE_KEY,
expect.objectContaining({
state: 'done',
prompt: 'queued prompt',
agentType: 'codex',
lastAssistantMessage: 'queued completion'
}),
'Future Tab',
{ updatedAt: 1_700_000_000_200, stateStartedAt: 1_699_999_999_100 }
)
})
it('applies remote status snapshots while repo ownership is still hydrating', async () => {
const setAgentStatus = vi.fn()
const getSnapshot = vi.fn(() =>

View File

@ -81,6 +81,9 @@ import {
export { resolveZoomTarget } from './resolve-zoom-target'
const ZOOM_STEP = 0.5
const PENDING_AGENT_STATUS_RETRY_MS = 100
const PENDING_AGENT_STATUS_TTL_MS = 15_000
const MAX_PENDING_AGENT_STATUS_EVENTS = 100
let remoteWorkspaceSnapshotApplyDepth = 0
let remoteWorkspaceSnapshotWriteSuppressUntil = 0
const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000
@ -488,6 +491,13 @@ function getActiveRuntimeEnvironmentId(): string | null {
export function useIpcEvents(): void {
useEffect(() => {
const unsubs: (() => void)[] = []
type PendingAgentStatusEvent = {
data: AgentStatusIpcPayload
firstSeenAt: number
}
type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped'
const pendingAgentStatusEvents: PendingAgentStatusEvent[] = []
let pendingAgentStatusRetryTimer: ReturnType<typeof setTimeout> | null = null
unsubs.push(attachMobileMarkdownBridge())
@ -1801,13 +1811,55 @@ export function useIpcEvents(): void {
// hook callback or an OSC fallback path. Startup pushes are ignored until
// workspace session hydration finishes; the snapshot pull below replays the
// main-process cache after tab identity is available.
function schedulePendingAgentStatusFlush(): void {
if (pendingAgentStatusRetryTimer !== null || pendingAgentStatusEvents.length === 0) {
return
}
pendingAgentStatusRetryTimer = globalThis.setTimeout(() => {
pendingAgentStatusRetryTimer = null
flushPendingAgentStatuses()
}, PENDING_AGENT_STATUS_RETRY_MS)
}
function enqueuePendingAgentStatus(data: AgentStatusIpcPayload): void {
pendingAgentStatusEvents.push({ data, firstSeenAt: Date.now() })
while (pendingAgentStatusEvents.length > MAX_PENDING_AGENT_STATUS_EVENTS) {
pendingAgentStatusEvents.shift()
}
schedulePendingAgentStatusFlush()
}
function flushPendingAgentStatuses(): void {
if (pendingAgentStatusEvents.length === 0) {
return
}
const now = Date.now()
const remaining: PendingAgentStatusEvent[] = []
for (const event of pendingAgentStatusEvents) {
if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) {
continue
}
const result = applyAgentStatus(event.data, { retry: true })
if (result === 'pending') {
remaining.push(event)
}
}
pendingAgentStatusEvents.length = 0
pendingAgentStatusEvents.push(...remaining)
if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) {
globalThis.clearTimeout(pendingAgentStatusRetryTimer)
pendingAgentStatusRetryTimer = null
}
schedulePendingAgentStatusFlush()
}
const applyAgentStatus = (
data: AgentStatusIpcPayload,
options?: { replay?: boolean }
): void => {
options?: { replay?: boolean; retry?: boolean }
): AgentStatusApplyResult => {
const store = useAppStore.getState()
if (!store.workspaceSessionReady) {
return
return 'dropped'
}
const payload = normalizeAgentStatusPayload({
state: data.state,
@ -1819,7 +1871,7 @@ export function useIpcEvents(): void {
interrupted: data.interrupted
})
if (!payload) {
return
return 'dropped'
}
const { exists, title, repoConnectionId, repoConnectionResolved, owningWorktreeId } =
resolvePaneKey(store, data.paneKey)
@ -1831,9 +1883,23 @@ export function useIpcEvents(): void {
// include entries whose tabs were closed before this session — that
// reconciliation miss is not a regression signal.
if (options?.replay !== true) {
track('agent_hook_unattributed', { reason: 'unknown_tab_id' })
if (options?.retry !== true) {
track('agent_hook_unattributed', { reason: 'unknown_tab_id' })
// Why: live hook IPC can beat the renderer's tab/layout hydration.
// Main already cached the event; retry locally so a transient
// pane-key miss does not drop Droid/Codex completion state.
enqueuePendingAgentStatus(data)
}
return 'pending'
}
return 'dropped'
}
if (options?.replay !== true && options?.retry !== true) {
for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) {
if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) {
pendingAgentStatusEvents.splice(index, 1)
}
}
return
}
// Why: drop in-flight events from a connection that no longer owns
// this pane. After an SSH disconnect (or tab destroy/recreate during
@ -1860,7 +1926,7 @@ export function useIpcEvents(): void {
data.connectionId !== repoConnectionId &&
!canAcceptPendingRemoteOwnership
) {
return
return 'dropped'
}
store.setAgentStatus(data.paneKey, payload, title, {
updatedAt: data.receivedAt,
@ -1877,6 +1943,7 @@ export function useIpcEvents(): void {
payload
})
}
return 'applied'
}
let snapshotRequestedForReadyWindow = false
@ -1972,6 +2039,7 @@ export function useIpcEvents(): void {
unsubs.push(
useAppStore.subscribe(() => {
requestAgentStatusSnapshotIfReady()
flushPendingAgentStatuses()
syncAgentHookCompletionNotificationSettings()
})
)
@ -2084,6 +2152,10 @@ export function useIpcEvents(): void {
}
return () => {
if (pendingAgentStatusRetryTimer !== null) {
globalThis.clearTimeout(pendingAgentStatusRetryTimer)
}
pendingAgentStatusEvents.length = 0
unsubs.forEach((fn) => fn())
resetAgentHookCompletionNotificationCoordinators()
}

View File

@ -3,6 +3,7 @@ import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { getRendererTitleLog, installRendererTitleLog } from './helpers/terminal-title-log'
import {
sendToTerminal,
waitForActivePaneHookDescriptor,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
@ -116,33 +117,6 @@ async function getAgentStatuses(page: Page): Promise<
})
}
async function getActivePaneDescriptor(
page: Page
): Promise<{ paneKey: string; worktreeId: string }> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('No active worktree')
}
const tabId = state.activeTabIdByWorktree[worktreeId] ?? state.activeTabId
if (!tabId) {
throw new Error('No active tab')
}
const manager = window.__paneManagers?.get(tabId)
const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
const leafId = activePane ? manager?.getLeafIdMap?.().get(activePane.id) : null
if (!leafId) {
throw new Error('No active pane leaf id')
}
return { paneKey: `${tabId}:${leafId}`, worktreeId }
})
}
test.describe('Droid notifications', () => {
test('Codex hook completion dispatches while its worktree is inactive', async ({
orcaPage,
@ -162,7 +136,7 @@ test.describe('Droid notifications', () => {
await sendToTerminal(orcaPage, ptyId, `printf '${readyMarker}\\n'\r`)
await waitForTerminalOutput(orcaPage, readyMarker)
const { paneKey, worktreeId } = await getActivePaneDescriptor(orcaPage)
const { paneKey, worktreeId } = await waitForActivePaneHookDescriptor(orcaPage)
const prompt = `codex-hook-notify-${Date.now()}`
await emitCodexHookStatus(endpoint, {
paneKey,

View File

@ -166,58 +166,43 @@ export async function waitForSessionReady(page: Page, timeoutMs = 30_000): Promi
/** Wait until a worktree is active and return its ID. */
export async function waitForActiveWorktree(page: Page, timeoutMs = 30_000): Promise<string> {
const existingId = await getActiveWorktreeId(page)
if (existingId) {
return existingId
}
const activatedFromStore = await page.evaluate(() => {
const store = window.__store
if (!store) {
return false
}
const state = store.getState()
if (state.activeWorktreeId) {
return true
}
const firstWorktree = Object.values(state.worktreesByRepo).flat()[0]
if (!firstWorktree) {
return false
}
// Why: the sidebar no longer guarantees a role="option" worktree row
// during hydration, so DOM-click fallback can miss the only selectable
// worktree and leave fresh E2E sessions stuck with activeWorktreeId=null.
// Activating the first loaded worktree through the store matches the app's
// real selection path and keeps setup independent from sidebar markup.
state.setActiveWorktree(firstWorktree.id)
return true
})
if (!activatedFromStore) {
const primaryWorktreeOption = page.getByRole('option', { name: /primary/i }).first()
const anyWorktreeOption = page.getByRole('option').first()
const optionToClick =
(await primaryWorktreeOption.count()) > 0 ? primaryWorktreeOption : anyWorktreeOption
if ((await optionToClick.count()) > 0) {
// Why: isolated E2E sessions can finish hydrating with worktrees loaded but
// no selection restored. Clicking the sidebar option matches the real user
// path and drives the same activation logic the app relies on in production.
await optionToClick.click()
}
}
let activeWorktreeId: string | null = null
await expect
.poll(async () => getActiveWorktreeId(page), {
timeout: timeoutMs,
message: 'activeWorktreeId did not become available'
})
.poll(
async () => {
activeWorktreeId = await page.evaluate(() => {
const store = window.__store
if (!store) {
return null
}
let state = store.getState()
if (state.activeWorktreeId) {
return state.activeWorktreeId
}
const firstWorktree = Object.values(state.worktreesByRepo).flat()[0]
if (!firstWorktree) {
return null
}
// Why: isolated E2E sessions can hydrate worktree rows without
// restoring a selection. Re-try store activation as worktrees load
// instead of relying on sidebar option click hit targets.
state.setActiveWorktree(firstWorktree.id)
state = store.getState()
return state.activeWorktreeId
})
return activeWorktreeId
},
{
timeout: timeoutMs,
message: 'activeWorktreeId did not become available'
}
)
.not.toBeNull()
return (await getActiveWorktreeId(page))!
return activeWorktreeId!
}
/** Get all worktree IDs across all repos. */
@ -277,26 +262,6 @@ export async function switchToWorktree(page: Page, worktreeId: string): Promise<
* hidden-window mode and avoids racing that initial auto-create step.
*/
export async function ensureTerminalVisible(page: Page, timeoutMs = 10_000): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
return
}
const state = store.getState()
if (state.activeWorktreeId) {
const tabs = state.tabsByWorktree[state.activeWorktreeId] ?? []
if (tabs.length === 0) {
// Why: fresh isolated E2E profiles may not have finished the UI-driven
// auto-create effect yet. Use the same store action to create the first
// terminal tab so terminal-focused specs start from a stable baseline.
state.createTab(state.activeWorktreeId)
}
}
if (state.activeTabType !== 'terminal') {
state.setActiveTabType('terminal')
}
})
await expect
.poll(
async () =>
@ -305,12 +270,41 @@ export async function ensureTerminalVisible(page: Page, timeoutMs = 10_000): Pro
if (!store) {
return false
}
const state = store.getState()
if (state.activeTabType !== 'terminal' || !state.activeWorktreeId) {
let state = store.getState()
let worktreeId = state.activeWorktreeId
if (!worktreeId) {
const firstWorktree = Object.values(state.worktreesByRepo).flat()[0]
if (!firstWorktree) {
return false
}
// Why: reload-based specs can briefly clear the active worktree
// after session readiness while worktrees are already loaded.
state.setActiveWorktree(firstWorktree.id)
state = store.getState()
worktreeId = state.activeWorktreeId ?? firstWorktree.id
}
const tabs = state.tabsByWorktree[worktreeId] ?? []
const activeTab =
tabs.find((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) ??
tabs.find((tab) => tab.id === state.activeTabId) ??
tabs[0] ??
// Why: fresh isolated E2E profiles may not have finished the UI-driven
// auto-create effect yet. Use the same store action to create the first
// terminal tab so terminal-focused specs start from a stable baseline.
state.createTab(worktreeId)
state.setActiveTab(activeTab.id)
if (state.activeTabType !== 'terminal') {
state.setActiveTabType('terminal')
}
state = store.getState()
if (state.activeTabType !== 'terminal' || state.activeWorktreeId !== worktreeId) {
return false
}
const tabs = state.tabsByWorktree[state.activeWorktreeId] ?? []
return tabs.some((tab) => tab.id === state.activeTabId)
return (state.tabsByWorktree[worktreeId] ?? []).some(
(tab) => tab.id === state.activeTabId
)
}),
{ timeout: timeoutMs, message: 'No active terminal tab found for current worktree' }
)

View File

@ -17,6 +17,11 @@ export type PaneIdentitySnapshot = {
ptyIdsByLeafId: Record<string, string>
}
export type ActivePaneHookDescriptor = {
paneKey: string
worktreeId: string
}
// Why: worktree restoration can render the terminal surface before the legacy
// global activeTabId settles. Prefer the active worktree's saved terminal tab
// pointer, then fall back to the first terminal tab.
@ -121,6 +126,82 @@ export async function waitForActivePanePtyId(page: Page, timeoutMs = 15_000): Pr
return ptyId
}
export async function waitForActivePaneHookDescriptor(
page: Page,
timeoutMs = 15_000
): Promise<ActivePaneHookDescriptor> {
let descriptor: ActivePaneHookDescriptor | null = null
await expect
.poll(
async () => {
const tabId = await resolveActiveTabId(page)
if (!tabId) {
descriptor = null
return false
}
descriptor = await page.evaluate((tabId) => {
const layoutHasLeaf = (node: unknown, targetLeafId: string): boolean => {
if (!node || typeof node !== 'object') {
return false
}
const record = node as {
type?: unknown
leafId?: unknown
first?: unknown
second?: unknown
}
if (record.type === 'leaf') {
return record.leafId === targetLeafId
}
return (
layoutHasLeaf(record.first, targetLeafId) ||
layoutHasLeaf(record.second, targetLeafId)
)
}
const store = window.__store
const manager = window.__paneManagers?.get(tabId)
if (!store || !manager) {
return null
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (
!worktreeId ||
!(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId)
) {
return null
}
const activePane = manager.getActivePane?.() ?? manager.getPanes?.()[0]
const leafId = activePane?.leafId ?? null
const layout = state.terminalLayoutsByTabId[tabId]
if (
!leafId ||
!layoutHasLeaf(layout?.root, leafId) ||
layout?.ptyIdsByLeafId?.[leafId] !== activePane?.container?.dataset?.ptyId
) {
return null
}
return { paneKey: `${tabId}:${leafId}`, worktreeId }
}, tabId)
return descriptor !== null
},
{
timeout: timeoutMs,
// Why: hook IPC routing drops statuses for pane keys before the store
// layout knows that leaf, even if the terminal DOM already has a PTY.
message: 'Active terminal pane did not become routable for hook status IPC'
}
)
.toBe(true)
if (!descriptor) {
throw new Error('Active terminal pane descriptor disappeared after routing wait')
}
return descriptor
}
// Why: PTY IDs are opaque integers not exposed in the DOM. Probe each
// candidate with a unique marker and read back via SerializeAddon.
export async function discoverActivePtyId(page: Page): Promise<string> {

View File

@ -126,7 +126,11 @@ async function openRepoSettings(page: Page, repoId: string): Promise<Locator> {
}
async function openImportedSetupSettingsFromToast(page: Page, repoId: string): Promise<Locator> {
await page.getByRole('button', { name: 'View in Settings' }).click()
const viewInSettings = page.getByRole('button', { name: 'View in Settings' })
await expect(viewInSettings).toBeAttached({ timeout: 10_000 })
// Why: in hidden Electron CI windows, the Sonner action can be laid out just
// outside Playwright's viewport even though the action is mounted and wired.
await viewInSettings.evaluate((button) => (button as HTMLButtonElement).click())
const localCommands = page.locator(`[id="repo-${repoId}-local-commands"]`)
await expect(localCommands).toBeVisible({ timeout: 10_000 })
await expect(localCommands.getByText('Local Settings Commands').first()).toBeVisible()