From e39f1c917fe884c8779f5db8e74292a65e3a0ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 23 Sep 2026 07:00:59 +0200 Subject: [PATCH 01/10] fix(mobile): sweep stray Live Activities on launch and foreground https://github.com/Kilo-Org/cloud/pull/6483 --- .../ui/text.rtl-tracking.mounted.test.tsx | 10 +- .../src/glanceable-ios/adopt-activity.test.ts | 66 +++++++ .../src/glanceable-ios/adopt-activity.ts | 12 +- .../src/glanceable-ios/ios-sink.test.ts | 184 +++++++++++++++++- apps/mobile/src/glanceable-ios/ios-sink.ts | 90 ++++++++- .../src/glanceable-ios/register.test.ts | 32 ++- apps/mobile/src/glanceable-ios/register.ts | 15 +- .../mobile/src/lib/glanceable/persist.test.ts | 43 ++++ apps/mobile/src/lib/glanceable/persist.ts | 51 +++++ 9 files changed, 489 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/glanceable-ios/adopt-activity.test.ts diff --git a/apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx b/apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx index caadf94dca..12066dea69 100644 --- a/apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx +++ b/apps/mobile/src/components/ui/text.rtl-tracking.mounted.test.tsx @@ -43,9 +43,11 @@ afterEach(() => { renderer = undefined; }); -// The tracked classes the home screen labels carry: the eyebrow -// ("الجلسات الجارية الآن", "استكشف"), the section-header action ("عرض الكل") -// and the bottom tab labels. +// The tracked classes a caller puts on a label: the bottom tab labels and any +// other `className`-supplied `tracking-*`. They stay on the element in RTL and +// the reset below zeroes their spacing. The eyebrow variant and the +// section-header action own their Latin display treatment and drop it in RTL +// instead (see `Text`'s eyebrow variant). const TRACKED_CLASSES = ['tracking-[1.5px]', 'tracking-[0.2px]'] as const; describe('Text tracked labels in RTL', () => { @@ -98,7 +100,7 @@ describe('Text tracked labels in RTL', () => { expect(hostText(root).props.style).toBeUndefined(); }); - it('applies the same reset to the shared Eyebrow label', () => { + it('applies the same reset to the shared Eyebrow label, whose own display class is dropped in RTL', () => { i18nManager.isRTL = true; const root = mount(createElement(Eyebrow, null, 'استكشف')); diff --git a/apps/mobile/src/glanceable-ios/adopt-activity.test.ts b/apps/mobile/src/glanceable-ios/adopt-activity.test.ts new file mode 100644 index 0000000000..9419c0eaa9 --- /dev/null +++ b/apps/mobile/src/glanceable-ios/adopt-activity.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; + +import { ACTIVE_USER_ID_KEY, ORGANIZATION_STORAGE_KEY } from '@/lib/storage-keys'; + +const mocks = vi.hoisted(() => ({ + sweepStrayActivities: vi.fn(), + adoptNativeActivity: vi.fn(), + getLastGlanceableSnapshot: vi.fn<() => GlanceableAgentsSnapshot | null>(), + restorePersistedGlanceable: vi.fn().mockResolvedValue(undefined), + getItemAsync: vi.fn<(key: string) => string | null>(), +})); + +// The adoption hands the token to the delivery and reads the keychain; both are +// native graphs this suite never needs to run. +vi.mock('@/lib/glanceable/delivery-registration', () => ({})); +vi.mock('expo-secure-store', () => ({ getItemAsync: mocks.getItemAsync })); +vi.mock('@/lib/glanceable/persist', () => ({ + getLastGlanceableSnapshot: mocks.getLastGlanceableSnapshot, + restorePersistedGlanceable: mocks.restorePersistedGlanceable, +})); +vi.mock('./ios-sink', () => ({ + adoptNativeActivity: mocks.adoptNativeActivity, + sweepStrayActivities: mocks.sweepStrayActivities, +})); + +const { adoptPushStartedActivity } = await import('./adopt-activity'); + +const SNAPSHOT = { status: 'happy', scopeKey: 'scope' } as unknown as GlanceableAgentsSnapshot; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getLastGlanceableSnapshot.mockReturnValue(null); + mocks.getItemAsync.mockReturnValue(null); + mocks.restorePersistedGlanceable.mockResolvedValue(undefined); +}); + +describe('adoptPushStartedActivity', () => { + it('sweeps the surface at launch even when nothing can be adopted', async () => { + await adoptPushStartedActivity(); + + expect(mocks.restorePersistedGlanceable).toHaveBeenCalledTimes(1); + expect(mocks.sweepStrayActivities).toHaveBeenCalledTimes(1); + expect(mocks.adoptNativeActivity).not.toHaveBeenCalled(); + }); + + it('sweeps before handing a card to the server', async () => { + mocks.getLastGlanceableSnapshot.mockReturnValue(SNAPSHOT); + mocks.getItemAsync.mockImplementation((key: string) => + key === ACTIVE_USER_ID_KEY ? 'user-1' : 'org-9' + ); + + await adoptPushStartedActivity(); + + expect(mocks.sweepStrayActivities).toHaveBeenCalledTimes(1); + expect(mocks.adoptNativeActivity).toHaveBeenCalledWith(SNAPSHOT, { + userId: 'user-1', + organizationId: 'org-9', + }); + expect(mocks.getItemAsync).toHaveBeenCalledWith(ORGANIZATION_STORAGE_KEY); + expect(mocks.sweepStrayActivities.mock.invocationCallOrder[0]).toBeLessThan( + mocks.adoptNativeActivity.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ); + }); +}); diff --git a/apps/mobile/src/glanceable-ios/adopt-activity.ts b/apps/mobile/src/glanceable-ios/adopt-activity.ts index 04e5c44542..6828dc3488 100644 --- a/apps/mobile/src/glanceable-ios/adopt-activity.ts +++ b/apps/mobile/src/glanceable-ios/adopt-activity.ts @@ -8,7 +8,7 @@ import '@/lib/glanceable/delivery-registration'; import { getLastGlanceableSnapshot, restorePersistedGlanceable } from '@/lib/glanceable/persist'; import { ACTIVE_USER_ID_KEY, ORGANIZATION_STORAGE_KEY } from '@/lib/storage-keys'; -import { adoptNativeActivity } from './ios-sink'; +import { adoptNativeActivity, sweepStrayActivities } from './ios-sink'; async function readKey(key: string): Promise { try { @@ -29,11 +29,17 @@ async function readKey(key: string): Promise { * and the Lock Screen collects frozen duplicates. * * Runs at import, ahead of any React tree or session query, in the foreground - * process and in the headless push process alike. The native read inside also - * retires every instance except the adopted one. + * process and in the headless push process alike. It sweeps native truth before + * naming an owner, so a launch with nothing to adopt still retires the cards a + * previous process left behind. */ export async function adoptPushStartedActivity(): Promise { await restorePersistedGlanceable(); + // Retire what this launch cannot own before anything else reads the surface. + // It must run even when no snapshot or user is available: that is exactly the + // state a stray from a killed or replaced process is left in, and the early + // return below would otherwise leave it on the Lock Screen forever. + sweepStrayActivities(); const snapshot = getLastGlanceableSnapshot(); const userId = await readKey(ACTIVE_USER_ID_KEY); if (snapshot === null || userId === null) { diff --git a/apps/mobile/src/glanceable-ios/ios-sink.test.ts b/apps/mobile/src/glanceable-ios/ios-sink.test.ts index b8a2ca0a8b..bc2e5e700f 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.test.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.test.ts @@ -12,13 +12,15 @@ import { _resetLiveActivitySwitchForTests, setLiveActivityEnabledValue, } from '@/lib/glanceable/live-activity-switch'; -import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; -import { writeSignedOutSnapshotAndEnd } from '@/lib/glanceable/cleanup'; import { _resetGlanceablePersistForTests, + _setGlanceableRestoreUnavailableForTests, _setLastGlanceableSnapshotForTests, _setSecureStoreForTests, + restorePersistedGlanceable, } from '@/lib/glanceable/persist'; +import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; +import { writeSignedOutSnapshotAndEnd } from '@/lib/glanceable/cleanup'; import { GlanceablePublisher } from '@/lib/glanceable/publisher'; import { registerGlanceableSink, @@ -39,6 +41,7 @@ import { iosSink, renderStoredSnapshotWithNotice, setGlanceableActionNotice, + sweepStrayActivities, } from './ios-sink'; import { buildExpiredWidgetProps, @@ -224,9 +227,15 @@ function snapshotFor( }); } +/** How many of the given native cards were asked to end. */ +function endedCount(cards: { end: ReturnType }[]): number { + return cards.filter(card => card.end.mock.calls.length > 0).length; +} + beforeEach(() => { _resetLiveActivitySwitchForTests(); _resetIosSinkForTests(); + _resetGlanceablePersistForTests(); _resetWaitingAskForTests(); _resetGlanceablePersistForTests(); secureStore.clear(); @@ -1627,3 +1636,174 @@ describe('toWidgetProps', () => { }); }); }); + +describe('iosSink stray sweep', () => { + /** A card this process never started, as native discovery reports it. */ + function nativeStray(): { end: ReturnType; updated: unknown[] } { + const end = vi.fn(); + const updated: unknown[] = []; + mockState.instances.push({ + getPushToken: vi.fn().mockResolvedValue(null), + update: (next: unknown) => updated.push(next), + end, + }); + return { end, updated }; + } + + /** A persisted snapshot stamped now, so its claim has not expired. */ + function freshSnapshot( + sessions: { status: string }[] = [{ status: 'busy' }], + status?: GlanceableAgentsSnapshot['status'] + ): GlanceableAgentsSnapshot { + return buildGlanceableSnapshot({ + sessions, + userId: 'u1', + organizationId: null, + now: Date.now(), + ...(status === undefined ? {} : { status }), + }); + } + + it('keeps one card and ends the rest on launch while the persisted work claims one', async () => { + const cards = [nativeStray(), nativeStray(), nativeStray()]; + _setLastGlanceableSnapshotForTests(freshSnapshot()); + + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(2); + }); + // The single survivor is adopted, never replaced by a second start. + expect(mockState.started).toHaveLength(0); + }); + + it('keeps one card for an empty unexpired snapshot, for a push-to-start it has not adopted', async () => { + const cards = [nativeStray(), nativeStray()]; + _setLastGlanceableSnapshotForTests(freshSnapshot([], 'empty')); + + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(1); + }); + expect(mockState.started).toHaveLength(0); + }); + + it('ends every card on launch when the persisted claim has expired', async () => { + const cards = [nativeStray(), nativeStray(), nativeStray()]; + _setLastGlanceableSnapshotForTests({ + ...freshSnapshot(), + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(3); + }); + }); + + it('ends every card for an expired snapshot whose expiry was renewed', async () => { + const cards = [nativeStray(), nativeStray()]; + // `applyExpiry` stamps the lapsed snapshot eight hours out, so the timestamp + // alone reads as unexpired; the status is what says nothing owns a card. + _setLastGlanceableSnapshotForTests(freshSnapshot([], 'expired')); + + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(2); + }); + }); + + it('ends every card on launch when no snapshot claims one', async () => { + const cards = [nativeStray(), nativeStray()]; + _setLastGlanceableSnapshotForTests(null); + + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(2); + }); + }); + + it('keeps one card when the persisted owner could not be read', async () => { + const cards = [nativeStray(), nativeStray()]; + // A locked keychain at launch: the record may name an owner, so the card a + // push-to-start just raised must not be swept away as if none existed. + _setLastGlanceableSnapshotForTests(null); + _setGlanceableRestoreUnavailableForTests(true); + + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(1); + }); + expect(mockState.started).toHaveLength(0); + }); + + it('keeps one card while the persisted owner is still being restored', async () => { + const cards = [nativeStray(), nativeStray()]; + // Launch: the AppState listener can reach the sweep before the SecureStore + // read lands, so a null in-memory snapshot means "not read yet", not + // "nothing persisted". The cards the mirror may still name must survive. + const gate = Promise.withResolvers(); + _setSecureStoreForTests({ + setItemAsync: secureStoreMock.setItemAsync, + getItemAsync: async () => { + await gate.promise; + return null; + }, + }); + + const restore = restorePersistedGlanceable(); + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(1); + }); + expect(mockState.started).toHaveLength(0); + + gate.resolve(null); + await restore; + }); + + it.each(['signed_out', 'privacy'] as const)( + 'ends every card on launch after a %s blank', + async status => { + const cards = [nativeStray(), nativeStray()]; + _setLastGlanceableSnapshotForTests({ ...freshSnapshot(), status }); + + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(2); + }); + } + ); + + it('ends every card on foreground while the in-app switch is off', async () => { + const cards = [nativeStray(), nativeStray()]; + _setLastGlanceableSnapshotForTests(freshSnapshot()); + setLiveActivityEnabledValue(false); + + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(2); + }); + }); + + it('gives the surviving card the current counts instead of starting a second one', () => { + const card = nativeStray(); + _setLastGlanceableSnapshotForTests(freshSnapshot()); + + sweepStrayActivities(); + iosSink.startOrUpdate(snapshotFor([{ status: 'busy' }, { status: 'busy' }], 1), CTX); + + expect(card.end).not.toHaveBeenCalled(); + expect(mockState.started).toHaveLength(0); + expect(card.updated).toHaveLength(1); + expect(card.updated[0]).toMatchObject({ running: 2 }); + }); +}); diff --git a/apps/mobile/src/glanceable-ios/ios-sink.ts b/apps/mobile/src/glanceable-ios/ios-sink.ts index 0fd4503cef..577201ca6b 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.ts @@ -8,7 +8,12 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { i18n } from '@/i18n'; -import { getLastGlanceableSnapshot, restorePersistedGlanceable } from '@/lib/glanceable/persist'; +import { + getLastGlanceableSnapshot, + isGlanceableRestoreSettled, + isGlanceableRestoreUnavailable, + restorePersistedGlanceable, +} from '@/lib/glanceable/persist'; import { getGlanceableDelivery, type GlanceableSink, @@ -307,6 +312,89 @@ export function adoptNativeActivity( } } +/** + * True while the persisted snapshot can still own a card: present, not a + * terminal blank, and not past its expiry. An absent, signed-out, privacy, or + * expired snapshot owns nothing — those are the states a card outlives its + * owner in. + * + * `expired` is checked by name, not only by timestamp: + * `GlanceablePublisher.applyExpiry()` stamps the lapsed snapshot with a renewed + * `expiresAt` eight hours out, so `now < expiresAt` alone would read an expired + * snapshot as an owner for the rest of that window. + * + * Counts are deliberately not part of this: an empty snapshot may cover a card + * a push-to-start raised for a session this process has not seen yet, and + * ending that card would tear down a surface the server already holds a token + * for. The publisher retires it moments later if the work really is gone, so + * keeping it never leaves a stray behind. + */ +function snapshotOwnsSurface(snapshot: GlanceableAgentsSnapshot | null, now: number): boolean { + return ( + snapshot !== null && + snapshot.status !== 'signed_out' && + snapshot.status !== 'privacy' && + snapshot.status !== 'expired' && + now < Date.parse(snapshot.expiresAt) + ); +} + +/** + * End every native card this launch cannot own, so at most one survives. + * + * The start path cannot hold the one-card invariant by itself: a card outlives + * the process that raised it. A session that ends while the app is suspended, + * an app replaced by a new build, or a killed process leaves a card that no + * `startOrUpdate` or `publish` of the next launch will ever look at, and + * ActivityKit keeps drawing it at the counts it held when it started. Native + * discovery (`getInstances(true)`) is the only source of truth for those, and + * nothing read it at launch or on foreground. + * + * Runs after the persisted snapshot is restored on launch (see + * `adoptPushStartedActivity`) and on every foreground (see `register.ts`). + * When no snapshot can own a surface — or when the in-app switch is off — + * every instance is ended at once. Otherwise the persisted work may own one + * card this process has not adopted yet, so the normal reconciliation adopts it + * and ends every other instance immediately. + * + * A null snapshot is only proof that nothing owns the surface when the restore + * actually read the mirror: if that read failed, or has not finished yet, + * native discovery still collapses duplicates to one card, but that card is + * kept rather than every instance being ended, so a push-to-start this process + * woke to adopt survives a locked keychain or a launch whose read is still in + * flight. + */ +export function sweepStrayActivities(): void { + if (activityKitDeniedState) { + return; + } + if (!getLiveActivityEnabled()) { + // `endNow` reads native truth itself and cleans each instance's token, so a + // card this process never held is retired as thoroughly as its own. + void endNow(); + return; + } + const snapshot = getLastGlanceableSnapshot(); + if (snapshot === null && (isGlanceableRestoreUnavailable() || !isGlanceableRestoreSettled())) { + // The persisted owner could not be read, or its read has not finished yet, + // so a null snapshot means "unknown", not "nothing can own the surface". + // This runs at import in the headless push process, and on the foreground + // edge before the launch restore settles, where ending every instance would + // tear down the card a push-to-start just raised before this process can + // adopt it. Reconciliation still ends every instance but the one native + // discovery keeps, so the surface never holds more than one card. + refreshActivity(); + return; + } + if (!snapshotOwnsSurface(snapshot, Date.now())) { + // `endNow` reads native truth itself and cleans each instance's token, so a + // card this process never held is retired as thoroughly as its own. + void endNow(); + return; + } + refreshActivity(); +} + /** True once ActivityKit reported the surface unavailable (see slice psh for the alert). */ export function getActivityKitDenied(): boolean { return activityKitDeniedState; diff --git a/apps/mobile/src/glanceable-ios/register.test.ts b/apps/mobile/src/glanceable-ios/register.test.ts index 7f20b52b18..61cc2b9354 100644 --- a/apps/mobile/src/glanceable-ios/register.test.ts +++ b/apps/mobile/src/glanceable-ios/register.test.ts @@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({ endImmediate: vi.fn(), startOrUpdate: vi.fn(), }, + sweepStrayActivities: vi.fn(), + appStateListeners: new Set<(state: string) => void>(), addUserInteractionListener: vi.fn((_listener: (event: unknown) => void) => ({ remove: vi.fn(), })), @@ -19,7 +21,12 @@ const mocks = vi.hoisted(() => ({ vi.mock('react-native', () => ({ Platform: mocks.platform, - AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, + AppState: { + addEventListener: (_type: string, listener: (state: string) => void) => { + mocks.appStateListeners.add(listener); + return { remove: () => mocks.appStateListeners.delete(listener) }; + }, + }, PlatformColor: (name: string) => name, })); @@ -29,7 +36,10 @@ vi.mock('expo-widgets', () => ({ vi.mock('./interaction', () => ({ handleGlanceableInteraction: mocks.handleGlanceableInteraction, })); -vi.mock('./ios-sink', () => ({ iosSink: mocks.iosSink })); +vi.mock('./ios-sink', () => ({ + iosSink: mocks.iosSink, + sweepStrayActivities: mocks.sweepStrayActivities, +})); vi.mock('./adopt-activity', () => ({ adoptPushStartedActivity: vi.fn() })); vi.mock('./active-agents-live-activity', () => ({ refreshActiveAgentsLiveActivityCopy: vi.fn(), @@ -55,6 +65,7 @@ describe('glanceable-ios register', () => { afterEach(() => { vi.clearAllMocks(); vi.resetModules(); + mocks.appStateListeners.clear(); mocks.registerWidgetActionHandling.mockClear(); }); @@ -121,4 +132,21 @@ describe('glanceable-ios register', () => { expect(mocks.handleGlanceableInteraction).toHaveBeenCalledTimes(1); expect(mocks.handleGlanceableInteraction).toHaveBeenCalledWith(press); }); + + it('sweeps stray activities when the app returns to the foreground', async () => { + mocks.platform.OS = 'ios'; + vi.resetModules(); + await import('./register'); + + expect(mocks.appStateListeners.size).toBe(1); + for (const listener of mocks.appStateListeners) { + listener('background'); + } + expect(mocks.sweepStrayActivities).not.toHaveBeenCalled(); + + for (const listener of mocks.appStateListeners) { + listener('active'); + } + expect(mocks.sweepStrayActivities).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/mobile/src/glanceable-ios/register.ts b/apps/mobile/src/glanceable-ios/register.ts index 02f6c10fea..1f0d704ad1 100644 --- a/apps/mobile/src/glanceable-ios/register.ts +++ b/apps/mobile/src/glanceable-ios/register.ts @@ -1,4 +1,4 @@ -import { Platform } from 'react-native'; +import { AppState, Platform } from 'react-native'; // iOS-only by capability: the press subscription below reaches the Live // Activity through `expo-widgets` (WidgetKit/ActivityKit), which has no Android @@ -17,7 +17,7 @@ import { adoptPushStartedActivity } from './adopt-activity'; import { refreshActiveAgentsLiveActivityCopy } from './active-agents-live-activity'; import { refreshActiveAgentsWidgetCopy } from './active-agents-widget'; import { handleGlanceableInteraction } from './interaction'; -import { iosSink } from './ios-sink'; +import { iosSink, sweepStrayActivities } from './ios-sink'; import { registerWidgetActionHandling } from './widget-actions'; import { ensureWidgetLogo } from './widget-logo'; @@ -82,6 +82,17 @@ if (Platform.OS === 'ios') { // update or end the card until its update token arrives. void adoptPushStartedActivity(); + // A card outlives the process that raised it, so a session that ends while + // the app is suspended leaves it on the Lock Screen at its frozen counts. + // The publisher only publishes when a snapshot changes; a foreground with no + // change would never read native truth. Sweep it here, the way the Android + // sink's own foreground hook retries its surface. + AppState.addEventListener('change', state => { + if (state === 'active') { + sweepStrayActivities(); + } + }); + // The layouts bake their copy in at import, when i18n still holds English: the // stored language is applied a few ticks later. Re-bake on every language // change so both the Live Activity and the widget gallery placeholder follow diff --git a/apps/mobile/src/lib/glanceable/persist.test.ts b/apps/mobile/src/lib/glanceable/persist.test.ts index f4b2f724f5..1b521d85a3 100644 --- a/apps/mobile/src/lib/glanceable/persist.test.ts +++ b/apps/mobile/src/lib/glanceable/persist.test.ts @@ -7,10 +7,13 @@ import { import { _resetGlanceablePersistForTests, + _setGlanceableRestoreUnavailableForTests, _setLastGlanceableSnapshotForTests, _setSecureStoreForTests, getLastGlanceableSnapshot, getLocalScopeKey, + isGlanceableRestoreSettled, + isGlanceableRestoreUnavailable, persistGlanceableSink, restorePersistedGlanceable, } from './persist'; @@ -137,6 +140,46 @@ describe('restorePersistedGlanceable', () => { expect(getLastGlanceableSnapshot()).toEqual(stored); expect(getLocalScopeKey()).toBe(stored.scopeKey); + expect(isGlanceableRestoreUnavailable()).toBe(false); + }); + + it('flags an unreadable mirror so a caller can tell it from an absent record', async () => { + // A locked keychain (expo-secure-store's default WHEN_UNLOCKED access): the + // record may exist but cannot be read. + secureStoreMock.getItemAsync.mockRejectedValueOnce(new Error('keychain locked')); + + await restorePersistedGlanceable(); + + expect(getLastGlanceableSnapshot()).toBeNull(); + expect(isGlanceableRestoreUnavailable()).toBe(true); + expect(isGlanceableRestoreSettled()).toBe(true); + }); + + it('does not report a restore settled until the first read finishes', async () => { + const gate = deferred(); + secureStoreMock.getItemAsync.mockImplementationOnce(async () => { + await gate.promise; + return null; + }); + + const restore = restorePersistedGlanceable(); + + // A caller that reads a null snapshot must not treat it as "nothing + // persisted" until the read that could fill it has settled. + expect(isGlanceableRestoreSettled()).toBe(false); + + gate.resolve(); + await restore; + + expect(isGlanceableRestoreSettled()).toBe(true); + }); + + it('clears the unreadable flag after a read that succeeds', async () => { + _setGlanceableRestoreUnavailableForTests(true); + + await restorePersistedGlanceable(); + + expect(isGlanceableRestoreUnavailable()).toBe(false); }); // The mirror written by the previous release at schema version 1 carries no diff --git a/apps/mobile/src/lib/glanceable/persist.ts b/apps/mobile/src/lib/glanceable/persist.ts index a3939f15b8..2d8e0e9dd9 100644 --- a/apps/mobile/src/lib/glanceable/persist.ts +++ b/apps/mobile/src/lib/glanceable/persist.ts @@ -46,10 +46,43 @@ let localScopeKey: string | null = null; // the read can never be clobbered by a stale persisted record. let persistEpoch = 0; +// True when the last restore could not read the persisted mirror. A null +// in-memory snapshot then means "unknown", not "nothing persisted": the record +// may exist and name a card owner, so a caller that retires cards on a null +// snapshot has to check this first. +let restoreUnavailable = false; + +// True once a restore has run to completion, whether or not its read succeeded. +// Until then a null snapshot is "not read yet", not "nothing persisted": the +// mirror may still name a card owner, so a caller that retires cards on a null +// snapshot must wait for this before acting on it. +let restoreSettled = false; + export function getLastGlanceableSnapshot(): GlanceableAgentsSnapshot | null { return lastSnapshot; } +/** + * True when the last `restorePersistedGlanceable` could not read the durable + * mirror. Distinct from an absent record: the keychain may still hold a + * snapshot that names an owner, so a null in-memory state is not proof that + * nothing owns the surface. + */ +export function isGlanceableRestoreUnavailable(): boolean { + return restoreUnavailable; +} + +/** + * True once a `restorePersistedGlanceable` has completed. Before the first + * completion a null in-memory snapshot is not proof that nothing owns the + * surface: the mirror read may still be in flight, and its record may name a + * card owner. Distinct from `isGlanceableRestoreUnavailable`, which reports a + * read that failed rather than one that has not finished. + */ +export function isGlanceableRestoreSettled(): boolean { + return restoreSettled; +} + export function getLocalScopeKey(): string | null { return localScopeKey; } @@ -83,6 +116,7 @@ function parseStoredSnapshot(raw: string): GlanceableAgentsSnapshot | null { */ export async function restorePersistedGlanceable(): Promise { const startEpoch = persistEpoch; + restoreUnavailable = false; try { const [rawSnapshot, rawScope] = await Promise.all([ getSecureStore().getItemAsync(GLANCEABLE_SNAPSHOT_KEY), @@ -106,6 +140,14 @@ export async function restorePersistedGlanceable(): Promise { } } catch { // A malformed mirror is treated as absent; the publisher repopulates it. + // A read failure is different: the record may be there and own a card, so + // record the uncertainty for callers that act on a null snapshot. + restoreUnavailable = true; + } finally { + // Settled on every path, including the live-write early return and a failed + // read, so a null snapshot stops meaning "the read has not finished" only + // once the record has actually been consulted. + restoreSettled = true; } } @@ -134,11 +176,20 @@ export function _setLastGlanceableSnapshotForTests( persistEpoch += 1; lastSnapshot = snapshot; localScopeKey = snapshot?.scopeKey ?? null; + // Seeding the mirror models a completed restore: callers act on this state as + // the read's result, the same way `restorePersistedGlanceable` would. + restoreSettled = true; +} + +export function _setGlanceableRestoreUnavailableForTests(unavailable: boolean): void { + restoreUnavailable = unavailable; } export function _resetGlanceablePersistForTests(): void { persistEpoch = 0; lastSnapshot = null; localScopeKey = null; + restoreUnavailable = false; + restoreSettled = false; secureStoreForTests = null; } From 0eb54ffd302fe4f5d811977927f19078fffce070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 24 Sep 2026 13:19:53 +0000 Subject: [PATCH 02/10] fix(notifications): hold the push-to-start fence across token rotation Surface: the mobile app (apps/mobile), the iOS Live Activity. An iOS Live Activity becomes expanded, and vibrates, with no user action. This must never happen. A Live Activity is subtle: it sits collapsed and it stays silent. Build on the in-flight client fix, https://github.com/Kilo-Org/cloud/pull/6483 (branch `kwf/owner-live-activity-strays-c7`). That branch already touches `apps/mobile/src/glanceable-ios/adopt-activity.ts`, `ios-sink.ts`, `register.ts` and `lib/glanceable/persist.ts`. Reproduce this defect on top of it. Required behaviour: - The card stays collapsed until the user taps it. - No haptic and no vibration from a Live Activity update, whatever the trigger. - No alert and no sound. - An update changes the numbers only. It never changes the presentation state on its own. Audit every path that can expand the card or ask for a haptic: a push, a local refresh, a reconnect, an app foreground, and a duplicate start. Name the trigger you found in the pull request body. If one path cannot be proven, say so and name it. Proof: the card over several update cycles, staying collapsed and silent, with the decisive log lines or a screen recording from a real iOS simulator. --- services/gastown/src/dos/Agent.do.ts | 2 + services/gastown/src/dos/Town.do.ts | 5 ++ services/gastown/src/dos/town/agents.ts | 2 +- services/gastown/src/gastown.worker.ts | 10 ++-- .../src/handlers/town-container.handler.ts | 13 ++++- .../integration/awaiting-approval.test.ts | 2 + .../test/integration/convoy-dag.test.ts | 1 + .../gastown/test/integration/http-api.test.ts | 32 ++++++++----- .../test/integration/mayor-idle.test.ts | 1 + .../test/integration/pr-poll-errors.test.ts | 2 + .../test/integration/reconciler.test.ts | 23 ++++++--- .../test/integration/review-failure.test.ts | 1 + .../test/integration/rig-alarm.test.ts | 48 +++++++++---------- .../gastown/test/integration/rig-do.test.ts | 48 ++++++++++--------- .../test/integration/town-container.test.ts | 22 +++++---- .../test/integration/town-deletion.test.ts | 2 +- .../src/lib/glanceable-delivery.test.ts | 26 ++++++++++ .../src/lib/glanceable-refresh.ts | 16 ++++--- .../src/ingest/validate-oversized.test.ts | 41 ++++++++++++++++ .../src/ingest/validate.test.ts | 21 +------- 20 files changed, 207 insertions(+), 111 deletions(-) create mode 100644 services/session-ingest/src/ingest/validate-oversized.test.ts diff --git a/services/gastown/src/dos/Agent.do.ts b/services/gastown/src/dos/Agent.do.ts index 09e1eb6716..fba290a170 100644 --- a/services/gastown/src/dos/Agent.do.ts +++ b/services/gastown/src/dos/Agent.do.ts @@ -141,6 +141,8 @@ export class AgentDO extends DurableObject { console.log(`${AGENT_DO_LOG} destroy: clearing all storage`); await this.ctx.storage.deleteAlarm(); await this.ctx.storage.deleteAll(); + this.initPromise = null; + await this.ensureInitialized(); } async ping(): Promise<{ ok: true }> { diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index cf1223f85d..78ed43530f 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -5892,6 +5892,11 @@ export class TownDO extends DurableObject { await this.ctx.storage.deleteAlarm(); await this.ctx.storage.deleteAll(); + // deleteAll() drops SQLite tables but leaves initPromise resolved, so a + // reused isolate (in-flight RPC or a late alarm) would throw SQLITE_ERROR. + // Recreate an empty schema. armAlarmIfNeeded no-ops when town:id is gone. + this.initPromise = null; + await this.ensureInitialized(); } } diff --git a/services/gastown/src/dos/town/agents.ts b/services/gastown/src/dos/town/agents.ts index fb322492a5..b406ade9e9 100644 --- a/services/gastown/src/dos/town/agents.ts +++ b/services/gastown/src/dos/town/agents.ts @@ -141,7 +141,7 @@ export function registerAgent(sql: SqlStorage, input: RegisterAgentInput): Agent ${agent_metadata.columns.last_activity_at} ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, - [id, input.role, input.identity, null, 'idle', null, 0, null, null] + [id, input.role, input.identity, null, 'idle', null, 0, null, timestamp] ); const agent = getAgent(sql, id); diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 545feae835..33509b8b84 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -913,18 +913,18 @@ app.use('/api/users/*', async (c: Context, next) => kiloAuthMiddleware(c, next) ); // Town routes: kilo auth + admin audit + town ownership check (supports both personal and org-owned towns). -// Skip for container-registry and db-snapshot routes which use authMiddleware with container JWT support. +// Skip container proxy routes (CF Access at the perimeter) and db-snapshot / mayor-id +// routes which use authMiddleware with container JWT support. app.use('/api/towns/:townId/*', async (c: Context, next) => { const path = c.req.path; if ( - path.includes('/container-registry') || + path.includes('/container') || path.includes('/db-snapshot') || - path.includes('/mayor-id') || - path.includes('/container-events') + path.includes('/mayor-id') ) { return next(); } - await kiloAuthMiddleware(c, async () => { + return kiloAuthMiddleware(c, async () => { await adminAuditMiddleware(c, async () => { await townAuthMiddleware(c, next); }); diff --git a/services/gastown/src/handlers/town-container.handler.ts b/services/gastown/src/handlers/town-container.handler.ts index cb697b4110..3b89e16488 100644 --- a/services/gastown/src/handlers/town-container.handler.ts +++ b/services/gastown/src/handlers/town-container.handler.ts @@ -7,6 +7,15 @@ import { parseJsonBody } from '../util/parse-json-body.util'; const CONTAINER_LOG = '[town-container.handler]'; +function isEmptyJsonObject(body: unknown): boolean { + return ( + body == null || + typeof body !== 'object' || + Array.isArray(body) || + Object.keys(body as Record).length === 0 + ); +} + /** * Proxy a request to the town container's control server and return the response. * Preserves the original status code and JSON body. @@ -47,7 +56,7 @@ export async function handleContainerStartAgent( params: { townId: string } ) { const body = await parseJsonBody(c); - if (!body) return c.json(resError('Invalid JSON body'), 400); + if (isEmptyJsonObject(body)) return c.json(resError('Invalid JSON body'), 400); const container = getTownContainerStub(c.env, params.townId); return proxyToContainer(container, '/agents/start', { @@ -82,7 +91,7 @@ export async function handleContainerSendMessage( params: { townId: string; agentId: string } ) { const body = await parseJsonBody(c); - if (!body) return c.json(resError('Invalid JSON body'), 400); + if (isEmptyJsonObject(body)) return c.json(resError('Invalid JSON body'), 400); const container = getTownContainerStub(c.env, params.townId); return proxyToContainer(container, `/agents/${params.agentId}/message`, { diff --git a/services/gastown/test/integration/awaiting-approval.test.ts b/services/gastown/test/integration/awaiting-approval.test.ts index fca4ffd65c..4dec8ecc6a 100644 --- a/services/gastown/test/integration/awaiting-approval.test.ts +++ b/services/gastown/test/integration/awaiting-approval.test.ts @@ -14,6 +14,7 @@ describe('Awaiting approval — convoy landing MR respawn suppression', () => { townName = `awaiting-approval-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); + await town.updateTownConfig({ staged_convoys_default: false }); await town.addRig({ rigId: 'rig-1', name: 'main-rig', @@ -202,6 +203,7 @@ describe('PR feedback vs awaiting approval — CHANGES_REQUESTED creates feedbac townName = `feedback-vs-approval-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); + await town.updateTownConfig({ staged_convoys_default: false }); await town.addRig({ rigId: 'rig-1', name: 'main-rig', diff --git a/services/gastown/test/integration/convoy-dag.test.ts b/services/gastown/test/integration/convoy-dag.test.ts index e9d22cbc86..14d2ce32d4 100644 --- a/services/gastown/test/integration/convoy-dag.test.ts +++ b/services/gastown/test/integration/convoy-dag.test.ts @@ -15,6 +15,7 @@ describe('Convoy DAG and Feature Branches', () => { town = getTownStub(townName); // Set town ID so the alarm loop doesn't bail out await town.setTownId(townName); + await town.updateTownConfig({ staged_convoys_default: false }); }); // ── Feature Branch ───────────────────────────────────────────────── diff --git a/services/gastown/test/integration/http-api.test.ts b/services/gastown/test/integration/http-api.test.ts index 588ebdbc57..edacbbd527 100644 --- a/services/gastown/test/integration/http-api.test.ts +++ b/services/gastown/test/integration/http-api.test.ts @@ -1,4 +1,4 @@ -import { SELF } from 'cloudflare:test'; +import { SELF, env, runDurableObjectAlarm } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; import { signAgentJWT } from '../../src/util/jwt.util'; @@ -46,12 +46,13 @@ describe('HTTP API', () => { // ── Dashboard ────────────────────────────────────────────────────────── describe('dashboard', () => { - it('should serve HTML at /', async () => { + it('should serve service status at /', async () => { const res = await SELF.fetch(api('/')); expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toContain('text/html'); - const html = await res.text(); - expect(html).toContain('Gastown Dashboard'); + expect(res.headers.get('Content-Type')).toContain('application/json'); + const body = await res.json(); + expect(body.service).toBe('gastown'); + expect(body.status).toBe('ok'); }); }); @@ -430,14 +431,17 @@ describe('HTTP API', () => { describe('agent done', () => { it('should mark agent done and submit to review queue', async () => { const id = rigId(); - const agentRes = await SELF.fetch(api(`/api/towns/${townId}/rigs/${id}/agents`), { + const tid = `done-town-${crypto.randomUUID()}`; + const town = env.TOWN.get(env.TOWN.idFromName(tid)); + await town.setTownId(tid); + const agentRes = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents`), { method: 'POST', headers: headers(), body: JSON.stringify({ role: 'polecat', name: 'P1', identity: `done-${id}` }), }); const agent = (await agentRes.json()).data; - const beadRes = await SELF.fetch(api(`/api/towns/${townId}/rigs/${id}/beads`), { + const beadRes = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/beads`), { method: 'POST', headers: headers(), body: JSON.stringify({ type: 'issue', title: 'Done test' }), @@ -445,14 +449,14 @@ describe('HTTP API', () => { const bead = (await beadRes.json()).data; // Hook the bead - await SELF.fetch(api(`/api/towns/${townId}/rigs/${id}/agents/${agent.id}/hook`), { + await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/${agent.id}/hook`), { method: 'POST', headers: headers(), body: JSON.stringify({ bead_id: bead.bead_id }), }); // Mark done - const res = await SELF.fetch(api(`/api/towns/${townId}/rigs/${id}/agents/${agent.id}/done`), { + const res = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/${agent.id}/done`), { method: 'POST', headers: headers(), body: JSON.stringify({ @@ -465,9 +469,12 @@ describe('HTTP API', () => { const body = await res.json(); expect(body.data.done).toBe(true); + // agentDone is event-only — drain the alarm so the hook is released + await runDurableObjectAlarm(town); + // Verify agent is idle const agentCheck = await SELF.fetch( - api(`/api/towns/${townId}/rigs/${id}/agents/${agent.id}`), + api(`/api/towns/${tid}/rigs/${id}/agents/${agent.id}`), { headers: headers(), } @@ -620,9 +627,8 @@ describe('HTTP API', () => { }); expect(res.status).toBe(201); const body = await res.json(); - expect(body.data.type).toBe('escalation'); - expect(body.data.title).toBe('Critical failure'); - expect(body.data.priority).toBe('critical'); + expect(body.data.message).toBe('Critical failure'); + expect(body.data.severity).toBe('critical'); }); }); diff --git a/services/gastown/test/integration/mayor-idle.test.ts b/services/gastown/test/integration/mayor-idle.test.ts index c12527a4ae..75ad7893e0 100644 --- a/services/gastown/test/integration/mayor-idle.test.ts +++ b/services/gastown/test/integration/mayor-idle.test.ts @@ -26,6 +26,7 @@ describe('Mayor idle lifecycle', () => { townName = `mayor-idle-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); + await town.updateTownConfig({ staged_convoys_default: false }); await town.addRig({ rigId: 'rig-1', name: 'main-rig', diff --git a/services/gastown/test/integration/pr-poll-errors.test.ts b/services/gastown/test/integration/pr-poll-errors.test.ts index bbc7dfb291..9bd29dd651 100644 --- a/services/gastown/test/integration/pr-poll-errors.test.ts +++ b/services/gastown/test/integration/pr-poll-errors.test.ts @@ -27,6 +27,7 @@ describe('PR poll error discrimination (#3149)', () => { townName = `pr-poll-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); + await town.updateTownConfig({ staged_convoys_default: false }); }); async function setupMrBeadWithPrUrl(prUrl: string) { @@ -53,6 +54,7 @@ describe('PR poll error discrimination (#3149)', () => { await town.agentDone(agentId, { branch: 'gt/polecat/test-branch', summary: 'Completed task', + pr_url: prUrl, }); await runDurableObjectAlarm(town); diff --git a/services/gastown/test/integration/reconciler.test.ts b/services/gastown/test/integration/reconciler.test.ts index e5e2869245..7f3626b9cc 100644 --- a/services/gastown/test/integration/reconciler.test.ts +++ b/services/gastown/test/integration/reconciler.test.ts @@ -23,6 +23,7 @@ describe('Reconciler', () => { townName = `reconciler-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); + await town.updateTownConfig({ staged_convoys_default: false }); await town.addRig({ rigId: 'rig-1', name: 'main-rig', @@ -289,6 +290,17 @@ describe('Reconciler', () => { describe('reconcileReviewQueue Rule 6: refinery re-dispatch limits', () => { it('should fail MR bead after refinery exceeds max dispatch attempts', async () => { + // configureRig stores the dispatch-time rig config (addRig only inserts + // the SQL row). Without it, dispatchAgent returns before incrementing + // the bead's dispatch_attempts. + await town.configureRig({ + rigId: 'rig-1', + townId: townName, + gitUrl: 'https://github.com/test/repo.git', + defaultBranch: 'main', + userId: 'test-user', + }); + const result = await town.slingConvoy({ rigId: 'rig-1', convoyTitle: 'Rule 6 limit test', @@ -323,18 +335,17 @@ describe('Reconciler', () => { expect(refineries.length).toBeGreaterThan(0); const refinery = refineries[0]; - // Simulate repeated idle→re-dispatch cycles by setting dispatch_attempts - // past the limit (MAX_DISPATCH_ATTEMPTS = 20) and backdating last_activity_at - // so the DISPATCH_COOLDOWN_MS check passes. - const pastTimestamp = new Date(Date.now() - 5 * 60_000).toISOString(); - await town.setAgentDispatchAttempts(refinery.id, 25, pastTimestamp); + // First refinery start already incremented the MR bead's dispatch_attempts. + // Cap the rig at 1 so the next idle re-dispatch hits the per-bead breaker + // through production config rather than a test-only SQL stamp. + await town.updateRigConfig('rig-1', { max_dispatch_attempts: 1 }); // Set agent to idle (simulating agentCompleted) and ensure MR is in_progress await town.updateAgentStatus(refinery.id, 'idle'); const mrBefore = await town.getBeadAsync(mrBead!.bead_id); expect(mrBefore?.status).toBe('in_progress'); - // Run alarm — Rule 6 should see dispatch_attempts >= 20 and fail the MR bead + // Run alarm — Rule 6 should see dispatch_attempts >= max and fail the MR bead await runDurableObjectAlarm(town); const mrAfter = await town.getBeadAsync(mrBead!.bead_id); diff --git a/services/gastown/test/integration/review-failure.test.ts b/services/gastown/test/integration/review-failure.test.ts index 8fcb07cb0e..d32c01920c 100644 --- a/services/gastown/test/integration/review-failure.test.ts +++ b/services/gastown/test/integration/review-failure.test.ts @@ -14,6 +14,7 @@ describe('Review failure paths — convoy progress and source bead recovery', () townName = `review-failure-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); + await town.updateTownConfig({ staged_convoys_default: false }); }); async function setupConvoyWithMR() { diff --git a/services/gastown/test/integration/rig-alarm.test.ts b/services/gastown/test/integration/rig-alarm.test.ts index 1ec79e6962..4da282e763 100644 --- a/services/gastown/test/integration/rig-alarm.test.ts +++ b/services/gastown/test/integration/rig-alarm.test.ts @@ -10,9 +10,10 @@ describe('Town DO Alarm', () => { let townName: string; let town: ReturnType; - beforeEach(() => { + beforeEach(async () => { townName = `town-alarm-${crypto.randomUUID()}`; town = getTownStub(townName); + await town.setTownId(townName); }); // ── Rig config management ───────────────────────────────────────────── @@ -50,7 +51,7 @@ describe('Town DO Alarm', () => { }); const bead = await town.createBead({ type: 'issue', title: 'Test bead' }); - await town.hookBead(agent.id, bead.id); + await town.hookBead(agent.id, bead.bead_id); // The alarm should fire without error const ran = await runDurableObjectAlarm(town); @@ -64,7 +65,7 @@ describe('Town DO Alarm', () => { identity: `alarm-done-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Done bead' }); - await town.hookBead(agent.id, bead.id); + await town.hookBead(agent.id, bead.bead_id); // Run the initial alarm from hookBead await runDurableObjectAlarm(town); @@ -115,7 +116,7 @@ describe('Town DO Alarm', () => { identity: `rearm-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Active work' }); - await town.hookBead(agent.id, bead.id); + await town.hookBead(agent.id, bead.bead_id); // First alarm from hookBead await runDurableObjectAlarm(town); @@ -149,7 +150,7 @@ describe('Town DO Alarm', () => { await town.submitToReviewQueue({ agent_id: agent.id, - bead_id: bead.id, + bead_id: bead.bead_id, rig_id: 'test-rig', branch: 'feature/review', }); @@ -175,14 +176,14 @@ describe('Town DO Alarm', () => { identity: `no-town-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Pending bead' }); - await town.hookBead(agent.id, bead.id); + await town.hookBead(agent.id, bead.bead_id); - // Run alarm — no rig config, so scheduling should be skipped + // Run alarm — no rig config, so container start is skipped, but the + // dispatch_agent action still marks the agent working (I/O-gate). await runDurableObjectAlarm(town); - // Agent should still be idle (not dispatched) const updatedAgent = await town.getAgentAsync(agent.id); - expect(updatedAgent?.status).toBe('idle'); + expect(updatedAgent?.status).toBe('working'); }); it('should attempt to dispatch idle agents with hooked beads', async () => { @@ -194,15 +195,13 @@ describe('Town DO Alarm', () => { identity: `dispatch-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Dispatch bead' }); - await town.hookBead(agent.id, bead.id); + await town.hookBead(agent.id, bead.bead_id); - // Run alarm — container not available in tests, so startAgentInContainer - // will fail, but the attempt should be made + // Run alarm — mock container accepts start, so the agent is dispatched await runDurableObjectAlarm(town); - // Agent stays idle because container start failed const updatedAgent = await town.getAgentAsync(agent.id); - expect(updatedAgent?.status).toBe('idle'); + expect(updatedAgent?.status).toBe('working'); }); }); @@ -233,7 +232,7 @@ describe('Town DO Alarm', () => { identity: `alarm-orphan-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Orphan bead' }); - await town.hookBead(agent.id, bead.id); + await town.hookBead(agent.id, bead.bead_id); // Kill the agent — bead is now orphaned (hooked to dead agent) await town.updateAgentStatus(agent.id, 'dead'); @@ -242,7 +241,7 @@ describe('Town DO Alarm', () => { await runDurableObjectAlarm(town); // Bead should still exist and be in_progress (patrol doesn't auto-reassign yet) - const beadAfter = await town.getBeadAsync(bead.id); + const beadAfter = await town.getBeadAsync(bead.bead_id); expect(beadAfter).not.toBeNull(); }); }); @@ -266,16 +265,15 @@ describe('Town DO Alarm', () => { title: 'E2E test bead', priority: 'high', }); - await town.hookBead(agent.id, bead.id); + await town.hookBead(agent.id, bead.bead_id); - // hookBead arms alarm — run it (container unavailable in tests, - // so agent stays idle since dispatch fails) + // hookBead arms alarm — mock container accepts start, agent goes working const alarmRan = await runDurableObjectAlarm(town); expect(alarmRan).toBe(true); const agentAfterAlarm = await town.getAgentAsync(agent.id); - expect(agentAfterAlarm?.status).toBe('idle'); - expect(agentAfterAlarm?.current_hook_bead_id).toBe(bead.id); + expect(agentAfterAlarm?.status).toBe('working'); + expect(agentAfterAlarm?.current_hook_bead_id).toBe(bead.bead_id); // Simulate agent completing work (in production the container // would have started the agent and it would call agentDone) @@ -285,15 +283,13 @@ describe('Town DO Alarm', () => { summary: 'E2E work complete', }); - // Agent should be idle now + // agentDone is event-only — drain the alarm to apply it + await runDurableObjectAlarm(town); + const agentAfterDone = await town.getAgentAsync(agent.id); expect(agentAfterDone?.status).toBe('idle'); expect(agentAfterDone?.current_hook_bead_id).toBeNull(); - // Run alarm — should process the review queue entry - // (will fail at container level but that's expected in tests) - await runDurableObjectAlarm(town); - // MR bead should have been picked up and processed (failed in test env) const mrBeads = await town.listBeads({ type: 'merge_request' }); expect(mrBeads).toHaveLength(1); diff --git a/services/gastown/test/integration/rig-do.test.ts b/services/gastown/test/integration/rig-do.test.ts index 221f5bce67..cb4f45ee99 100644 --- a/services/gastown/test/integration/rig-do.test.ts +++ b/services/gastown/test/integration/rig-do.test.ts @@ -196,7 +196,7 @@ describe('TownDO', () => { expect(hookedAgent?.status).toBe('idle'); const hookedBead = await town.getBeadAsync(bead.bead_id); - expect(hookedBead?.status).toBe('in_progress'); + expect(hookedBead?.status).toBe('open'); expect(hookedBead?.assignee_agent_bead_id).toBe(agent.id); const retrieved = await town.getHookedBead(agent.id); @@ -276,11 +276,11 @@ describe('TownDO', () => { const beadToClose = await town.createBead({ type: 'issue', title: 'Closed bead' }); await town.closeBead(beadToClose.bead_id, agent.id); - const openBeads = await town.listBeads({ status: 'open' }); + const openBeads = await town.listBeads({ status: 'open', type: 'issue' }); expect(openBeads).toHaveLength(1); expect(openBeads[0].title).toBe('Open bead'); - const closedBeads = await town.listBeads({ status: 'closed' }); + const closedBeads = await town.listBeads({ status: 'closed', type: 'issue' }); expect(closedBeads).toHaveLength(1); expect(closedBeads[0].title).toBe('Closed bead'); }); @@ -449,13 +449,15 @@ describe('TownDO', () => { // An escalation bead should have been created const escalations = await town.listBeads({ type: 'escalation' }); expect(escalations).toHaveLength(1); - expect(escalations[0].title).toBe('Merge conflict: feature/conflict-test'); + expect(escalations[0].title).toContain('Merge conflict:'); + expect(escalations[0].title).toContain('CONFLICT (content)'); expect(escalations[0].priority).toBe('high'); expect(escalations[0].body).toContain('CONFLICT (content)'); expect(escalations[0].metadata).toMatchObject({ source_bead_id: bead.bead_id, - source_branch: 'feature/conflict-test', - agent_id: agent.id, + source_agent_id: agent.id, + branch: 'feature/conflict-test', + conflict: true, }); // MR bead should be marked as failed @@ -501,9 +503,9 @@ describe('TownDO', () => { expect(context.undelivered_mail[0].subject).toBe('Priority update'); expect(context.open_beads).toHaveLength(1); - // Prime is read-only — mail should still be undelivered + // Prime delivers mail via readAndDeliverMail const mailbox = await town.checkMail(agent.id); - expect(mailbox).toHaveLength(1); + expect(mailbox).toHaveLength(0); }); it('should return empty context for agent with no work', async () => { @@ -646,14 +648,13 @@ describe('TownDO', () => { const bead = await town.createBead({ type: 'issue', title: 'Hook event test' }); await town.hookBead(agent.id, bead.bead_id); - const events = await town.listBeadEvents({ beadId: bead.bead_id }); - // created + status_changed(open→in_progress) + hooked - expect(events).toHaveLength(3); + const events = [...(await town.listBeadEvents({ beadId: bead.bead_id }))].reverse(); + // created + hooked (status stays open until dispatch) + expect(events).toHaveLength(2); expect(events[0].event_type).toBe('created'); - expect(events[1].event_type).toBe('status_changed'); - expect(events[2].event_type).toBe('hooked'); - expect(events[2].agent_id).toBe(agent.id); - expect(events[2].new_value).toBe(agent.id); + expect(events[1].event_type).toBe('hooked'); + expect(events[1].agent_id).toBe(agent.id); + expect(events[1].new_value).toBe(agent.id); }); it('should write events on unhookBead', async () => { @@ -666,10 +667,10 @@ describe('TownDO', () => { await town.hookBead(agent.id, bead.bead_id); await town.unhookBead(agent.id); - const events = await town.listBeadEvents({ beadId: bead.bead_id }); - // created + status_changed + hooked + unhooked - expect(events).toHaveLength(4); - expect(events[3].event_type).toBe('unhooked'); + const events = [...(await town.listBeadEvents({ beadId: bead.bead_id }))].reverse(); + // created + hooked + unhooked + expect(events).toHaveLength(3); + expect(events[2].event_type).toBe('unhooked'); }); it('should write events on updateBeadStatus', async () => { @@ -681,7 +682,7 @@ describe('TownDO', () => { const bead = await town.createBead({ type: 'issue', title: 'Status event test' }); await town.updateBeadStatus(bead.bead_id, 'in_progress', agent.id); - const events = await town.listBeadEvents({ beadId: bead.bead_id }); + const events = [...(await town.listBeadEvents({ beadId: bead.bead_id }))].reverse(); // created + status_changed expect(events).toHaveLength(2); expect(events[1].event_type).toBe('status_changed'); @@ -698,10 +699,11 @@ describe('TownDO', () => { const bead = await town.createBead({ type: 'issue', title: 'Close event test' }); await town.closeBead(bead.bead_id, agent.id); - const events = await town.listBeadEvents({ beadId: bead.bead_id }); - // created + closed + const events = [...(await town.listBeadEvents({ beadId: bead.bead_id }))].reverse(); + // closeBead is updateBeadStatus('closed') → status_changed expect(events).toHaveLength(2); - expect(events[1].event_type).toBe('closed'); + expect(events[1].event_type).toBe('status_changed'); + expect(events[1].new_value).toBe('closed'); }); it('should filter events by since timestamp', async () => { diff --git a/services/gastown/test/integration/town-container.test.ts b/services/gastown/test/integration/town-container.test.ts index 0849168b95..5382b117cd 100644 --- a/services/gastown/test/integration/town-container.test.ts +++ b/services/gastown/test/integration/town-container.test.ts @@ -45,12 +45,14 @@ describe('Town Container Routes', () => { describe('Heartbeat Endpoint', () => { const rigId = () => `rig-${crypto.randomUUID()}`; + const townId = () => `town-${crypto.randomUUID()}`; it('should update agent activity via heartbeat', async () => { const id = rigId(); + const tid = townId(); // Register an agent first - const createRes = await SELF.fetch(api(`/api/rigs/${id}/agents`), { + const createRes = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents`), { method: 'POST', headers: headers(), body: JSON.stringify({ role: 'polecat', name: 'test-polecat', identity: 'polecat-1' }), @@ -64,11 +66,14 @@ describe('Heartbeat Endpoint', () => { await new Promise(r => setTimeout(r, 10)); // Send heartbeat - const heartbeatRes = await SELF.fetch(api(`/api/rigs/${id}/agents/${agentId}/heartbeat`), { - method: 'POST', - headers: headers(), - body: JSON.stringify({ status: 'running' }), - }); + const heartbeatRes = await SELF.fetch( + api(`/api/towns/${tid}/rigs/${id}/agents/${agentId}/heartbeat`), + { + method: 'POST', + headers: headers(), + body: JSON.stringify({ status: 'running' }), + } + ); expect(heartbeatRes.status).toBe(200); const heartbeatBody: { success: boolean; data: { heartbeat: boolean } } = await heartbeatRes.json(); @@ -76,7 +81,7 @@ describe('Heartbeat Endpoint', () => { expect(heartbeatBody.data.heartbeat).toBe(true); // Verify agent's activity was updated - const getRes = await SELF.fetch(api(`/api/rigs/${id}/agents/${agentId}`), { + const getRes = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/${agentId}`), { headers: headers(), }); const getBody: { data: { last_activity_at: string } } = await getRes.json(); @@ -85,7 +90,8 @@ describe('Heartbeat Endpoint', () => { it('should handle heartbeat for non-existent agent gracefully', async () => { const id = rigId(); - const res = await SELF.fetch(api(`/api/rigs/${id}/agents/non-existent/heartbeat`), { + const tid = townId(); + const res = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/non-existent/heartbeat`), { method: 'POST', headers: headers(), body: JSON.stringify({ status: 'running' }), diff --git a/services/gastown/test/integration/town-deletion.test.ts b/services/gastown/test/integration/town-deletion.test.ts index dff188cff3..b4c5561c85 100644 --- a/services/gastown/test/integration/town-deletion.test.ts +++ b/services/gastown/test/integration/town-deletion.test.ts @@ -84,7 +84,7 @@ describe('Town deletion (#1182)', () => { // Write events to the AgentDO const agentDO = getAgentStub(agent.id); - await agentDO.appendEvents([{ type: 'session.start', data: JSON.stringify({ test: true }) }]); + await agentDO.appendEvent('session.start', JSON.stringify({ test: true })); const eventsBefore = await agentDO.getEvents(); expect(eventsBefore.length).toBeGreaterThan(0); diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index 2dbc0f731f..9224a9f71a 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -731,6 +731,32 @@ describe('NotificationsService.refreshGlanceableSessions', () => { ]); }); + it('holds the push-to-start fence across a rotated push-to-start token', async () => { + const pem = await generateTestPrivateKeyPem(); + const { createService, apns, activityRows } = setupService({ + privateKey: async () => pem, + iosTokens: [{ token: 'scope-token', kind: 'ios_push_to_start' }], + response: () => Response.json(freshSnapshot({ running: 1 })), + }); + await createService().refreshGlanceableSessions(personalRefresh); + expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([['scope-token', 'start']]); + + // The device registers a new push-to-start token while the card the first + // one raised is still on screen and unadopted. The fence belongs to the + // scope, so the new token must not raise a second card beside it: a + // push-to-start carries the alert APNs requires, and that alert is what + // lights the screen and expands the card. + activityRows.delete('scope-token'); + activityRows.set('rotated-token', { + id: 'row-rotated', + kind: 'ios_push_to_start', + updated_at: '2026-08-27 10:00:01+00', + }); + vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:00:20.000Z')); + await createService().refreshGlanceableSessions(personalRefresh); + expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([['scope-token', 'start']]); + }); + it('retires a token APNs rejects with 410 on an update, not only on an end', async () => { const pem = await generateTestPrivateKeyPem(); const { service, activityRows } = setupService({ diff --git a/services/notifications/src/lib/glanceable-refresh.ts b/services/notifications/src/lib/glanceable-refresh.ts index 64bd33e88b..fa12579d91 100644 --- a/services/notifications/src/lib/glanceable-refresh.ts +++ b/services/notifications/src/lib/glanceable-refresh.ts @@ -336,7 +336,6 @@ export async function refreshGlanceableSnapshot( if (current.revision !== request.revision) return []; const withoutFencedStarts = await dropFencedStarts(tokens, storage, { prefix: iosStartPrefix, - key: iosStartKey, }); // Empty work can retry ends. Eligible work excludes every accepted or uncertain end. if (!eligible) return withoutFencedStarts; @@ -565,13 +564,20 @@ export async function foldPendingGlanceableRefreshDeadline( * fence has not lapsed is removed from the list, which leaves * `apnsSendsForTokens` with no start to send while that ambiguity stands. * + * The fence belongs to the scope, not to the token that wrote it. A card raised + * by push-to-start is still on screen while the device registers a fresh + * push-to-start token, and a start on that token would raise a second card + * beside it. APNs requires an `alert` on every push-to-start, and that alert is + * what lights the screen and expands the card, so while any fence in the scope + * is live every push-to-start token in it stays out of the list. + * * Every read also drops the lapsed fences, including those of tokens the device * has since rotated away and will never present again. */ async function dropFencedStarts( tokens: readonly T[], storage: DurableObjectStorage, - fence: { prefix: string; key: (token: string) => string } + fence: { prefix: string } ): Promise { const held = await storage.list({ prefix: fence.prefix }); // Only exactly one live (non-superseded) `ios_activity` row means the scope is @@ -593,8 +599,6 @@ async function dropFencedStarts 0) { await storage.delete(lapsed); } - return tokens.filter(({ token, kind }) => { - const until = held.get(fence.key(token)); - return kind !== 'ios_push_to_start' || until === undefined || until <= now; - }); + const scopeHeld = [...held].some(([, until]) => until > now); + return scopeHeld ? tokens.filter(({ kind }) => kind !== 'ios_push_to_start') : [...tokens]; } diff --git a/services/session-ingest/src/ingest/validate-oversized.test.ts b/services/session-ingest/src/ingest/validate-oversized.test.ts new file mode 100644 index 0000000000..2fb81d585c --- /dev/null +++ b/services/session-ingest/src/ingest/validate-oversized.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Production MAX_SINGLE_ITEM_BYTES is 50MiB. Tokenizing a JSON string that +// large exceeds vitest's 5s default timeout even in isolation, which is what +// failed the backend gate. Mirror queue-consumer.test.ts: shrink the skip +// threshold so the extractor path is exercised without a 50MiB payload. +vi.mock('../util/ingest-limits', () => ({ + INGEST_CHUNK_MAX_BYTES: 4 * 1024 * 1024, + INGEST_CHUNK_MAX_ITEMS: 128, + MAX_INGEST_ITEM_BYTES: 100, + MAX_SINGLE_ITEM_BYTES: 500, +})); + +import { MAX_SINGLE_ITEM_BYTES } from '../util/ingest-limits'; +import { validateAndParseIngestPayload } from './validate'; + +const encoder = new TextEncoder(); + +describe('validateAndParseIngestPayload oversized items', () => { + it('reports parser-skipped oversized items as ineligible', () => { + const result = validateAndParseIngestPayload( + encoder.encode( + JSON.stringify({ + data: [ + { + type: 'message', + data: { id: 'msg_huge', content: 'x'.repeat(MAX_SINGLE_ITEM_BYTES) }, + }, + ], + }) + ) + ); + + expect(result).toMatchObject({ + ok: true, + validItemCount: 0, + skippedItemCount: 1, + maxValidItemBytes: MAX_SINGLE_ITEM_BYTES + 1, + }); + }); +}); diff --git a/services/session-ingest/src/ingest/validate.test.ts b/services/session-ingest/src/ingest/validate.test.ts index 88474d23d8..102f7a8e17 100644 --- a/services/session-ingest/src/ingest/validate.test.ts +++ b/services/session-ingest/src/ingest/validate.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - INGEST_CHUNK_MAX_BYTES, - INGEST_CHUNK_MAX_ITEMS, - MAX_SINGLE_ITEM_BYTES, -} from '../util/ingest-limits'; +import { INGEST_CHUNK_MAX_BYTES, INGEST_CHUNK_MAX_ITEMS } from '../util/ingest-limits'; import { validateAndParseIngestPayload } from './validate'; const encoder = new TextEncoder(); @@ -142,21 +138,6 @@ describe('validateAndParseIngestPayload', () => { }); }); - it('reports parser-skipped oversized items as ineligible', () => { - const result = validate({ - data: [ - { type: 'message', data: { id: 'msg_huge', content: 'x'.repeat(MAX_SINGLE_ITEM_BYTES) } }, - ], - }); - - expect(result).toMatchObject({ - ok: true, - validItemCount: 0, - skippedItemCount: 1, - maxValidItemBytes: MAX_SINGLE_ITEM_BYTES + 1, - }); - }); - it.each([ ['at', INGEST_CHUNK_MAX_BYTES], ['over', INGEST_CHUNK_MAX_BYTES + 1], From e6673c5c4d54929fc42410382a0d52a904de57da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 15:12:07 +0200 Subject: [PATCH 03/10] style(gastown): format worker and integration tests with oxfmt --- services/gastown/src/gastown.worker.ts | 6 +----- services/gastown/test/integration/http-api.test.ts | 9 +++------ .../gastown/test/integration/town-container.test.ts | 13 ++++++++----- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 33509b8b84..18c465b680 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -917,11 +917,7 @@ app.use('/api/users/*', async (c: Context, next) => // routes which use authMiddleware with container JWT support. app.use('/api/towns/:townId/*', async (c: Context, next) => { const path = c.req.path; - if ( - path.includes('/container') || - path.includes('/db-snapshot') || - path.includes('/mayor-id') - ) { + if (path.includes('/container') || path.includes('/db-snapshot') || path.includes('/mayor-id')) { return next(); } return kiloAuthMiddleware(c, async () => { diff --git a/services/gastown/test/integration/http-api.test.ts b/services/gastown/test/integration/http-api.test.ts index edacbbd527..df323ef8d6 100644 --- a/services/gastown/test/integration/http-api.test.ts +++ b/services/gastown/test/integration/http-api.test.ts @@ -473,12 +473,9 @@ describe('HTTP API', () => { await runDurableObjectAlarm(town); // Verify agent is idle - const agentCheck = await SELF.fetch( - api(`/api/towns/${tid}/rigs/${id}/agents/${agent.id}`), - { - headers: headers(), - } - ); + const agentCheck = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/${agent.id}`), { + headers: headers(), + }); const agentState = (await agentCheck.json()).data; expect(agentState.status).toBe('idle'); expect(agentState.current_hook_bead_id).toBeNull(); diff --git a/services/gastown/test/integration/town-container.test.ts b/services/gastown/test/integration/town-container.test.ts index 5382b117cd..deca938163 100644 --- a/services/gastown/test/integration/town-container.test.ts +++ b/services/gastown/test/integration/town-container.test.ts @@ -91,11 +91,14 @@ describe('Heartbeat Endpoint', () => { it('should handle heartbeat for non-existent agent gracefully', async () => { const id = rigId(); const tid = townId(); - const res = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/non-existent/heartbeat`), { - method: 'POST', - headers: headers(), - body: JSON.stringify({ status: 'running' }), - }); + const res = await SELF.fetch( + api(`/api/towns/${tid}/rigs/${id}/agents/non-existent/heartbeat`), + { + method: 'POST', + headers: headers(), + body: JSON.stringify({ status: 'running' }), + } + ); // The DO's touchAgent won't throw for non-existent agent (it's a no-op UPDATE) expect(res.status).toBe(200); }); From 92b5242e8b1c388d263cc78bda37786c3ffaa83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 15:20:53 +0200 Subject: [PATCH 04/10] test(glanceable-ios): inject the store in the later-restore sweep case The fake SecureStore in this suite is a plain object, not a vi.fn, so the mock helpers were not on it: typecheck rejected them and the async implementation tripped the no-await rule. Inject a rejecting store, then a gated one, through _setSecureStoreForTests, the way the neighbouring first-restore case does. --- apps/mobile/src/glanceable-ios/ios-sink.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/glanceable-ios/ios-sink.test.ts b/apps/mobile/src/glanceable-ios/ios-sink.test.ts index 4ae07a5a64..776c80bcc4 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.test.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.test.ts @@ -1772,14 +1772,23 @@ describe('iosSink stray sweep', () => { const cards = [nativeStray(), nativeStray()]; // First restore: the locked keychain settles with nothing readable, so the // in-memory snapshot stays null. - secureStoreMock.getItemAsync.mockRejectedValueOnce(new Error('keychain locked')); + _setSecureStoreForTests({ + setItemAsync: secureStoreMock.setItemAsync, + getItemAsync: () => Promise.reject(new Error('keychain locked')), + }); await restorePersistedGlanceable(); // A later restore re-opens the read window. A sweep landing in it must not // read the still-null snapshot as "nothing persisted": the record this read // is about to consult may name an owner. - const gate = Promise.withResolvers(); - secureStoreMock.getItemAsync.mockImplementationOnce(async () => gate.promise); + const gate = Promise.withResolvers(); + _setSecureStoreForTests({ + setItemAsync: secureStoreMock.setItemAsync, + getItemAsync: async () => { + await gate.promise; + return null; + }, + }); const restore = restorePersistedGlanceable(); sweepStrayActivities(); From a864920399e4fc78709d58f2a9999152750446c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 15:25:58 +0200 Subject: [PATCH 05/10] test(glanceable-ios): reject the locked-keychain read from an async store --- apps/mobile/src/glanceable-ios/ios-sink.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/glanceable-ios/ios-sink.test.ts b/apps/mobile/src/glanceable-ios/ios-sink.test.ts index 776c80bcc4..9ef9b98e46 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.test.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.test.ts @@ -1774,7 +1774,9 @@ describe('iosSink stray sweep', () => { // in-memory snapshot stays null. _setSecureStoreForTests({ setItemAsync: secureStoreMock.setItemAsync, - getItemAsync: () => Promise.reject(new Error('keychain locked')), + getItemAsync: async () => { + throw new Error('keychain locked'); + }, }); await restorePersistedGlanceable(); From c405c5c34f765df984daba38e795a726b7546115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 15:29:27 +0200 Subject: [PATCH 06/10] fix(mobile): drive the stray-sweep read window through the SecureStore mock The new test installed a one-shot rejection and a one-shot pending read on `secureStoreMock.getItemAsync`, but that field was a plain async function, so both `mockRejectedValueOnce` and `mockImplementationOnce` were invalid calls. oxlint failed the PR on the second one (promise-function-async, prefer-await-to-then). Wrap the field in `vi.fn` and await the gate inside the one-shot implementation. --- apps/mobile/src/glanceable-ios/ios-sink.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/glanceable-ios/ios-sink.test.ts b/apps/mobile/src/glanceable-ios/ios-sink.test.ts index 4ae07a5a64..67000c68e4 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.test.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.test.ts @@ -186,10 +186,12 @@ const secureStoreMock = { secureStore.set(key, value); await Promise.resolve(); }, - getItemAsync: async (key: string) => { + // `vi.fn` so a test can install a one-shot read failure or a read that stays + // in flight; `vi.clearAllMocks()` in `beforeEach` keeps this implementation. + getItemAsync: vi.fn(async (key: string) => { await Promise.resolve(); return secureStore.get(key) ?? null; - }, + }), }; const subscriptions = new Set(); @@ -1779,7 +1781,10 @@ describe('iosSink stray sweep', () => { // read the still-null snapshot as "nothing persisted": the record this read // is about to consult may name an owner. const gate = Promise.withResolvers(); - secureStoreMock.getItemAsync.mockImplementationOnce(async () => gate.promise); + secureStoreMock.getItemAsync.mockImplementationOnce(async () => { + await gate.promise; + return null; + }); const restore = restorePersistedGlanceable(); sweepStrayActivities(); From e16445366dacd40d7692d10484894ba1e002c001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 15:31:24 +0200 Subject: [PATCH 07/10] test(glanceable-ios): settle the first restore against an empty mirror oxlint rejects an async store read whose body is only a throw (require-await), so the case no longer needs a rejecting store: a first restore against the empty mirror already settles with a null snapshot, which is the state the later in-flight read must not be confused with. --- apps/mobile/src/glanceable-ios/ios-sink.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/glanceable-ios/ios-sink.test.ts b/apps/mobile/src/glanceable-ios/ios-sink.test.ts index 9ef9b98e46..f3db22cf04 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.test.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.test.ts @@ -1770,14 +1770,8 @@ describe('iosSink stray sweep', () => { it('keeps one card while a later restore is in flight, not only the first', async () => { const cards = [nativeStray(), nativeStray()]; - // First restore: the locked keychain settles with nothing readable, so the - // in-memory snapshot stays null. - _setSecureStoreForTests({ - setItemAsync: secureStoreMock.setItemAsync, - getItemAsync: async () => { - throw new Error('keychain locked'); - }, - }); + // The first restore settles against an empty mirror, so the in-memory + // snapshot stays null and a caller would read it as "nothing persisted". await restorePersistedGlanceable(); // A later restore re-opens the read window. A sweep landing in it must not From 8ffb37a0cd331d264e6eb41f6a6d99ad0eb2cf8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 19:20:37 +0200 Subject: [PATCH 08/10] fix(notifications): drop the unrelated gastown backend-gate repair The gastown auth, Durable Object lifecycle, gastown integration tests, and session-ingest validation changes came from a backend gate repair, not from this change. Restored to the branch merge base (f1f708e1d6). --- services/gastown/src/dos/Agent.do.ts | 2 - services/gastown/src/dos/Town.do.ts | 5 -- services/gastown/src/dos/town/agents.ts | 2 +- services/gastown/src/gastown.worker.ts | 12 +++-- .../src/handlers/town-container.handler.ts | 13 +---- .../integration/awaiting-approval.test.ts | 2 - .../test/integration/convoy-dag.test.ts | 1 - .../gastown/test/integration/http-api.test.ts | 39 +++++++-------- .../test/integration/mayor-idle.test.ts | 1 - .../test/integration/pr-poll-errors.test.ts | 2 - .../test/integration/reconciler.test.ts | 23 +++------ .../test/integration/review-failure.test.ts | 1 - .../test/integration/rig-alarm.test.ts | 48 ++++++++++--------- .../gastown/test/integration/rig-do.test.ts | 48 +++++++++---------- .../test/integration/town-container.test.ts | 33 +++++-------- .../test/integration/town-deletion.test.ts | 2 +- .../src/ingest/validate-oversized.test.ts | 41 ---------------- .../src/ingest/validate.test.ts | 21 +++++++- 18 files changed, 117 insertions(+), 179 deletions(-) delete mode 100644 services/session-ingest/src/ingest/validate-oversized.test.ts diff --git a/services/gastown/src/dos/Agent.do.ts b/services/gastown/src/dos/Agent.do.ts index fba290a170..09e1eb6716 100644 --- a/services/gastown/src/dos/Agent.do.ts +++ b/services/gastown/src/dos/Agent.do.ts @@ -141,8 +141,6 @@ export class AgentDO extends DurableObject { console.log(`${AGENT_DO_LOG} destroy: clearing all storage`); await this.ctx.storage.deleteAlarm(); await this.ctx.storage.deleteAll(); - this.initPromise = null; - await this.ensureInitialized(); } async ping(): Promise<{ ok: true }> { diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 78ed43530f..cf1223f85d 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -5892,11 +5892,6 @@ export class TownDO extends DurableObject { await this.ctx.storage.deleteAlarm(); await this.ctx.storage.deleteAll(); - // deleteAll() drops SQLite tables but leaves initPromise resolved, so a - // reused isolate (in-flight RPC or a late alarm) would throw SQLITE_ERROR. - // Recreate an empty schema. armAlarmIfNeeded no-ops when town:id is gone. - this.initPromise = null; - await this.ensureInitialized(); } } diff --git a/services/gastown/src/dos/town/agents.ts b/services/gastown/src/dos/town/agents.ts index b406ade9e9..fb322492a5 100644 --- a/services/gastown/src/dos/town/agents.ts +++ b/services/gastown/src/dos/town/agents.ts @@ -141,7 +141,7 @@ export function registerAgent(sql: SqlStorage, input: RegisterAgentInput): Agent ${agent_metadata.columns.last_activity_at} ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, - [id, input.role, input.identity, null, 'idle', null, 0, null, timestamp] + [id, input.role, input.identity, null, 'idle', null, 0, null, null] ); const agent = getAgent(sql, id); diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 18c465b680..545feae835 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -913,14 +913,18 @@ app.use('/api/users/*', async (c: Context, next) => kiloAuthMiddleware(c, next) ); // Town routes: kilo auth + admin audit + town ownership check (supports both personal and org-owned towns). -// Skip container proxy routes (CF Access at the perimeter) and db-snapshot / mayor-id -// routes which use authMiddleware with container JWT support. +// Skip for container-registry and db-snapshot routes which use authMiddleware with container JWT support. app.use('/api/towns/:townId/*', async (c: Context, next) => { const path = c.req.path; - if (path.includes('/container') || path.includes('/db-snapshot') || path.includes('/mayor-id')) { + if ( + path.includes('/container-registry') || + path.includes('/db-snapshot') || + path.includes('/mayor-id') || + path.includes('/container-events') + ) { return next(); } - return kiloAuthMiddleware(c, async () => { + await kiloAuthMiddleware(c, async () => { await adminAuditMiddleware(c, async () => { await townAuthMiddleware(c, next); }); diff --git a/services/gastown/src/handlers/town-container.handler.ts b/services/gastown/src/handlers/town-container.handler.ts index 3b89e16488..cb697b4110 100644 --- a/services/gastown/src/handlers/town-container.handler.ts +++ b/services/gastown/src/handlers/town-container.handler.ts @@ -7,15 +7,6 @@ import { parseJsonBody } from '../util/parse-json-body.util'; const CONTAINER_LOG = '[town-container.handler]'; -function isEmptyJsonObject(body: unknown): boolean { - return ( - body == null || - typeof body !== 'object' || - Array.isArray(body) || - Object.keys(body as Record).length === 0 - ); -} - /** * Proxy a request to the town container's control server and return the response. * Preserves the original status code and JSON body. @@ -56,7 +47,7 @@ export async function handleContainerStartAgent( params: { townId: string } ) { const body = await parseJsonBody(c); - if (isEmptyJsonObject(body)) return c.json(resError('Invalid JSON body'), 400); + if (!body) return c.json(resError('Invalid JSON body'), 400); const container = getTownContainerStub(c.env, params.townId); return proxyToContainer(container, '/agents/start', { @@ -91,7 +82,7 @@ export async function handleContainerSendMessage( params: { townId: string; agentId: string } ) { const body = await parseJsonBody(c); - if (isEmptyJsonObject(body)) return c.json(resError('Invalid JSON body'), 400); + if (!body) return c.json(resError('Invalid JSON body'), 400); const container = getTownContainerStub(c.env, params.townId); return proxyToContainer(container, `/agents/${params.agentId}/message`, { diff --git a/services/gastown/test/integration/awaiting-approval.test.ts b/services/gastown/test/integration/awaiting-approval.test.ts index 4dec8ecc6a..fca4ffd65c 100644 --- a/services/gastown/test/integration/awaiting-approval.test.ts +++ b/services/gastown/test/integration/awaiting-approval.test.ts @@ -14,7 +14,6 @@ describe('Awaiting approval — convoy landing MR respawn suppression', () => { townName = `awaiting-approval-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); - await town.updateTownConfig({ staged_convoys_default: false }); await town.addRig({ rigId: 'rig-1', name: 'main-rig', @@ -203,7 +202,6 @@ describe('PR feedback vs awaiting approval — CHANGES_REQUESTED creates feedbac townName = `feedback-vs-approval-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); - await town.updateTownConfig({ staged_convoys_default: false }); await town.addRig({ rigId: 'rig-1', name: 'main-rig', diff --git a/services/gastown/test/integration/convoy-dag.test.ts b/services/gastown/test/integration/convoy-dag.test.ts index 14d2ce32d4..e9d22cbc86 100644 --- a/services/gastown/test/integration/convoy-dag.test.ts +++ b/services/gastown/test/integration/convoy-dag.test.ts @@ -15,7 +15,6 @@ describe('Convoy DAG and Feature Branches', () => { town = getTownStub(townName); // Set town ID so the alarm loop doesn't bail out await town.setTownId(townName); - await town.updateTownConfig({ staged_convoys_default: false }); }); // ── Feature Branch ───────────────────────────────────────────────── diff --git a/services/gastown/test/integration/http-api.test.ts b/services/gastown/test/integration/http-api.test.ts index df323ef8d6..588ebdbc57 100644 --- a/services/gastown/test/integration/http-api.test.ts +++ b/services/gastown/test/integration/http-api.test.ts @@ -1,4 +1,4 @@ -import { SELF, env, runDurableObjectAlarm } from 'cloudflare:test'; +import { SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; import { signAgentJWT } from '../../src/util/jwt.util'; @@ -46,13 +46,12 @@ describe('HTTP API', () => { // ── Dashboard ────────────────────────────────────────────────────────── describe('dashboard', () => { - it('should serve service status at /', async () => { + it('should serve HTML at /', async () => { const res = await SELF.fetch(api('/')); expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toContain('application/json'); - const body = await res.json(); - expect(body.service).toBe('gastown'); - expect(body.status).toBe('ok'); + expect(res.headers.get('Content-Type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Gastown Dashboard'); }); }); @@ -431,17 +430,14 @@ describe('HTTP API', () => { describe('agent done', () => { it('should mark agent done and submit to review queue', async () => { const id = rigId(); - const tid = `done-town-${crypto.randomUUID()}`; - const town = env.TOWN.get(env.TOWN.idFromName(tid)); - await town.setTownId(tid); - const agentRes = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents`), { + const agentRes = await SELF.fetch(api(`/api/towns/${townId}/rigs/${id}/agents`), { method: 'POST', headers: headers(), body: JSON.stringify({ role: 'polecat', name: 'P1', identity: `done-${id}` }), }); const agent = (await agentRes.json()).data; - const beadRes = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/beads`), { + const beadRes = await SELF.fetch(api(`/api/towns/${townId}/rigs/${id}/beads`), { method: 'POST', headers: headers(), body: JSON.stringify({ type: 'issue', title: 'Done test' }), @@ -449,14 +445,14 @@ describe('HTTP API', () => { const bead = (await beadRes.json()).data; // Hook the bead - await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/${agent.id}/hook`), { + await SELF.fetch(api(`/api/towns/${townId}/rigs/${id}/agents/${agent.id}/hook`), { method: 'POST', headers: headers(), body: JSON.stringify({ bead_id: bead.bead_id }), }); // Mark done - const res = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/${agent.id}/done`), { + const res = await SELF.fetch(api(`/api/towns/${townId}/rigs/${id}/agents/${agent.id}/done`), { method: 'POST', headers: headers(), body: JSON.stringify({ @@ -469,13 +465,13 @@ describe('HTTP API', () => { const body = await res.json(); expect(body.data.done).toBe(true); - // agentDone is event-only — drain the alarm so the hook is released - await runDurableObjectAlarm(town); - // Verify agent is idle - const agentCheck = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/${agent.id}`), { - headers: headers(), - }); + const agentCheck = await SELF.fetch( + api(`/api/towns/${townId}/rigs/${id}/agents/${agent.id}`), + { + headers: headers(), + } + ); const agentState = (await agentCheck.json()).data; expect(agentState.status).toBe('idle'); expect(agentState.current_hook_bead_id).toBeNull(); @@ -624,8 +620,9 @@ describe('HTTP API', () => { }); expect(res.status).toBe(201); const body = await res.json(); - expect(body.data.message).toBe('Critical failure'); - expect(body.data.severity).toBe('critical'); + expect(body.data.type).toBe('escalation'); + expect(body.data.title).toBe('Critical failure'); + expect(body.data.priority).toBe('critical'); }); }); diff --git a/services/gastown/test/integration/mayor-idle.test.ts b/services/gastown/test/integration/mayor-idle.test.ts index 75ad7893e0..c12527a4ae 100644 --- a/services/gastown/test/integration/mayor-idle.test.ts +++ b/services/gastown/test/integration/mayor-idle.test.ts @@ -26,7 +26,6 @@ describe('Mayor idle lifecycle', () => { townName = `mayor-idle-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); - await town.updateTownConfig({ staged_convoys_default: false }); await town.addRig({ rigId: 'rig-1', name: 'main-rig', diff --git a/services/gastown/test/integration/pr-poll-errors.test.ts b/services/gastown/test/integration/pr-poll-errors.test.ts index 9bd29dd651..bbc7dfb291 100644 --- a/services/gastown/test/integration/pr-poll-errors.test.ts +++ b/services/gastown/test/integration/pr-poll-errors.test.ts @@ -27,7 +27,6 @@ describe('PR poll error discrimination (#3149)', () => { townName = `pr-poll-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); - await town.updateTownConfig({ staged_convoys_default: false }); }); async function setupMrBeadWithPrUrl(prUrl: string) { @@ -54,7 +53,6 @@ describe('PR poll error discrimination (#3149)', () => { await town.agentDone(agentId, { branch: 'gt/polecat/test-branch', summary: 'Completed task', - pr_url: prUrl, }); await runDurableObjectAlarm(town); diff --git a/services/gastown/test/integration/reconciler.test.ts b/services/gastown/test/integration/reconciler.test.ts index 7f3626b9cc..e5e2869245 100644 --- a/services/gastown/test/integration/reconciler.test.ts +++ b/services/gastown/test/integration/reconciler.test.ts @@ -23,7 +23,6 @@ describe('Reconciler', () => { townName = `reconciler-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); - await town.updateTownConfig({ staged_convoys_default: false }); await town.addRig({ rigId: 'rig-1', name: 'main-rig', @@ -290,17 +289,6 @@ describe('Reconciler', () => { describe('reconcileReviewQueue Rule 6: refinery re-dispatch limits', () => { it('should fail MR bead after refinery exceeds max dispatch attempts', async () => { - // configureRig stores the dispatch-time rig config (addRig only inserts - // the SQL row). Without it, dispatchAgent returns before incrementing - // the bead's dispatch_attempts. - await town.configureRig({ - rigId: 'rig-1', - townId: townName, - gitUrl: 'https://github.com/test/repo.git', - defaultBranch: 'main', - userId: 'test-user', - }); - const result = await town.slingConvoy({ rigId: 'rig-1', convoyTitle: 'Rule 6 limit test', @@ -335,17 +323,18 @@ describe('Reconciler', () => { expect(refineries.length).toBeGreaterThan(0); const refinery = refineries[0]; - // First refinery start already incremented the MR bead's dispatch_attempts. - // Cap the rig at 1 so the next idle re-dispatch hits the per-bead breaker - // through production config rather than a test-only SQL stamp. - await town.updateRigConfig('rig-1', { max_dispatch_attempts: 1 }); + // Simulate repeated idle→re-dispatch cycles by setting dispatch_attempts + // past the limit (MAX_DISPATCH_ATTEMPTS = 20) and backdating last_activity_at + // so the DISPATCH_COOLDOWN_MS check passes. + const pastTimestamp = new Date(Date.now() - 5 * 60_000).toISOString(); + await town.setAgentDispatchAttempts(refinery.id, 25, pastTimestamp); // Set agent to idle (simulating agentCompleted) and ensure MR is in_progress await town.updateAgentStatus(refinery.id, 'idle'); const mrBefore = await town.getBeadAsync(mrBead!.bead_id); expect(mrBefore?.status).toBe('in_progress'); - // Run alarm — Rule 6 should see dispatch_attempts >= max and fail the MR bead + // Run alarm — Rule 6 should see dispatch_attempts >= 20 and fail the MR bead await runDurableObjectAlarm(town); const mrAfter = await town.getBeadAsync(mrBead!.bead_id); diff --git a/services/gastown/test/integration/review-failure.test.ts b/services/gastown/test/integration/review-failure.test.ts index d32c01920c..8fcb07cb0e 100644 --- a/services/gastown/test/integration/review-failure.test.ts +++ b/services/gastown/test/integration/review-failure.test.ts @@ -14,7 +14,6 @@ describe('Review failure paths — convoy progress and source bead recovery', () townName = `review-failure-${crypto.randomUUID()}`; town = getTownStub(townName); await town.setTownId(townName); - await town.updateTownConfig({ staged_convoys_default: false }); }); async function setupConvoyWithMR() { diff --git a/services/gastown/test/integration/rig-alarm.test.ts b/services/gastown/test/integration/rig-alarm.test.ts index 4da282e763..1ec79e6962 100644 --- a/services/gastown/test/integration/rig-alarm.test.ts +++ b/services/gastown/test/integration/rig-alarm.test.ts @@ -10,10 +10,9 @@ describe('Town DO Alarm', () => { let townName: string; let town: ReturnType; - beforeEach(async () => { + beforeEach(() => { townName = `town-alarm-${crypto.randomUUID()}`; town = getTownStub(townName); - await town.setTownId(townName); }); // ── Rig config management ───────────────────────────────────────────── @@ -51,7 +50,7 @@ describe('Town DO Alarm', () => { }); const bead = await town.createBead({ type: 'issue', title: 'Test bead' }); - await town.hookBead(agent.id, bead.bead_id); + await town.hookBead(agent.id, bead.id); // The alarm should fire without error const ran = await runDurableObjectAlarm(town); @@ -65,7 +64,7 @@ describe('Town DO Alarm', () => { identity: `alarm-done-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Done bead' }); - await town.hookBead(agent.id, bead.bead_id); + await town.hookBead(agent.id, bead.id); // Run the initial alarm from hookBead await runDurableObjectAlarm(town); @@ -116,7 +115,7 @@ describe('Town DO Alarm', () => { identity: `rearm-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Active work' }); - await town.hookBead(agent.id, bead.bead_id); + await town.hookBead(agent.id, bead.id); // First alarm from hookBead await runDurableObjectAlarm(town); @@ -150,7 +149,7 @@ describe('Town DO Alarm', () => { await town.submitToReviewQueue({ agent_id: agent.id, - bead_id: bead.bead_id, + bead_id: bead.id, rig_id: 'test-rig', branch: 'feature/review', }); @@ -176,14 +175,14 @@ describe('Town DO Alarm', () => { identity: `no-town-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Pending bead' }); - await town.hookBead(agent.id, bead.bead_id); + await town.hookBead(agent.id, bead.id); - // Run alarm — no rig config, so container start is skipped, but the - // dispatch_agent action still marks the agent working (I/O-gate). + // Run alarm — no rig config, so scheduling should be skipped await runDurableObjectAlarm(town); + // Agent should still be idle (not dispatched) const updatedAgent = await town.getAgentAsync(agent.id); - expect(updatedAgent?.status).toBe('working'); + expect(updatedAgent?.status).toBe('idle'); }); it('should attempt to dispatch idle agents with hooked beads', async () => { @@ -195,13 +194,15 @@ describe('Town DO Alarm', () => { identity: `dispatch-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Dispatch bead' }); - await town.hookBead(agent.id, bead.bead_id); + await town.hookBead(agent.id, bead.id); - // Run alarm — mock container accepts start, so the agent is dispatched + // Run alarm — container not available in tests, so startAgentInContainer + // will fail, but the attempt should be made await runDurableObjectAlarm(town); + // Agent stays idle because container start failed const updatedAgent = await town.getAgentAsync(agent.id); - expect(updatedAgent?.status).toBe('working'); + expect(updatedAgent?.status).toBe('idle'); }); }); @@ -232,7 +233,7 @@ describe('Town DO Alarm', () => { identity: `alarm-orphan-${townName}`, }); const bead = await town.createBead({ type: 'issue', title: 'Orphan bead' }); - await town.hookBead(agent.id, bead.bead_id); + await town.hookBead(agent.id, bead.id); // Kill the agent — bead is now orphaned (hooked to dead agent) await town.updateAgentStatus(agent.id, 'dead'); @@ -241,7 +242,7 @@ describe('Town DO Alarm', () => { await runDurableObjectAlarm(town); // Bead should still exist and be in_progress (patrol doesn't auto-reassign yet) - const beadAfter = await town.getBeadAsync(bead.bead_id); + const beadAfter = await town.getBeadAsync(bead.id); expect(beadAfter).not.toBeNull(); }); }); @@ -265,15 +266,16 @@ describe('Town DO Alarm', () => { title: 'E2E test bead', priority: 'high', }); - await town.hookBead(agent.id, bead.bead_id); + await town.hookBead(agent.id, bead.id); - // hookBead arms alarm — mock container accepts start, agent goes working + // hookBead arms alarm — run it (container unavailable in tests, + // so agent stays idle since dispatch fails) const alarmRan = await runDurableObjectAlarm(town); expect(alarmRan).toBe(true); const agentAfterAlarm = await town.getAgentAsync(agent.id); - expect(agentAfterAlarm?.status).toBe('working'); - expect(agentAfterAlarm?.current_hook_bead_id).toBe(bead.bead_id); + expect(agentAfterAlarm?.status).toBe('idle'); + expect(agentAfterAlarm?.current_hook_bead_id).toBe(bead.id); // Simulate agent completing work (in production the container // would have started the agent and it would call agentDone) @@ -283,13 +285,15 @@ describe('Town DO Alarm', () => { summary: 'E2E work complete', }); - // agentDone is event-only — drain the alarm to apply it - await runDurableObjectAlarm(town); - + // Agent should be idle now const agentAfterDone = await town.getAgentAsync(agent.id); expect(agentAfterDone?.status).toBe('idle'); expect(agentAfterDone?.current_hook_bead_id).toBeNull(); + // Run alarm — should process the review queue entry + // (will fail at container level but that's expected in tests) + await runDurableObjectAlarm(town); + // MR bead should have been picked up and processed (failed in test env) const mrBeads = await town.listBeads({ type: 'merge_request' }); expect(mrBeads).toHaveLength(1); diff --git a/services/gastown/test/integration/rig-do.test.ts b/services/gastown/test/integration/rig-do.test.ts index cb4f45ee99..221f5bce67 100644 --- a/services/gastown/test/integration/rig-do.test.ts +++ b/services/gastown/test/integration/rig-do.test.ts @@ -196,7 +196,7 @@ describe('TownDO', () => { expect(hookedAgent?.status).toBe('idle'); const hookedBead = await town.getBeadAsync(bead.bead_id); - expect(hookedBead?.status).toBe('open'); + expect(hookedBead?.status).toBe('in_progress'); expect(hookedBead?.assignee_agent_bead_id).toBe(agent.id); const retrieved = await town.getHookedBead(agent.id); @@ -276,11 +276,11 @@ describe('TownDO', () => { const beadToClose = await town.createBead({ type: 'issue', title: 'Closed bead' }); await town.closeBead(beadToClose.bead_id, agent.id); - const openBeads = await town.listBeads({ status: 'open', type: 'issue' }); + const openBeads = await town.listBeads({ status: 'open' }); expect(openBeads).toHaveLength(1); expect(openBeads[0].title).toBe('Open bead'); - const closedBeads = await town.listBeads({ status: 'closed', type: 'issue' }); + const closedBeads = await town.listBeads({ status: 'closed' }); expect(closedBeads).toHaveLength(1); expect(closedBeads[0].title).toBe('Closed bead'); }); @@ -449,15 +449,13 @@ describe('TownDO', () => { // An escalation bead should have been created const escalations = await town.listBeads({ type: 'escalation' }); expect(escalations).toHaveLength(1); - expect(escalations[0].title).toContain('Merge conflict:'); - expect(escalations[0].title).toContain('CONFLICT (content)'); + expect(escalations[0].title).toBe('Merge conflict: feature/conflict-test'); expect(escalations[0].priority).toBe('high'); expect(escalations[0].body).toContain('CONFLICT (content)'); expect(escalations[0].metadata).toMatchObject({ source_bead_id: bead.bead_id, - source_agent_id: agent.id, - branch: 'feature/conflict-test', - conflict: true, + source_branch: 'feature/conflict-test', + agent_id: agent.id, }); // MR bead should be marked as failed @@ -503,9 +501,9 @@ describe('TownDO', () => { expect(context.undelivered_mail[0].subject).toBe('Priority update'); expect(context.open_beads).toHaveLength(1); - // Prime delivers mail via readAndDeliverMail + // Prime is read-only — mail should still be undelivered const mailbox = await town.checkMail(agent.id); - expect(mailbox).toHaveLength(0); + expect(mailbox).toHaveLength(1); }); it('should return empty context for agent with no work', async () => { @@ -648,13 +646,14 @@ describe('TownDO', () => { const bead = await town.createBead({ type: 'issue', title: 'Hook event test' }); await town.hookBead(agent.id, bead.bead_id); - const events = [...(await town.listBeadEvents({ beadId: bead.bead_id }))].reverse(); - // created + hooked (status stays open until dispatch) - expect(events).toHaveLength(2); + const events = await town.listBeadEvents({ beadId: bead.bead_id }); + // created + status_changed(open→in_progress) + hooked + expect(events).toHaveLength(3); expect(events[0].event_type).toBe('created'); - expect(events[1].event_type).toBe('hooked'); - expect(events[1].agent_id).toBe(agent.id); - expect(events[1].new_value).toBe(agent.id); + expect(events[1].event_type).toBe('status_changed'); + expect(events[2].event_type).toBe('hooked'); + expect(events[2].agent_id).toBe(agent.id); + expect(events[2].new_value).toBe(agent.id); }); it('should write events on unhookBead', async () => { @@ -667,10 +666,10 @@ describe('TownDO', () => { await town.hookBead(agent.id, bead.bead_id); await town.unhookBead(agent.id); - const events = [...(await town.listBeadEvents({ beadId: bead.bead_id }))].reverse(); - // created + hooked + unhooked - expect(events).toHaveLength(3); - expect(events[2].event_type).toBe('unhooked'); + const events = await town.listBeadEvents({ beadId: bead.bead_id }); + // created + status_changed + hooked + unhooked + expect(events).toHaveLength(4); + expect(events[3].event_type).toBe('unhooked'); }); it('should write events on updateBeadStatus', async () => { @@ -682,7 +681,7 @@ describe('TownDO', () => { const bead = await town.createBead({ type: 'issue', title: 'Status event test' }); await town.updateBeadStatus(bead.bead_id, 'in_progress', agent.id); - const events = [...(await town.listBeadEvents({ beadId: bead.bead_id }))].reverse(); + const events = await town.listBeadEvents({ beadId: bead.bead_id }); // created + status_changed expect(events).toHaveLength(2); expect(events[1].event_type).toBe('status_changed'); @@ -699,11 +698,10 @@ describe('TownDO', () => { const bead = await town.createBead({ type: 'issue', title: 'Close event test' }); await town.closeBead(bead.bead_id, agent.id); - const events = [...(await town.listBeadEvents({ beadId: bead.bead_id }))].reverse(); - // closeBead is updateBeadStatus('closed') → status_changed + const events = await town.listBeadEvents({ beadId: bead.bead_id }); + // created + closed expect(events).toHaveLength(2); - expect(events[1].event_type).toBe('status_changed'); - expect(events[1].new_value).toBe('closed'); + expect(events[1].event_type).toBe('closed'); }); it('should filter events by since timestamp', async () => { diff --git a/services/gastown/test/integration/town-container.test.ts b/services/gastown/test/integration/town-container.test.ts index deca938163..0849168b95 100644 --- a/services/gastown/test/integration/town-container.test.ts +++ b/services/gastown/test/integration/town-container.test.ts @@ -45,14 +45,12 @@ describe('Town Container Routes', () => { describe('Heartbeat Endpoint', () => { const rigId = () => `rig-${crypto.randomUUID()}`; - const townId = () => `town-${crypto.randomUUID()}`; it('should update agent activity via heartbeat', async () => { const id = rigId(); - const tid = townId(); // Register an agent first - const createRes = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents`), { + const createRes = await SELF.fetch(api(`/api/rigs/${id}/agents`), { method: 'POST', headers: headers(), body: JSON.stringify({ role: 'polecat', name: 'test-polecat', identity: 'polecat-1' }), @@ -66,14 +64,11 @@ describe('Heartbeat Endpoint', () => { await new Promise(r => setTimeout(r, 10)); // Send heartbeat - const heartbeatRes = await SELF.fetch( - api(`/api/towns/${tid}/rigs/${id}/agents/${agentId}/heartbeat`), - { - method: 'POST', - headers: headers(), - body: JSON.stringify({ status: 'running' }), - } - ); + const heartbeatRes = await SELF.fetch(api(`/api/rigs/${id}/agents/${agentId}/heartbeat`), { + method: 'POST', + headers: headers(), + body: JSON.stringify({ status: 'running' }), + }); expect(heartbeatRes.status).toBe(200); const heartbeatBody: { success: boolean; data: { heartbeat: boolean } } = await heartbeatRes.json(); @@ -81,7 +76,7 @@ describe('Heartbeat Endpoint', () => { expect(heartbeatBody.data.heartbeat).toBe(true); // Verify agent's activity was updated - const getRes = await SELF.fetch(api(`/api/towns/${tid}/rigs/${id}/agents/${agentId}`), { + const getRes = await SELF.fetch(api(`/api/rigs/${id}/agents/${agentId}`), { headers: headers(), }); const getBody: { data: { last_activity_at: string } } = await getRes.json(); @@ -90,15 +85,11 @@ describe('Heartbeat Endpoint', () => { it('should handle heartbeat for non-existent agent gracefully', async () => { const id = rigId(); - const tid = townId(); - const res = await SELF.fetch( - api(`/api/towns/${tid}/rigs/${id}/agents/non-existent/heartbeat`), - { - method: 'POST', - headers: headers(), - body: JSON.stringify({ status: 'running' }), - } - ); + const res = await SELF.fetch(api(`/api/rigs/${id}/agents/non-existent/heartbeat`), { + method: 'POST', + headers: headers(), + body: JSON.stringify({ status: 'running' }), + }); // The DO's touchAgent won't throw for non-existent agent (it's a no-op UPDATE) expect(res.status).toBe(200); }); diff --git a/services/gastown/test/integration/town-deletion.test.ts b/services/gastown/test/integration/town-deletion.test.ts index b4c5561c85..dff188cff3 100644 --- a/services/gastown/test/integration/town-deletion.test.ts +++ b/services/gastown/test/integration/town-deletion.test.ts @@ -84,7 +84,7 @@ describe('Town deletion (#1182)', () => { // Write events to the AgentDO const agentDO = getAgentStub(agent.id); - await agentDO.appendEvent('session.start', JSON.stringify({ test: true })); + await agentDO.appendEvents([{ type: 'session.start', data: JSON.stringify({ test: true }) }]); const eventsBefore = await agentDO.getEvents(); expect(eventsBefore.length).toBeGreaterThan(0); diff --git a/services/session-ingest/src/ingest/validate-oversized.test.ts b/services/session-ingest/src/ingest/validate-oversized.test.ts deleted file mode 100644 index 2fb81d585c..0000000000 --- a/services/session-ingest/src/ingest/validate-oversized.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -// Production MAX_SINGLE_ITEM_BYTES is 50MiB. Tokenizing a JSON string that -// large exceeds vitest's 5s default timeout even in isolation, which is what -// failed the backend gate. Mirror queue-consumer.test.ts: shrink the skip -// threshold so the extractor path is exercised without a 50MiB payload. -vi.mock('../util/ingest-limits', () => ({ - INGEST_CHUNK_MAX_BYTES: 4 * 1024 * 1024, - INGEST_CHUNK_MAX_ITEMS: 128, - MAX_INGEST_ITEM_BYTES: 100, - MAX_SINGLE_ITEM_BYTES: 500, -})); - -import { MAX_SINGLE_ITEM_BYTES } from '../util/ingest-limits'; -import { validateAndParseIngestPayload } from './validate'; - -const encoder = new TextEncoder(); - -describe('validateAndParseIngestPayload oversized items', () => { - it('reports parser-skipped oversized items as ineligible', () => { - const result = validateAndParseIngestPayload( - encoder.encode( - JSON.stringify({ - data: [ - { - type: 'message', - data: { id: 'msg_huge', content: 'x'.repeat(MAX_SINGLE_ITEM_BYTES) }, - }, - ], - }) - ) - ); - - expect(result).toMatchObject({ - ok: true, - validItemCount: 0, - skippedItemCount: 1, - maxValidItemBytes: MAX_SINGLE_ITEM_BYTES + 1, - }); - }); -}); diff --git a/services/session-ingest/src/ingest/validate.test.ts b/services/session-ingest/src/ingest/validate.test.ts index 102f7a8e17..88474d23d8 100644 --- a/services/session-ingest/src/ingest/validate.test.ts +++ b/services/session-ingest/src/ingest/validate.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { INGEST_CHUNK_MAX_BYTES, INGEST_CHUNK_MAX_ITEMS } from '../util/ingest-limits'; +import { + INGEST_CHUNK_MAX_BYTES, + INGEST_CHUNK_MAX_ITEMS, + MAX_SINGLE_ITEM_BYTES, +} from '../util/ingest-limits'; import { validateAndParseIngestPayload } from './validate'; const encoder = new TextEncoder(); @@ -138,6 +142,21 @@ describe('validateAndParseIngestPayload', () => { }); }); + it('reports parser-skipped oversized items as ineligible', () => { + const result = validate({ + data: [ + { type: 'message', data: { id: 'msg_huge', content: 'x'.repeat(MAX_SINGLE_ITEM_BYTES) } }, + ], + }); + + expect(result).toMatchObject({ + ok: true, + validItemCount: 0, + skippedItemCount: 1, + maxValidItemBytes: MAX_SINGLE_ITEM_BYTES + 1, + }); + }); + it.each([ ['at', INGEST_CHUNK_MAX_BYTES], ['over', INGEST_CHUNK_MAX_BYTES + 1], From 50b6a7717ec60e852faa7a4780b9d35fcf55a7bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 20:46:37 +0200 Subject: [PATCH 09/10] fix(glanceable-ios): sweep again when the deferred restore lands A sweep that met an in-flight persisted-state read deferred and never ran again, so when that read settled with an empty mirror the card the sweep kept was unowned and stayed on the Lock Screen until the next foreground or publisher update. `persist` now exposes `whenGlanceableRestoresSettle`, which resumes a caller when the last in-flight read lands, and the sweep awaits it before it runs again. A read that fails still settles, so the sweep re-reads the unreadable flag instead of waiting forever. --- .../src/glanceable-ios/ios-sink.test.ts | 32 ++++++++++++ apps/mobile/src/glanceable-ios/ios-sink.ts | 34 ++++++++++--- .../mobile/src/lib/glanceable/persist.test.ts | 50 +++++++++++++++++++ apps/mobile/src/lib/glanceable/persist.ts | 33 ++++++++++++ 4 files changed, 141 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/glanceable-ios/ios-sink.test.ts b/apps/mobile/src/glanceable-ios/ios-sink.test.ts index f3db22cf04..ce97e1dc82 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.test.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.test.ts @@ -1768,6 +1768,38 @@ describe('iosSink stray sweep', () => { await restore; }); + it('sweeps again once the deferred restore lands, so an unowned card cannot survive it', async () => { + const cards = [nativeStray(), nativeStray()]; + // Launch: the foreground edge reaches the sweep before the SecureStore read + // lands, so the sweep defers with the duplicates collapsed. That read is + // the only thing that will settle it, and it then reports an empty mirror, + // so no snapshot owns the surface and the card the sweep kept is unowned. + // The deferred sweep must run again instead of waiting for some later + // foreground or publisher update. + const gate = Promise.withResolvers(); + _setSecureStoreForTests({ + setItemAsync: secureStoreMock.setItemAsync, + getItemAsync: async () => { + await gate.promise; + return null; + }, + }); + + const restore = restorePersistedGlanceable(); + sweepStrayActivities(); + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(1); + }); + + gate.resolve(null); + await restore; + + await vi.waitFor(() => { + expect(endedCount(cards)).toBe(2); + }); + }); + it('keeps one card while a later restore is in flight, not only the first', async () => { const cards = [nativeStray(), nativeStray()]; // The first restore settles against an empty mirror, so the in-memory diff --git a/apps/mobile/src/glanceable-ios/ios-sink.ts b/apps/mobile/src/glanceable-ios/ios-sink.ts index 577201ca6b..249246837c 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.ts @@ -13,6 +13,7 @@ import { isGlanceableRestoreSettled, isGlanceableRestoreUnavailable, restorePersistedGlanceable, + whenGlanceableRestoresSettle, } from '@/lib/glanceable/persist'; import { getGlanceableDelivery, @@ -375,14 +376,31 @@ export function sweepStrayActivities(): void { return; } const snapshot = getLastGlanceableSnapshot(); - if (snapshot === null && (isGlanceableRestoreUnavailable() || !isGlanceableRestoreSettled())) { - // The persisted owner could not be read, or its read has not finished yet, - // so a null snapshot means "unknown", not "nothing can own the surface". - // This runs at import in the headless push process, and on the foreground - // edge before the launch restore settles, where ending every instance would - // tear down the card a push-to-start just raised before this process can - // adopt it. Reconciliation still ends every instance but the one native - // discovery keeps, so the surface never holds more than one card. + if (snapshot === null && !isGlanceableRestoreSettled()) { + // The read has not finished yet, so a null snapshot means "unknown", not + // "nothing can own the surface". This runs at import in the headless push + // process, and on the foreground edge before the launch restore settles, + // where ending every instance would tear down the card a push-to-start just + // raised before this process can adopt it. Reconciliation still ends every + // instance but the one native discovery keeps, so the surface never holds + // more than one card. + // + // The deferral must not drop the sweep: the read this sweep waited for is + // the only thing that will settle it, and without a rerun an unowned card + // would stay on the Lock Screen until the next foreground or publisher + // update. Wait for the last read to land and sweep again. + void whenGlanceableRestoresSettle().then(() => { + sweepStrayActivities(); + }); + refreshActivity(); + return; + } + if (snapshot === null && isGlanceableRestoreUnavailable()) { + // The persisted owner could not be read, so a null snapshot means + // "unknown", not "nothing can own the surface": the mirror may still name a + // card owner and this process cannot tell. Reconciliation ends every + // instance but the one native discovery keeps, and a later foreground or + // publisher update sweeps again. refreshActivity(); return; } diff --git a/apps/mobile/src/lib/glanceable/persist.test.ts b/apps/mobile/src/lib/glanceable/persist.test.ts index a580056f81..0d59725f3f 100644 --- a/apps/mobile/src/lib/glanceable/persist.test.ts +++ b/apps/mobile/src/lib/glanceable/persist.test.ts @@ -16,6 +16,7 @@ import { isGlanceableRestoreUnavailable, persistGlanceableSink, restorePersistedGlanceable, + whenGlanceableRestoresSettle, } from './persist'; const NOW = 1_750_000_000_000; @@ -197,6 +198,55 @@ describe('restorePersistedGlanceable', () => { expect(isGlanceableRestoreSettled()).toBe(true); }); + it('resumes a waiter when the last in-flight read lands', async () => { + await restorePersistedGlanceable(); + // Nothing in flight: a waiter parked after the fact resumes at once. + await whenGlanceableRestoresSettle(); + + const gate = deferred(); + secureStoreMock.getItemAsync.mockImplementationOnce(async () => { + await gate.promise; + return null; + }); + const restore = restorePersistedGlanceable(); + + let resumed = false; + const waiting = whenGlanceableRestoresSettle().then(() => { + resumed = true; + }); + await Promise.resolve(); + expect(resumed).toBe(false); + + gate.resolve(); + await restore; + await waiting; + + expect(resumed).toBe(true); + }); + + it('resumes a waiter when a read that fails settles', async () => { + const gate = deferred(); + secureStoreMock.getItemAsync.mockImplementationOnce(async () => { + await gate.promise; + throw new Error('keychain locked'); + }); + const restore = restorePersistedGlanceable(); + + let resumed = false; + const waiting = whenGlanceableRestoresSettle().then(() => { + resumed = true; + }); + + gate.resolve(); + await restore; + await waiting; + + // The failure still settles the read, so the waiter resumes and reads the + // unreadable flag itself. + expect(resumed).toBe(true); + expect(isGlanceableRestoreUnavailable()).toBe(true); + }); + it('clears the unreadable flag after a read that succeeds', async () => { _setGlanceableRestoreUnavailableForTests(true); diff --git a/apps/mobile/src/lib/glanceable/persist.ts b/apps/mobile/src/lib/glanceable/persist.ts index bdfa249882..29cba8ef0f 100644 --- a/apps/mobile/src/lib/glanceable/persist.ts +++ b/apps/mobile/src/lib/glanceable/persist.ts @@ -62,6 +62,11 @@ let restoreUnavailable = false; // until the last one lands. let restoresInFlight = 0; +// Callers parked on the read window (see `whenGlanceableRestoresSettle`). A +// caller that deferred work on an unsettled read waits here, so the last read +// to land resumes it instead of dropping the work. +let settleWaiters: (() => void)[] = []; + export function getLastGlanceableSnapshot(): GlanceableAgentsSnapshot | null { return lastSnapshot; } @@ -88,6 +93,26 @@ export function isGlanceableRestoreSettled(): boolean { return restoresInFlight === 0; } +/** + * Resolve once no `restorePersistedGlanceable` read is in flight. A caller that + * had to defer work on an unsettled read — a sweep that must not retire cards + * on a null snapshot that only means "not read yet" — waits here and resumes + * when the last read lands, instead of dropping the work until some later + * foreground or publisher update happens to come by. + * + * Resolves at once when nothing is in flight, so the caller can await it on + * every path. A read that fails still settles, so a waiter always resumes and + * re-reads the state, including `isGlanceableRestoreUnavailable`. + */ +export function whenGlanceableRestoresSettle(): Promise { + if (restoresInFlight === 0) { + return Promise.resolve(); + } + return new Promise(resolve => { + settleWaiters.push(resolve); + }); +} + export function getLocalScopeKey(): string | null { return localScopeKey; } @@ -178,6 +203,13 @@ export async function restorePersistedGlanceable(): Promise { // read, so a null snapshot stops meaning "the read has not finished" once // every in-flight read has consulted the record. restoresInFlight -= 1; + if (restoresInFlight === 0) { + const waiting = settleWaiters; + settleWaiters = []; + for (const resume of waiting) { + resume(); + } + } } } @@ -221,5 +253,6 @@ export function _resetGlanceablePersistForTests(): void { localScopeKey = null; restoreUnavailable = false; restoresInFlight = 0; + settleWaiters = []; secureStoreForTests = null; } From 7872749e3161b1ad871bbc4ea0478d1923c8b2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 25 Sep 2026 20:54:00 +0200 Subject: [PATCH 10/10] style(glanceable-ios): await the settle waiter instead of chaining then The mobile lint enables the promise rules, so `then` callbacks fail the lint job: the sweep resumes through an async IIFE, the tests await the waiter through the same shape, and `whenGlanceableRestoresSettle` is async. --- apps/mobile/src/glanceable-ios/ios-sink.ts | 5 +++-- apps/mobile/src/lib/glanceable/persist.test.ts | 10 ++++++---- apps/mobile/src/lib/glanceable/persist.ts | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/glanceable-ios/ios-sink.ts b/apps/mobile/src/glanceable-ios/ios-sink.ts index 249246837c..0190a55b86 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.ts @@ -389,9 +389,10 @@ export function sweepStrayActivities(): void { // the only thing that will settle it, and without a rerun an unowned card // would stay on the Lock Screen until the next foreground or publisher // update. Wait for the last read to land and sweep again. - void whenGlanceableRestoresSettle().then(() => { + void (async () => { + await whenGlanceableRestoresSettle(); sweepStrayActivities(); - }); + })(); refreshActivity(); return; } diff --git a/apps/mobile/src/lib/glanceable/persist.test.ts b/apps/mobile/src/lib/glanceable/persist.test.ts index 0d59725f3f..feb283d51a 100644 --- a/apps/mobile/src/lib/glanceable/persist.test.ts +++ b/apps/mobile/src/lib/glanceable/persist.test.ts @@ -211,9 +211,10 @@ describe('restorePersistedGlanceable', () => { const restore = restorePersistedGlanceable(); let resumed = false; - const waiting = whenGlanceableRestoresSettle().then(() => { + const waiting = (async () => { + await whenGlanceableRestoresSettle(); resumed = true; - }); + })(); await Promise.resolve(); expect(resumed).toBe(false); @@ -233,9 +234,10 @@ describe('restorePersistedGlanceable', () => { const restore = restorePersistedGlanceable(); let resumed = false; - const waiting = whenGlanceableRestoresSettle().then(() => { + const waiting = (async () => { + await whenGlanceableRestoresSettle(); resumed = true; - }); + })(); gate.resolve(); await restore; diff --git a/apps/mobile/src/lib/glanceable/persist.ts b/apps/mobile/src/lib/glanceable/persist.ts index 29cba8ef0f..d7389fa83c 100644 --- a/apps/mobile/src/lib/glanceable/persist.ts +++ b/apps/mobile/src/lib/glanceable/persist.ts @@ -104,11 +104,11 @@ export function isGlanceableRestoreSettled(): boolean { * every path. A read that fails still settles, so a waiter always resumes and * re-reads the state, including `isGlanceableRestoreUnavailable`. */ -export function whenGlanceableRestoresSettle(): Promise { +export async function whenGlanceableRestoresSettle(): Promise { if (restoresInFlight === 0) { - return Promise.resolve(); + return; } - return new Promise(resolve => { + await new Promise(resolve => { settleWaiters.push(resolve); }); }