diff --git a/apps/mobile/src/components/agents/interaction-surface.test.ts b/apps/mobile/src/components/agents/interaction-surface.test.ts
new file mode 100644
index 0000000000..eb6343c2cb
--- /dev/null
+++ b/apps/mobile/src/components/agents/interaction-surface.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+
+import { hasActiveInteraction, pickActiveInteractionSurface } from './interaction-surface';
+
+describe('pickActiveInteractionSurface', () => {
+ it('returns none when no interaction is active', () => {
+ expect(
+ pickActiveInteractionSurface({
+ activeQuestion: null,
+ activePermission: null,
+ activeSuggestion: null,
+ })
+ ).toEqual({ kind: 'none' });
+ });
+
+ it('prefers question over permission and suggestion', () => {
+ expect(
+ pickActiveInteractionSurface({
+ activeQuestion: { requestId: 'q-1' },
+ activePermission: { requestId: 'p-1' },
+ activeSuggestion: { requestId: 's-1' },
+ })
+ ).toEqual({ kind: 'question' });
+ });
+
+ it('prefers permission over suggestion', () => {
+ expect(
+ pickActiveInteractionSurface({
+ activeQuestion: null,
+ activePermission: { requestId: 'p-1' },
+ activeSuggestion: { requestId: 's-1' },
+ })
+ ).toEqual({ kind: 'permission' });
+ });
+
+ it('returns suggestion when no question or permission is active', () => {
+ expect(
+ pickActiveInteractionSurface({
+ activeQuestion: null,
+ activePermission: null,
+ activeSuggestion: { requestId: 's-1' },
+ })
+ ).toEqual({ kind: 'suggestion' });
+ });
+});
+
+describe('hasActiveInteraction', () => {
+ it('is true when only suggestion is active so the composer is hidden', () => {
+ expect(
+ hasActiveInteraction({
+ activeQuestion: null,
+ activePermission: null,
+ activeSuggestion: { requestId: 's-1' },
+ })
+ ).toBe(true);
+ });
+
+ it('is false when no interaction is active so the composer can render', () => {
+ expect(
+ hasActiveInteraction({
+ activeQuestion: null,
+ activePermission: null,
+ activeSuggestion: null,
+ })
+ ).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/components/agents/interaction-surface.ts b/apps/mobile/src/components/agents/interaction-surface.ts
new file mode 100644
index 0000000000..3923fc2a0f
--- /dev/null
+++ b/apps/mobile/src/components/agents/interaction-surface.ts
@@ -0,0 +1,42 @@
+/**
+ * Pure derivation of the active interaction surface for `SessionDetailContent`.
+ *
+ * Exactly one interaction surface is visible at a time. The precedence is
+ * fixed: question > permission > suggestion. The composer is hidden for
+ * any active interaction so the user can only act on the surface they are
+ * being asked to respond to.
+ */
+
+type ActiveInteractionLike = { requestId: string } | null | undefined;
+
+export function hasActiveInteraction(args: {
+ activeQuestion: ActiveInteractionLike;
+ activePermission: ActiveInteractionLike;
+ activeSuggestion: ActiveInteractionLike;
+}): boolean {
+ return (
+ Boolean(args.activeQuestion) || Boolean(args.activePermission) || Boolean(args.activeSuggestion)
+ );
+}
+
+/**
+ * Returns the precedence winner for which interaction card to render.
+ * `kind: 'none'` means no interaction card should render and the composer
+ * is allowed (subject to its other disabled flags).
+ */
+export function pickActiveInteractionSurface(args: {
+ activeQuestion: ActiveInteractionLike;
+ activePermission: ActiveInteractionLike;
+ activeSuggestion: ActiveInteractionLike;
+}): { kind: 'question' } | { kind: 'permission' } | { kind: 'suggestion' } | { kind: 'none' } {
+ if (args.activeQuestion) {
+ return { kind: 'question' };
+ }
+ if (args.activePermission) {
+ return { kind: 'permission' };
+ }
+ if (args.activeSuggestion) {
+ return { kind: 'suggestion' };
+ }
+ return { kind: 'none' };
+}
diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx
index 34df5f1330..fb8c102e19 100644
--- a/apps/mobile/src/components/agents/session-detail-content.tsx
+++ b/apps/mobile/src/components/agents/session-detail-content.tsx
@@ -14,6 +14,7 @@ import { MessageBubble } from '@/components/agents/message-bubble';
import { ModelPickerSelectionScopeProvider } from '@/components/agents/model-selector';
import { PermissionCard } from '@/components/agents/permission-card';
import { QuestionCard } from '@/components/agents/question-card';
+import { SuggestionCard } from '@/components/agents/suggestion-card';
import { getSessionKeyboardContainerKind } from '@/components/agents/session-keyboard-container-state';
import { useSessionManager } from '@/components/agents/session-provider';
import { SessionStatusIndicator } from '@/components/agents/session-status-indicator';
@@ -23,11 +24,16 @@ import {
} from '@/components/agents/session-working-state';
import { EmptyState } from '@/components/empty-state';
import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding';
+import { useAgentSessionPresence } from '@/components/kilo-chat/hooks/use-agent-session-presence';
import { useInteractionHandlers } from '@/components/agents/use-interaction-handlers';
import { useSessionAutoScroll } from '@/components/agents/use-session-auto-scroll';
import { useSessionConfigSync } from '@/components/agents/use-session-config-sync';
import { WorkingIndicator } from '@/components/agents/working-indicator';
import { ChildSessionSheet } from '@/components/agents/child-session-sheet';
+import {
+ hasActiveInteraction,
+ pickActiveInteractionSurface,
+} from '@/components/agents/interaction-surface';
import { PartRenderer } from '@/components/agents/part-renderer';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
@@ -91,6 +97,7 @@ export function SessionDetailContent({
const supportsAttachments = useAtomValue(manager.atoms.supportsAttachments);
const activeQuestion = useAtomValue(manager.atoms.activeQuestion);
const activePermission = useAtomValue(manager.atoms.activePermission);
+ const activeSuggestion = useAtomValue(manager.atoms.activeSuggestion);
const totalCost = useAtomValue(manager.atoms.totalCost);
const getChildMessages = useAtomValue(manager.atoms.childMessages);
const getChildSessionHydrationState = useAtomValue(manager.atoms.childSessionHydrationState);
@@ -103,6 +110,12 @@ export function SessionDetailContent({
const { isConnected } = useAppLifecycle();
const { bottom } = useSafeAreaInsets();
+ // Hold exact-session presence while the user is actively viewing this
+ // session. The hook gates on app-active + route-focused and releases the
+ // context as soon as we navigate away so notifications for this session
+ // (and others) resume correctly.
+ useAgentSessionPresence(sessionId);
+
const analyticsSurface: AnalyticsSurface = fetchedData?.cloudAgentSessionId
? 'cloud-agent'
: 'remote-session';
@@ -271,6 +284,26 @@ export function SessionDetailContent({
}
}, [manager]);
+ // SuggestionCard owns safe error copy. We forward the manager call and
+ // let the error propagate to the card, which renders the friendly retry
+ // text; we deliberately avoid a second toast here.
+ const handleAcceptSuggestion = useCallback(
+ async (index: number) => {
+ if (!activeSuggestion) {
+ return;
+ }
+ await manager.acceptSuggestion(activeSuggestion.requestId, index);
+ },
+ [activeSuggestion, manager]
+ );
+
+ const handleDismissSuggestion = useCallback(async () => {
+ if (!activeSuggestion) {
+ return;
+ }
+ await manager.dismissSuggestion(activeSuggestion.requestId);
+ }, [activeSuggestion, manager]);
+
const handleBackToSessions = useCallback(() => {
router.replace('/(app)/(tabs)/(2_agents)' as Href);
}, [router]);
@@ -331,14 +364,27 @@ export function SessionDetailContent({
const title =
fetchedData?.kiloSessionId === sessionId ? (fetchedData.title ?? 'Session') : 'Session';
const requiresModel = Boolean(fetchedData?.cloudAgentSessionId);
+ // Exactly one interaction surface is ever visible: question wins, then
+ // permission, then suggestion. The composer is hidden while any of them
+ // is active so the user can only act on the surface they're being asked
+ // to respond to.
+ const hasInteraction = hasActiveInteraction({
+ activeQuestion,
+ activePermission,
+ activeSuggestion,
+ });
+ const activeInteractionSurface = pickActiveInteractionSurface({
+ activeQuestion,
+ activePermission,
+ activeSuggestion,
+ });
const isComposerDisabled =
isReadOnly ||
!canSend ||
shouldShowLoading ||
Boolean(error) ||
- Boolean(activeQuestion) ||
+ hasInteraction ||
(requiresModel && !currentModel);
- const showInteractionCards = activeQuestion ?? activePermission;
const composerPlaceholder =
(cloudStatus && COMPOSER_PLACEHOLDERS[cloudStatus.type]) ?? 'Message...';
const keyboardContainerKind = getSessionKeyboardContainerKind(Platform.OS);
@@ -429,7 +475,7 @@ export function SessionDetailContent({
<>
{renderContent()}
- {activeQuestion ? (
+ {activeInteractionSurface.kind === 'question' && activeQuestion ? (
{
@@ -442,7 +488,7 @@ export function SessionDetailContent({
/>
) : null}
- {activePermission ? (
+ {activeInteractionSurface.kind === 'permission' && activePermission ? (
) : null}
- {!showInteractionCards &&
+ {activeInteractionSurface.kind === 'suggestion' && activeSuggestion ? (
+ {
+ await handleAcceptSuggestion(index);
+ }}
+ onDismiss={async () => {
+ await handleDismissSuggestion();
+ }}
+ />
+ ) : null}
+
+ {!hasInteraction &&
(isReadOnly && messages.length > 0 ? (
diff --git a/apps/mobile/src/components/agents/suggestion-card.test.tsx b/apps/mobile/src/components/agents/suggestion-card.test.tsx
new file mode 100644
index 0000000000..2025f4cff0
--- /dev/null
+++ b/apps/mobile/src/components/agents/suggestion-card.test.tsx
@@ -0,0 +1,358 @@
+import * as React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SuggestionCard } from './suggestion-card';
+
+const mockHaptics = vi.hoisted(() => vi.fn());
+
+const MockButton = vi.hoisted(() => () => null);
+const MockText = vi.hoisted(() => () => null);
+const MockView = vi.hoisted(() => () => null);
+const MockScrollView = vi.hoisted(() => () => null);
+const MockPressable = vi.hoisted(() => () => null);
+const MockActivityIndicator = vi.hoisted(() => () => null);
+
+vi.mock('expo-haptics', () => ({
+ ImpactFeedbackStyle: { Light: 'Light' },
+ impactAsync: (style: string) => mockHaptics(style),
+}));
+
+vi.mock('@/components/ui/button', () => ({ Button: MockButton }));
+vi.mock('@/components/ui/text', () => ({ Text: MockText }));
+
+vi.mock('react-native', () => ({
+ ActivityIndicator: MockActivityIndicator,
+ Pressable: MockPressable,
+ ScrollView: MockScrollView,
+ Text: MockText,
+ View: MockView,
+}));
+
+type ReactInternals = {
+ __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: {
+ H: unknown;
+ };
+};
+
+type SuggestionCardProps = React.ComponentProps;
+
+type MockProps = { children?: React.ReactNode; [key: string]: unknown };
+type MockElement = React.ReactElement;
+
+function createRenderer() {
+ const reactInternals = React as typeof React & ReactInternals;
+ const hookState: unknown[] = [];
+ let hookIndex = 0;
+
+ // Mirrors the repo's existing React 19 test-only dispatcher harness; pinned
+ // to the current React internals shape and not intended for production use.
+ const dispatcher = {
+ useCallback: (fn: T) => fn,
+ useEffect: () => void 0,
+ useMemo: (factory: () => T) => factory(),
+ useRef: (initialValue: T) => {
+ const stateIndex = hookIndex;
+ hookIndex += 1;
+ if (hookState[stateIndex] === undefined) {
+ hookState[stateIndex] = { current: initialValue };
+ }
+ return hookState[stateIndex] as { current: T };
+ },
+ useState: (initialValue: T) => {
+ const stateIndex = hookIndex;
+ hookIndex += 1;
+ if (hookState[stateIndex] === undefined) {
+ hookState[stateIndex] = initialValue;
+ }
+ const setState = (value: T | ((prev: T) => T)) => {
+ const prev = hookState[stateIndex] as T;
+ hookState[stateIndex] =
+ typeof value === 'function' ? (value as (prev: T) => T)(prev) : value;
+ };
+ return [hookState[stateIndex] as T, setState] as const;
+ },
+ };
+
+ function render(props: SuggestionCardProps) {
+ hookIndex = 0;
+ const previousDispatcher =
+ reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H;
+ reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = dispatcher;
+ try {
+ const suggestionCard = SuggestionCard;
+ return suggestionCard(props) as React.ReactElement;
+ } finally {
+ reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H =
+ previousDispatcher;
+ }
+ }
+
+ return { render };
+}
+
+function findAll(
+ node: React.ReactNode,
+ predicate: (element: MockElement) => boolean
+): MockElement[] {
+ const results: MockElement[] = [];
+ function traverse(current: React.ReactNode) {
+ if (!React.isValidElement(current)) {
+ return;
+ }
+ const element = current as unknown as MockElement;
+ if (predicate(element)) {
+ results.push(element);
+ }
+ const children = element.props.children;
+ if (Array.isArray(children)) {
+ for (const child of children as React.ReactNode[]) {
+ traverse(child);
+ }
+ } else if (children !== undefined && children !== null) {
+ traverse(children);
+ }
+ }
+ traverse(node);
+ return results;
+}
+
+function getTextContent(node: React.ReactNode): string {
+ if (typeof node === 'string' || typeof node === 'number') {
+ return String(node);
+ }
+ if (!React.isValidElement(node)) {
+ return '';
+ }
+ const element = node as unknown as MockElement;
+ const children = element.props.children;
+ if (Array.isArray(children)) {
+ return (children as React.ReactNode[]).map(child => getTextContent(child)).join('');
+ }
+ if (children === undefined || children === null) {
+ return '';
+ }
+ return getTextContent(children);
+}
+
+function throwDeferredUninitialized(): never {
+ throw new Error('Deferred promise was not initialized');
+}
+
+function createDeferredPromise() {
+ let resolve: (value: T) => void = throwDeferredUninitialized;
+ let reject: (reason?: unknown) => void = throwDeferredUninitialized;
+ const promise = new Promise((_resolve, _reject) => {
+ resolve = _resolve;
+ reject = _reject;
+ });
+ return { promise, reject, resolve };
+}
+
+const baseProps: SuggestionCardProps = {
+ actions: [
+ { description: 'Use Prettier for formatting', label: 'Apply Prettier', prompt: 'p1' },
+ { description: 'Use Biome for formatting', label: 'Apply Biome', prompt: 'p2' },
+ ],
+ onAccept: vi.fn(),
+ onDismiss: vi.fn(),
+ text: 'Pick a formatter',
+};
+
+async function callOnPress(button: MockElement): Promise {
+ const onPress = button.props.onPress as (() => void | Promise) | undefined;
+ if (onPress === undefined) {
+ throw new Error('Button is missing onPress');
+ }
+ await onPress();
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('SuggestionCard', () => {
+ it('renders action labels and only one footer button', () => {
+ const renderer = createRenderer();
+
+ const tree = renderer.render(baseProps);
+
+ const buttons = findAll(tree, node => node.type === MockButton);
+ const buttonTexts = buttons.map(button => getTextContent(button.props.children));
+ expect(buttonTexts).toContain('Apply Prettier');
+ expect(buttonTexts).toContain('Apply Biome');
+ expect(buttonTexts).toContain('Dismiss suggestion');
+ expect(buttons).toHaveLength(baseProps.actions.length + 1);
+ });
+
+ it('second action calls onAccept(1)', async () => {
+ const renderer = createRenderer();
+ const onAccept = vi.fn().mockResolvedValue(undefined);
+ const onDismiss = vi.fn();
+
+ const tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const actionButtons = findAll(tree, node => node.type === MockButton);
+ const secondButton = actionButtons[1];
+ if (secondButton === undefined) {
+ throw new Error('Expected second action button');
+ }
+ await callOnPress(secondButton);
+
+ expect(onAccept).toHaveBeenCalledTimes(1);
+ expect(onAccept).toHaveBeenCalledWith(1);
+ expect(mockHaptics).toHaveBeenCalledWith('Light');
+ });
+
+ it('dismiss calls onDismiss', async () => {
+ const renderer = createRenderer();
+ const onAccept = vi.fn();
+ const onDismiss = vi.fn().mockResolvedValue(undefined);
+
+ const tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const dismissButton = findAll(tree, node => node.type === MockButton).find(
+ button => getTextContent(button.props.children) === 'Dismiss suggestion'
+ );
+ if (dismissButton === undefined) {
+ throw new Error('Expected dismiss button');
+ }
+ await callOnPress(dismissButton);
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('prevents same-frame double taps', async () => {
+ const renderer = createRenderer();
+ const onAccept = vi.fn().mockResolvedValue(undefined);
+ const onDismiss = vi.fn();
+
+ const tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const [firstButton] = findAll(tree, node => node.type === MockButton);
+ if (firstButton === undefined) {
+ throw new Error('Expected first action button');
+ }
+ void callOnPress(firstButton);
+ void callOnPress(firstButton);
+
+ await new Promise(resolve => {
+ setImmediate(resolve);
+ });
+
+ expect(onAccept).toHaveBeenCalledTimes(1);
+ });
+
+ it('deferred pending disables all buttons and shows loading only on tapped action', async () => {
+ const renderer = createRenderer();
+ const { promise, resolve } = createDeferredPromise();
+ const onAccept = vi.fn().mockReturnValue(promise);
+ const onDismiss = vi.fn();
+
+ let tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const actionButtons = findAll(tree, node => node.type === MockButton);
+ const secondButton = actionButtons[1];
+ if (secondButton === undefined) {
+ throw new Error('Expected second action button');
+ }
+ const pressPromise = callOnPress(secondButton);
+ expect(onAccept).toHaveBeenCalledTimes(1);
+
+ tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const buttonsAfterPending = findAll(tree, node => node.type === MockButton);
+ const [firstButton, secondButtonAfterPending, dismissButton] = buttonsAfterPending;
+ if (
+ firstButton === undefined ||
+ secondButtonAfterPending === undefined ||
+ dismissButton === undefined
+ ) {
+ throw new Error('Expected buttons after pending');
+ }
+ for (const button of buttonsAfterPending) {
+ expect(button.props.disabled).toBe(true);
+ }
+ expect(secondButtonAfterPending.props.loading).toBe(true);
+ expect(firstButton.props.loading).toBe(false);
+ expect(dismissButton.props.loading).toBe(false);
+
+ await callOnPress(secondButtonAfterPending);
+ expect(onAccept).toHaveBeenCalledTimes(1);
+
+ resolve(undefined);
+ await pressPromise;
+ });
+
+ it('accept failure shows exact safe copy and not raw error', async () => {
+ const renderer = createRenderer();
+ const onAccept = vi.fn().mockRejectedValue(new Error('Raw upstream error'));
+ const onDismiss = vi.fn();
+
+ let tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const [firstButton] = findAll(tree, node => node.type === MockButton);
+ if (firstButton === undefined) {
+ throw new Error('Expected first action button');
+ }
+ await callOnPress(firstButton);
+
+ tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const allText = findAll(tree, node => node.type === MockText)
+ .map(text => getTextContent(text))
+ .join(' ');
+ expect(allText).toContain("Couldn't apply this suggestion. Try again.");
+ expect(allText).not.toContain('Raw upstream error');
+ });
+
+ it('dismiss failure shows exact safe copy and not raw error', async () => {
+ const renderer = createRenderer();
+ const onAccept = vi.fn();
+ const onDismiss = vi.fn().mockRejectedValue(new Error('Raw dismiss error'));
+
+ let tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const dismissButton = findAll(tree, node => node.type === MockButton).find(
+ button => getTextContent(button.props.children) === 'Dismiss suggestion'
+ );
+ if (dismissButton === undefined) {
+ throw new Error('Expected dismiss button');
+ }
+ await callOnPress(dismissButton);
+
+ tree = renderer.render({ ...baseProps, onAccept, onDismiss });
+
+ const allText = findAll(tree, node => node.type === MockText)
+ .map(text => getTextContent(text))
+ .join(' ');
+ expect(allText).toContain("Couldn't dismiss this suggestion. Try again.");
+ expect(allText).not.toContain('Raw dismiss error');
+ });
+
+ it('sets accessibility label and hint on action buttons and hides description text', () => {
+ const renderer = createRenderer();
+
+ const tree = renderer.render(baseProps);
+
+ const actionButtons = findAll(tree, node => node.type === MockButton);
+ const [firstButton] = actionButtons;
+ if (firstButton === undefined) {
+ throw new Error('Expected first action button');
+ }
+ expect(firstButton.props.accessibilityLabel).toBe('Apply Prettier');
+ expect(firstButton.props.accessibilityHint).toBe('Use Prettier for formatting');
+
+ const descriptionTexts = findAll(
+ tree,
+ node =>
+ node.type === MockText &&
+ typeof node.props.children === 'string' &&
+ node.props.accessible === false
+ );
+ const descriptionContents = descriptionTexts.map(
+ (text): React.ReactNode => text.props.children
+ );
+ expect(descriptionContents).toContain('Use Prettier for formatting');
+ expect(descriptionContents).toContain('Use Biome for formatting');
+ });
+});
diff --git a/apps/mobile/src/components/agents/suggestion-card.tsx b/apps/mobile/src/components/agents/suggestion-card.tsx
new file mode 100644
index 0000000000..34f6b4eef0
--- /dev/null
+++ b/apps/mobile/src/components/agents/suggestion-card.tsx
@@ -0,0 +1,160 @@
+import { useCallback, useRef, useState } from 'react';
+import { ScrollView, View } from 'react-native';
+import * as Haptics from 'expo-haptics';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { cn } from '@/lib/utils';
+
+// Minimal shape of `StandaloneSuggestion['actions'][number]`. We re-declare
+// the relevant fields here (rather than importing the SDK type) so the
+// card stays a pure presentational component and can be rendered in tests
+// with a small literal. The runtime shape comes from the SDK manager.
+type SuggestionAction = {
+ label: string;
+ description?: string;
+ prompt: string;
+};
+
+type SuggestionCardProps = {
+ text: string;
+ actions: SuggestionAction[];
+ onAccept: (index: number) => Promise;
+ onDismiss: () => Promise;
+};
+
+type PendingState = { kind: 'accept'; index: number } | { kind: 'dismiss' };
+
+// Friendly inline error copy. Keep these user-facing strings local to the
+// card so retries stay actionable without exposing upstream error text.
+const ACCEPT_ERROR_COPY = "Couldn't apply this suggestion. Try again.";
+const DISMISS_ERROR_COPY = "Couldn't dismiss this suggestion. Try again.";
+
+export function SuggestionCard({
+ text,
+ actions,
+ onAccept,
+ onDismiss,
+}: Readonly) {
+ const [pending, setPending] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const pendingRef = useRef(null);
+
+ const handleAccept = useCallback(
+ async (index: number) => {
+ // Immediate ref lock prevents same-frame double-taps
+ if (pendingRef.current) {
+ return;
+ }
+ pendingRef.current = { kind: 'accept', index };
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ setPending({ kind: 'accept', index });
+ setErrorMessage(null);
+ try {
+ await onAccept(index);
+ // Success: the manager resolves activeSuggestion and the parent
+ // unmounts this card, so we don't need to clear pending here.
+ } catch {
+ pendingRef.current = null;
+ setErrorMessage(ACCEPT_ERROR_COPY);
+ setPending(null);
+ }
+ },
+ [onAccept]
+ );
+
+ const handleDismiss = useCallback(async () => {
+ // Immediate ref lock prevents same-frame double-taps
+ if (pendingRef.current) {
+ return;
+ }
+ pendingRef.current = { kind: 'dismiss' };
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ setPending({ kind: 'dismiss' });
+ setErrorMessage(null);
+ try {
+ await onDismiss();
+ } catch {
+ pendingRef.current = null;
+ setErrorMessage(DISMISS_ERROR_COPY);
+ setPending(null);
+ }
+ }, [onDismiss]);
+
+ const isDismissing = pending?.kind === 'dismiss';
+ const isPending = pending !== null;
+
+ return (
+
+
+ Agent suggestion
+
+
+
+
+ {text}
+
+ {actions.length > 0 ? (
+
+ {actions.map((action, index) => (
+
+
+ {action.description ? (
+
+ {action.description}
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.test.ts b/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.test.ts
new file mode 100644
index 0000000000..a374bf2b14
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useAgentSessionPresence } from './use-agent-session-presence';
+
+const testState = vi.hoisted(() => ({
+ context: null as string | null,
+ active: false,
+ appActiveAndFocused: true,
+}));
+
+vi.mock('@kilocode/kilo-chat-hooks', () => ({
+ usePresenceSubscription: (context: string | null, active: boolean) => {
+ testState.context = context;
+ testState.active = active;
+ },
+}));
+
+vi.mock('./use-app-active-and-focused', () => ({
+ useAppActiveAndFocused: () => testState.appActiveAndFocused,
+}));
+
+describe('useAgentSessionPresence', () => {
+ beforeEach(() => {
+ testState.context = null;
+ testState.active = false;
+ testState.appActiveAndFocused = true;
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('subscribes to the exact agent-session context when active and focused', () => {
+ useAgentSessionPresence('session-123');
+ expect(testState.context).toBe('/presence/agent-session/session-123');
+ expect(testState.active).toBe(true);
+ });
+
+ it('passes null context and disabled flag when no session id is provided', () => {
+ useAgentSessionPresence(undefined);
+ expect(testState.context).toBeNull();
+ expect(testState.active).toBe(false);
+ });
+
+ it('disables the subscription when the app is not active and focused', () => {
+ testState.appActiveAndFocused = false;
+ useAgentSessionPresence('session-123');
+ expect(testState.context).toBe('/presence/agent-session/session-123');
+ expect(testState.active).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.ts
new file mode 100644
index 0000000000..3715059ee1
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-agent-session-presence.ts
@@ -0,0 +1,25 @@
+import { presenceContextForAgentSession } from '@kilocode/event-service';
+import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks';
+
+import { useAppActiveAndFocused } from './use-app-active-and-focused';
+
+/**
+ * Exact-session presence for the Cloud Agent / remote CLI session the user
+ * is actively viewing. Mirrors the gating of `useConversationPresence`:
+ * holds the presence context only while the app is in the foreground AND
+ * the current expo-router route is focused. When the user is on any other
+ * session (or no session at all), the context is released so notifications
+ * can reach the device.
+ *
+ * The wrapper's policy filter already suppresses auto-approved and
+ * non-actionable upstream events, so we only need to gate on
+ * "is the user looking at this session right now" — not on transport
+ * state, message stream status, or any other internal flag.
+ */
+export function useAgentSessionPresence(sessionId: string | undefined) {
+ const activeAndFocused = useAppActiveAndFocused();
+ usePresenceSubscription(
+ sessionId ? presenceContextForAgentSession(sessionId) : null,
+ Boolean(sessionId) && activeAndFocused
+ );
+}
diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts
index c48690fa15..8112d49a8d 100644
--- a/apps/mobile/vitest.config.ts
+++ b/apps/mobile/vitest.config.ts
@@ -34,6 +34,7 @@ export default defineConfig({
'src/lib/kilo-pass/**/*.test.tsx',
'src/lib/onboarding/**/*.test.ts',
'src/components/**/*.test.ts',
+ 'src/components/**/*.test.tsx',
],
},
});
diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx
index 27416a0920..0fa775e807 100644
--- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx
+++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx
@@ -46,6 +46,7 @@ import { ContextUsageIndicator } from './ContextUsageIndicator';
import { resolveContextWindow } from './model-context-lengths';
import { useSlashCommandSets } from '@/hooks/useSlashCommandSets';
import { useCelebrationSound } from '@/hooks/useCelebrationSound';
+import { useAgentSessionPresence } from '@/hooks/useAgentSessionPresence';
import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants';
import { SetPageTitle } from '@/components/SetPageTitle';
@@ -204,6 +205,13 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
}
}, [sessionIdFromParams, manager]);
+ // Exact-session presence. Holds the per-session context only while the
+ // tab is visible and the user is on a session. We do not gate on the
+ // stream/socket state — the wrapper's policy filter and the outbox own
+ // upstream suppression; this hook only reflects "is the user looking
+ // at this session right now."
+ useAgentSessionPresence(sessionIdFromParams);
+
// -- Manager atoms --------------------------------------------------------
const isStreaming = useAtomValue(manager.atoms.isStreaming);
const isLoading = useAtomValue(manager.atoms.isLoading);
diff --git a/apps/web/src/hooks/useAgentSessionPresence.test.ts b/apps/web/src/hooks/useAgentSessionPresence.test.ts
new file mode 100644
index 0000000000..2a94de9455
--- /dev/null
+++ b/apps/web/src/hooks/useAgentSessionPresence.test.ts
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { renderToString } from 'react-dom/server';
+
+import { useAgentSessionPresence } from './useAgentSessionPresence';
+
+const mockUsePresenceSubscription = jest.fn();
+const mockUseDocumentVisible = jest.fn();
+
+jest.mock('@kilocode/kilo-chat-hooks', () => ({
+ usePresenceSubscription: (...args: unknown[]) => mockUsePresenceSubscription(...args),
+}));
+
+jest.mock('./useDocumentVisible', () => ({
+ useDocumentVisible: () => mockUseDocumentVisible(),
+}));
+
+function TestHarness({ sessionId }: { sessionId: string | null }) {
+ useAgentSessionPresence(sessionId);
+ return null;
+}
+
+beforeEach(() => {
+ mockUsePresenceSubscription.mockClear();
+ mockUseDocumentVisible.mockClear();
+ mockUseDocumentVisible.mockReturnValue(true);
+});
+
+describe('useAgentSessionPresence', () => {
+ it('subscribes to the exact agent-session context when visible and id is present', () => {
+ renderToString(React.createElement(TestHarness, { sessionId: 'session-123' }));
+
+ expect(mockUsePresenceSubscription).toHaveBeenCalledWith(
+ '/presence/agent-session/session-123',
+ true
+ );
+ });
+
+ it('passes null context and disabled flag when no session id is provided', () => {
+ renderToString(React.createElement(TestHarness, { sessionId: null }));
+
+ expect(mockUsePresenceSubscription).toHaveBeenCalledWith(null, false);
+ });
+
+ it('disables the subscription when the document is not visible', () => {
+ mockUseDocumentVisible.mockReturnValue(false);
+
+ renderToString(React.createElement(TestHarness, { sessionId: 'session-123' }));
+
+ expect(mockUsePresenceSubscription).toHaveBeenCalledWith(
+ '/presence/agent-session/session-123',
+ false
+ );
+ });
+
+ it('sends a new context when the session id changes', () => {
+ renderToString(React.createElement(TestHarness, { sessionId: 'session-123' }));
+
+ expect(mockUsePresenceSubscription).toHaveBeenCalledWith(
+ '/presence/agent-session/session-123',
+ true
+ );
+
+ mockUsePresenceSubscription.mockClear();
+
+ renderToString(React.createElement(TestHarness, { sessionId: 'session-456' }));
+
+ expect(mockUsePresenceSubscription).toHaveBeenCalledWith(
+ '/presence/agent-session/session-456',
+ true
+ );
+ });
+});
diff --git a/apps/web/src/hooks/useAgentSessionPresence.ts b/apps/web/src/hooks/useAgentSessionPresence.ts
new file mode 100644
index 0000000000..38f9074469
--- /dev/null
+++ b/apps/web/src/hooks/useAgentSessionPresence.ts
@@ -0,0 +1,27 @@
+'use client';
+
+import { presenceContextForAgentSession } from '@kilocode/event-service';
+import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks';
+
+import { useDocumentVisible } from './useDocumentVisible';
+
+/**
+ * Exact-session presence for the Cloud Agent / remote CLI session the user
+ * is actively viewing. Holds the `/presence/agent-session/{sessionId}`
+ * context only while the tab is visible and a session is loaded, so
+ * notifications routed through this context are suppressed only when the
+ * user is on that specific session.
+ *
+ * `kiloSessionId` is the Kilo-side session id (not the Cloud Agent
+ * session id) — the wrapper's policy filter and the outbox downstream
+ * already gate what reaches this hook, so we only need to gate on
+ * "is the user looking at this session right now" and document
+ * visibility.
+ */
+export function useAgentSessionPresence(kiloSessionId: string | null) {
+ const visible = useDocumentVisible();
+ usePresenceSubscription(
+ kiloSessionId ? presenceContextForAgentSession(kiloSessionId) : null,
+ Boolean(kiloSessionId) && visible
+ );
+}
diff --git a/packages/event-service/src/__tests__/presence.test.ts b/packages/event-service/src/__tests__/presence.test.ts
new file mode 100644
index 0000000000..d5a89ba45c
--- /dev/null
+++ b/packages/event-service/src/__tests__/presence.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ presenceContextForAgentSession,
+ presenceContextForConversation,
+ presenceContextForInstance,
+ presenceContextForPlatform,
+} from '../presence';
+
+describe('presence contexts', () => {
+ it('builds the platform presence path', () => {
+ expect(presenceContextForPlatform('app')).toBe('/presence/app');
+ expect(presenceContextForPlatform('web')).toBe('/presence/web');
+ });
+
+ it('builds the instance presence path under /presence', () => {
+ expect(presenceContextForInstance('sandbox-1')).toBe('/presence/kiloclaw/sandbox-1');
+ });
+
+ it('builds the conversation presence path under /presence', () => {
+ expect(presenceContextForConversation('sandbox-1', 'conv-1')).toBe(
+ '/presence/kiloclaw/sandbox-1/conv-1'
+ );
+ });
+
+ it('builds the per-agent-session presence path under /presence', () => {
+ expect(presenceContextForAgentSession('ses_1')).toBe('/presence/agent-session/ses_1');
+ });
+
+ it('builds distinct per-session paths for different cliSessionIds', () => {
+ expect(presenceContextForAgentSession('ses_1')).not.toBe(
+ presenceContextForAgentSession('ses_2')
+ );
+ });
+});
diff --git a/packages/event-service/src/presence.ts b/packages/event-service/src/presence.ts
index a267aa667c..3bebae6ac9 100644
--- a/packages/event-service/src/presence.ts
+++ b/packages/event-service/src/presence.ts
@@ -19,3 +19,13 @@ export const presenceContextForInstance = (sandboxId: string) =>
export const presenceContextForConversation = (sandboxId: string, conversationId: string) =>
`/presence${kiloclawConversationContext(sandboxId, conversationId)}` as const;
+
+/**
+ * Exact-session presence context for Cloud Agent / remote CLI sessions.
+ * Subscribed while the user is actively viewing the matching session, so
+ * notifications routed through this context are suppressed only when the
+ * user is on that specific session (not any other session). The
+ * notifications pipeline queries it via event-service.isUserInContext.
+ */
+export const presenceContextForAgentSession = (cliSessionId: string) =>
+ `/presence/agent-session/${cliSessionId}` as const;
diff --git a/packages/notifications/src/rpc-schemas.ts b/packages/notifications/src/rpc-schemas.ts
index ae06ef1008..3ffc339b4d 100644
--- a/packages/notifications/src/rpc-schemas.ts
+++ b/packages/notifications/src/rpc-schemas.ts
@@ -172,6 +172,44 @@ export type SendSessionReadyNotificationResult = z.infer<
typeof sendSessionReadyNotificationOutputSchema
>;
+// ── sendSessionAttentionNotification ────────────────────────────────
+
+/**
+ * Transport-neutral notification intent for actionable human waits on
+ * Cloud Agent / remote CLI sessions. The notifications service uses this
+ * to render fixed, lock-screen-safe copy and to look up exact-session
+ * presence before dispatching the push.
+ *
+ * The producer never supplies a body or title — copy is determined by
+ * `reason` and must not include prompt text, permission metadata, command
+ * args, paths, auth details, or arbitrary upstream messages.
+ */
+export const sessionAttentionReasonSchema = z.enum([
+ 'question',
+ 'permission',
+ 'blocking_suggestion',
+ 'action_required',
+]);
+export type SessionAttentionReason = z.infer;
+
+export const sendSessionAttentionNotificationInputSchema = z.object({
+ userId: z.string().min(1),
+ cliSessionId: z.string().min(1),
+ requestId: z.string().min(1),
+ reason: sessionAttentionReasonSchema,
+});
+export type SendSessionAttentionNotificationParams = z.infer<
+ typeof sendSessionAttentionNotificationInputSchema
+>;
+
+export const sendSessionAttentionNotificationOutputSchema = z.object({
+ dispatched: z.boolean(),
+ reason: z.enum(['missing_session', 'dispatch_failed', 'suppressed_presence']).optional(),
+});
+export type SendSessionAttentionNotificationResult = z.infer<
+ typeof sendSessionAttentionNotificationOutputSchema
+>;
+
// ── dispatchPush (internal DO RPC) ──────────────────────────────────
export const dispatchPushInputSchema = z.object({
diff --git a/packages/notifications/src/rpc-schemas.type-test.ts b/packages/notifications/src/rpc-schemas.type-test.ts
index 0513e670ce..2f7d8fd2e8 100644
--- a/packages/notifications/src/rpc-schemas.type-test.ts
+++ b/packages/notifications/src/rpc-schemas.type-test.ts
@@ -2,6 +2,9 @@ import type {
ScheduledActionEvent,
SendScheduledActionNoticeParams,
SendScheduledActionNoticeResult,
+ SendSessionAttentionNotificationParams,
+ SendSessionAttentionNotificationResult,
+ SessionAttentionReason,
} from './rpc-schemas';
const scheduledActionEvent = 'scheduled_restart_notice' satisfies ScheduledActionEvent;
@@ -25,3 +28,25 @@ const scheduledActionResult = {
void scheduledActionParams;
void scheduledActionResult;
+
+const attentionReasons = [
+ 'question',
+ 'permission',
+ 'blocking_suggestion',
+ 'action_required',
+] as const satisfies readonly SessionAttentionReason[];
+
+const attentionParams = {
+ userId: 'user-1',
+ cliSessionId: 'ses_1',
+ requestId: 'req_1',
+ reason: 'question',
+} satisfies SendSessionAttentionNotificationParams;
+
+const attentionResult = {
+ dispatched: true,
+} satisfies SendSessionAttentionNotificationResult;
+
+void attentionReasons;
+void attentionParams;
+void attentionResult;
diff --git a/packages/session-ingest-contracts/package.json b/packages/session-ingest-contracts/package.json
index 74206aaf6e..042443e725 100644
--- a/packages/session-ingest-contracts/package.json
+++ b/packages/session-ingest-contracts/package.json
@@ -10,13 +10,15 @@
},
"scripts": {
"typecheck": "tsgo --noEmit",
- "lint": "pnpm -w exec oxlint --config .oxlintrc.json packages/session-ingest-contracts/src"
+ "lint": "pnpm -w exec oxlint --config .oxlintrc.json packages/session-ingest-contracts/src",
+ "test": "vitest run"
},
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
- "typescript": "catalog:"
+ "typescript": "catalog:",
+ "vitest": "catalog:"
}
}
diff --git a/packages/session-ingest-contracts/src/rpc-contract.test.ts b/packages/session-ingest-contracts/src/rpc-contract.test.ts
new file mode 100644
index 0000000000..989ccf4650
--- /dev/null
+++ b/packages/session-ingest-contracts/src/rpc-contract.test.ts
@@ -0,0 +1,171 @@
+/**
+ * Tests for the Cloud Agent attention RPC contract.
+ *
+ * The schema intentionally permits only `question` and `permission` reasons —
+ * `blocking_suggestion` is excluded because the wrapper's policy filter
+ * suppresses suggestions before they reach the worker, and the per-session
+ * outbox handles them separately. The contract also rejects any extra body
+ * fields so the producer cannot smuggle prompt text or permission arguments
+ * into the RPC surface.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { recordCloudAgentSessionAttentionSchema, sessionIdSchema } from './index';
+
+const VALID_KILO_SESSION_ID = 'ses_12345678901234567890123456';
+
+function validRaise() {
+ return {
+ kiloUserId: 'usr_owner',
+ kiloSessionId: VALID_KILO_SESSION_ID,
+ requestId: 'req_attn_01',
+ intent: { kind: 'raise', reason: 'question' },
+ };
+}
+
+function validResolve() {
+ return {
+ kiloUserId: 'usr_owner',
+ kiloSessionId: VALID_KILO_SESSION_ID,
+ requestId: 'req_attn_01',
+ intent: { kind: 'resolve', reason: 'question' },
+ };
+}
+
+describe('recordCloudAgentSessionAttentionSchema', () => {
+ it('accepts a valid raise intent with reason "question"', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse(validRaise());
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).toEqual(validRaise());
+ }
+ });
+
+ it('accepts a valid raise intent with reason "permission"', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ intent: { kind: 'raise', reason: 'permission' },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('accepts a valid resolve intent with reason "question"', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse(validResolve());
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).toEqual(validResolve());
+ }
+ });
+
+ it('accepts a valid resolve intent with reason "permission"', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validResolve(),
+ intent: { kind: 'resolve', reason: 'permission' },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('rejects a resolve intent without a reason', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validResolve(),
+ intent: { kind: 'resolve' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects a resolve intent with reason "blocking_suggestion" (wrapper never raises suggestions)', () => {
+ // The Cloud Agent boundary is intentionally narrower than the
+ // session-ingest input schema: the wrapper's policy filter suppresses
+ // suggestions before they reach the worker, so the boundary must not
+ // accept them — the suggestion resolve must be produced only by the
+ // remote CLI / direct session-ingest path.
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validResolve(),
+ intent: { kind: 'resolve', reason: 'blocking_suggestion' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects a resolve intent with an unknown reason string', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validResolve(),
+ intent: { kind: 'resolve', reason: 'action_required' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects a raise intent with the blocking_suggestion reason', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ intent: { kind: 'raise', reason: 'blocking_suggestion' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects an unknown reason string', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ intent: { kind: 'raise', reason: 'urgent' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects a non-`ses_` kilo session ID before the DO is reached', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ kiloSessionId: 'not-a-session',
+ });
+ expect(result.success).toBe(false);
+ expect(sessionIdSchema.safeParse('not-a-session').success).toBe(false);
+ });
+
+ it('rejects a kilo session ID of the wrong length', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ kiloSessionId: 'ses_short',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects an empty requestId', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ requestId: '',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects an empty kiloUserId', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ kiloUserId: '',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects an unknown intent kind', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ intent: { kind: 'cancel' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('strips any raw body or envelope payload from the contract', () => {
+ const result = recordCloudAgentSessionAttentionSchema.safeParse({
+ ...validRaise(),
+ body: 'raw prompt text that must be stripped',
+ promptText: 'another raw field',
+ envelope: { id: 'e_1', payload: 'secret' },
+ permissionArgs: { command: 'rm -rf /' },
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).not.toHaveProperty('body');
+ expect(result.data).not.toHaveProperty('promptText');
+ expect(result.data).not.toHaveProperty('envelope');
+ expect(result.data).not.toHaveProperty('permissionArgs');
+ expect(result.data).toEqual(validRaise());
+ }
+ });
+});
diff --git a/packages/session-ingest-contracts/src/rpc-contract.ts b/packages/session-ingest-contracts/src/rpc-contract.ts
index 95bb454f06..26288baa3f 100644
--- a/packages/session-ingest-contracts/src/rpc-contract.ts
+++ b/packages/session-ingest-contracts/src/rpc-contract.ts
@@ -634,6 +634,37 @@ export type CloudAgentRootSessionMessages = {
};
export type GetCloudAgentRootSessionMessagesResult = CloudAgentRootSessionMessages | null;
+/**
+ * Attention intent for the Cloud Agent boundary RPC. The schema intentionally
+ * permits only `question` and `permission` reasons — `blocking_suggestion` is
+ * excluded because the wrapper's policy filter suppresses suggestions before
+ * they reach the worker, and the per-session outbox handles them separately.
+ *
+ * The resolve intent must carry the same reason the matching raise would
+ * have. Without a reason, an out-of-order resolve arriving before a raise
+ * cannot insert a tombstone with the correct `reason`, and the outbox-level
+ * "preserve original reason" rule has nothing to compare against.
+ */
+const cloudAgentAttentionReasonSchema = z.enum(['question', 'permission']);
+export const cloudAgentAttentionIntentSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('raise'), reason: cloudAgentAttentionReasonSchema }),
+ z.object({ kind: z.literal('resolve'), reason: cloudAgentAttentionReasonSchema }),
+]);
+export type CloudAgentAttentionIntent = z.infer;
+
+export const recordCloudAgentSessionAttentionSchema = z.object({
+ kiloUserId: z.string().min(1),
+ kiloSessionId: sessionIdSchema,
+ requestId: z.string().min(1),
+ intent: cloudAgentAttentionIntentSchema,
+});
+export type RecordCloudAgentSessionAttentionParams = z.input<
+ typeof recordCloudAgentSessionAttentionSchema
+>;
+export type RecordCloudAgentSessionAttentionResult =
+ | { accepted: true }
+ | { accepted: false; reason: 'invalid_input' | 'deleted' };
+
export type SessionIngestRpcMethods = {
createSessionForCloudAgent: (params: CreateSessionForCloudAgentParams) => Promise;
deleteSessionForCloudAgent: (params: DeleteSessionForCloudAgentParams) => Promise;
@@ -649,4 +680,7 @@ export type SessionIngestRpcMethods = {
getCloudAgentRootSessionMessages: (
params: GetCloudAgentRootSessionMessagesParams
) => Promise;
+ recordCloudAgentSessionAttention: (
+ params: RecordCloudAgentSessionAttentionParams
+ ) => Promise;
};
diff --git a/packages/session-ingest-contracts/vitest.config.ts b/packages/session-ingest-contracts/vitest.config.ts
new file mode 100644
index 0000000000..7dd13254e7
--- /dev/null
+++ b/packages/session-ingest-contracts/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['src/**/*.test.ts'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4ab8de0f67..fb8ed374d9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1363,6 +1363,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 5.9.3
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)
packages/trpc:
dependencies:
diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.test.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.test.ts
new file mode 100644
index 0000000000..d87c161b62
--- /dev/null
+++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.test.ts
@@ -0,0 +1,330 @@
+/**
+ * Focused unit tests for the CloudAgentSession attention scheduling helper.
+ *
+ * The orchestrator's task wires attention events through `getIngestHandler` as
+ * a synchronous `ctx.waitUntil` handoff. The helper under test here is
+ * `scheduleCloudAgentAttention(ctx, deps, event)`, which is exactly what
+ * `getIngestHandler` calls. This keeps the private-field-heavy DO out of the
+ * test surface while still proving the full contract:
+ *
+ * - scheduling: the helper is synchronous and hands the IO to ctx.waitUntil;
+ * - safe payload: only kiloUserId, kiloSessionId, requestId, and intent
+ * are forwarded to the binding; no reason/error message leaks;
+ * - missing metadata: the IO is skipped, the binding is never called;
+ * - error privacy: thrown errors and accepted:false outcomes emit a logger
+ * warning that does NOT contain requestId, reason, or error.message;
+ * - duplicates pass through: the same (requestId, intent) is forwarded
+ * every time — the outbox downstream owns dedup.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Mock } from 'vitest';
+
+vi.mock('../logger.js', () => {
+ const logger = {
+ setTags: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ withFields: vi.fn(),
+ };
+ logger.withFields.mockReturnValue(logger);
+ return { logger };
+});
+
+import { logger } from '../logger.js';
+import {
+ scheduleCloudAgentAttention,
+ type CloudAgentAttentionDeps,
+} from './cloud-agent-attention-scheduler.js';
+import type { AttentionEvent } from '../websocket/ingest-attention-classifier.js';
+import type { SessionMetadata } from './session-metadata.js';
+import type {
+ RecordCloudAgentSessionAttentionParams,
+ RecordCloudAgentSessionAttentionResult,
+} from '@kilocode/session-ingest-contracts';
+
+type RecordAttentionFn = (
+ params: RecordCloudAgentSessionAttentionParams
+) => Promise;
+
+type AttentionEnv = CloudAgentAttentionDeps['env'];
+
+const ROOT_KILO_SESSION_ID = 'kilo_root_session';
+
+function makeAttentionEvent(
+ intent: AttentionEvent['intent'],
+ requestId = 'req_1',
+ sourceKiloSessionId: string = ROOT_KILO_SESSION_ID
+): AttentionEvent {
+ return { requestId, intent, sourceKiloSessionId };
+}
+
+function makeMetadata(overrides?: { userId?: string; kiloSessionId?: string }): {
+ identity: { userId?: string };
+ auth: { kiloSessionId?: string };
+} {
+ return {
+ identity: {
+ userId: overrides && 'userId' in overrides ? overrides.userId : 'user_1',
+ },
+ auth: {
+ kiloSessionId:
+ overrides && 'kiloSessionId' in overrides ? overrides.kiloSessionId : ROOT_KILO_SESSION_ID,
+ },
+ };
+}
+
+function makeDeps(overrides?: {
+ metadata?: ReturnType | null;
+ recordCloudAgentSessionAttention?: Mock;
+ sessionId?: string;
+}): {
+ deps: CloudAgentAttentionDeps;
+ recordCloudAgentSessionAttention: Mock;
+ getMetadata: ReturnType;
+} {
+ const recordCloudAgentSessionAttention =
+ overrides?.recordCloudAgentSessionAttention ??
+ vi.fn().mockResolvedValue({ accepted: true });
+ const env: AttentionEnv = {
+ SESSION_INGEST: { recordCloudAgentSessionAttention },
+ };
+ const getMetadata = vi
+ .fn()
+ .mockResolvedValue(
+ overrides?.metadata === null ? null : (overrides?.metadata ?? makeMetadata())
+ );
+ const deps: CloudAgentAttentionDeps = {
+ sessionId: (overrides?.sessionId ?? 'sess_test') as CloudAgentAttentionDeps['sessionId'],
+ getMetadata: getMetadata as unknown as () => Promise,
+ env,
+ };
+ return { deps, recordCloudAgentSessionAttention, getMetadata };
+}
+
+async function flushWaitUntil(promise: Promise): Promise {
+ await promise;
+ await new Promise(resolve => setImmediate(resolve));
+}
+
+function getWarnCalls(): string {
+ const calls = vi.mocked(logger.warn).mock.calls;
+ return calls.map((args: unknown[]) => args.map(String).join(' ')).join('\n');
+}
+
+describe('scheduleCloudAgentAttention', () => {
+ beforeEach(() => {
+ vi.mocked(logger.warn).mockClear();
+ });
+
+ afterEach(() => {
+ vi.mocked(logger.warn).mockClear();
+ });
+
+ it('forwards the safe payload shape to the binding', async () => {
+ const { deps, recordCloudAgentSessionAttention } = makeDeps();
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention(
+ { waitUntil },
+ deps,
+ makeAttentionEvent({ raise: 'question' }, 'req_q')
+ );
+
+ expect(waitUntil).toHaveBeenCalledTimes(1);
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+ expect(recordCloudAgentSessionAttention).toHaveBeenCalledTimes(1);
+ expect(recordCloudAgentSessionAttention).toHaveBeenCalledWith({
+ kiloUserId: 'user_1',
+ kiloSessionId: ROOT_KILO_SESSION_ID,
+ requestId: 'req_q',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ });
+
+ it('maps a permission raise to { kind: "raise", reason: "permission" }', async () => {
+ const { deps, recordCloudAgentSessionAttention } = makeDeps();
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention(
+ { waitUntil },
+ deps,
+ makeAttentionEvent({ raise: 'permission' }, 'req_p')
+ );
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+
+ expect(recordCloudAgentSessionAttention).toHaveBeenCalledWith(
+ expect.objectContaining({ intent: { kind: 'raise', reason: 'permission' } })
+ );
+ });
+
+ it.each([
+ { intent: { resolve: 'question' } as const, reason: 'question' as const },
+ { intent: { resolve: 'permission' } as const, reason: 'permission' as const },
+ ])(
+ 'maps a resolve intent for $reason to { kind: "resolve", reason: "$reason" }',
+ async ({ intent, reason }) => {
+ const { deps, recordCloudAgentSessionAttention } = makeDeps();
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention(
+ { waitUntil },
+ deps,
+ makeAttentionEvent(intent, `req_r_${reason}`)
+ );
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+
+ expect(recordCloudAgentSessionAttention).toHaveBeenCalledWith({
+ kiloUserId: 'user_1',
+ kiloSessionId: ROOT_KILO_SESSION_ID,
+ requestId: `req_r_${reason}`,
+ intent: { kind: 'resolve', reason },
+ });
+ }
+ );
+
+ it('forwards the payload exactly once per call and does not coalesce duplicates', async () => {
+ const { deps, recordCloudAgentSessionAttention } = makeDeps();
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ const event = makeAttentionEvent({ raise: 'question' }, 'req_dup');
+ scheduleCloudAgentAttention({ waitUntil }, deps, event);
+ scheduleCloudAgentAttention({ waitUntil }, deps, event);
+ scheduleCloudAgentAttention({ waitUntil }, deps, event);
+
+ expect(waitUntil).toHaveBeenCalledTimes(3);
+ await Promise.all(waitUntil.mock.calls.map(([p]) => flushWaitUntil(p as Promise)));
+ expect(recordCloudAgentSessionAttention).toHaveBeenCalledTimes(3);
+ expect(recordCloudAgentSessionAttention).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ requestId: 'req_dup',
+ intent: { kind: 'raise', reason: 'question' },
+ })
+ );
+ expect(recordCloudAgentSessionAttention).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
+ requestId: 'req_dup',
+ intent: { kind: 'raise', reason: 'question' },
+ })
+ );
+ });
+
+ it('is a no-op when metadata is missing and never calls the binding', async () => {
+ const { deps, recordCloudAgentSessionAttention } = makeDeps({ metadata: null });
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention({ waitUntil }, deps, makeAttentionEvent({ raise: 'question' }));
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+
+ expect(recordCloudAgentSessionAttention).not.toHaveBeenCalled();
+ expect(logger.warn).toHaveBeenCalled();
+ });
+
+ it('is a no-op when kiloSessionId is missing and never calls the binding', async () => {
+ const { deps, recordCloudAgentSessionAttention } = makeDeps({
+ metadata: makeMetadata({ kiloSessionId: undefined }),
+ });
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention({ waitUntil }, deps, makeAttentionEvent({ raise: 'question' }));
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+
+ expect(recordCloudAgentSessionAttention).not.toHaveBeenCalled();
+ expect(logger.warn).toHaveBeenCalled();
+ });
+
+ it('is a no-op when userId is missing and never calls the binding', async () => {
+ const { deps, recordCloudAgentSessionAttention } = makeDeps({
+ metadata: makeMetadata({ userId: undefined }),
+ });
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention({ waitUntil }, deps, makeAttentionEvent({ raise: 'question' }));
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+
+ expect(recordCloudAgentSessionAttention).not.toHaveBeenCalled();
+ expect(logger.warn).toHaveBeenCalled();
+ });
+
+ it('is a no-op when event sourceKiloSessionId does not match metadata and never calls the binding', async () => {
+ const { deps, recordCloudAgentSessionAttention } = makeDeps();
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention(
+ { waitUntil },
+ deps,
+ makeAttentionEvent({ raise: 'question' }, 'req_child', 'kilo_child_session')
+ );
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+
+ expect(recordCloudAgentSessionAttention).not.toHaveBeenCalled();
+ expect(logger.warn).toHaveBeenCalled();
+ const joined = getWarnCalls();
+ expect(joined).not.toContain('req_child');
+ expect(joined).not.toContain('kilo_child_session');
+ expect(joined).not.toContain(ROOT_KILO_SESSION_ID);
+ });
+
+ it('emits a privacy-safe warn log when the binding throws — no requestId, no reason, no error message', async () => {
+ const secretMessage = 'provider-secret-token=abc123 stack=deep';
+ const recordCloudAgentSessionAttention = vi
+ .fn()
+ .mockRejectedValue(new Error(`Boom: ${secretMessage}`));
+ const { deps } = makeDeps({ recordCloudAgentSessionAttention });
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention(
+ { waitUntil },
+ deps,
+ makeAttentionEvent({ raise: 'question' }, 'req_secret')
+ );
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+
+ expect(logger.warn).toHaveBeenCalled();
+ const joined = getWarnCalls();
+ expect(joined).not.toContain('req_secret');
+ expect(joined).not.toContain('question');
+ expect(joined).not.toContain('permission');
+ expect(joined).not.toContain('resolve');
+ expect(joined).not.toContain(secretMessage);
+ expect(joined).not.toContain('Boom');
+ expect(joined).not.toContain('provider-secret-token');
+ });
+
+ it('emits a privacy-safe warn log when the binding returns accepted:false — no requestId or reason', async () => {
+ const recordCloudAgentSessionAttention = vi
+ .fn()
+ .mockResolvedValue({ accepted: false, reason: 'deleted' });
+ const { deps } = makeDeps({ recordCloudAgentSessionAttention });
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention(
+ { waitUntil },
+ deps,
+ makeAttentionEvent({ raise: 'permission' }, 'req_declined')
+ );
+ await flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise);
+
+ expect(logger.warn).toHaveBeenCalled();
+ const joined = getWarnCalls();
+ expect(joined).not.toContain('req_declined');
+ expect(joined).not.toContain('permission');
+ expect(joined).not.toContain('deleted');
+ });
+
+ it('does not throw to the caller when the binding rejects (non-fatal)', async () => {
+ const recordCloudAgentSessionAttention = vi
+ .fn()
+ .mockRejectedValue(new Error('upstream gone'));
+ const { deps } = makeDeps({ recordCloudAgentSessionAttention });
+ const waitUntil = vi.fn((p: Promise) => p);
+
+ scheduleCloudAgentAttention({ waitUntil }, deps, makeAttentionEvent({ raise: 'question' }));
+
+ await expect(
+ flushWaitUntil(waitUntil.mock.calls[0]?.[0] as Promise)
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts
index 001c1eefb2..c97a46d865 100644
--- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts
+++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts
@@ -21,6 +21,7 @@ import { logger } from '../logger.js';
import { BUILTIN_AGENT_MODES, Limits } from '../schema.js';
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
import migrations from '../../drizzle/migrations';
+import { scheduleCloudAgentAttention } from './cloud-agent-attention-scheduler.js';
import {
createExecutionQueries,
createEventQueries,
@@ -826,6 +827,21 @@ export class CloudAgentSession extends DurableObject {
: params
);
},
+ // Synchronous waitUntil ownership: the ingest callback runs on the
+ // WS request path, so we hand any external IO to ctx.waitUntil and
+ // resolve immediately. Duplicates replay each time; the outbox
+ // downstream of recordCloudAgentSessionAttention owns dedup.
+ onAttentionEvent: event => {
+ scheduleCloudAgentAttention(
+ this.ctx,
+ {
+ sessionId: this.sessionId,
+ getMetadata: () => this.getMetadata(),
+ env: this.env,
+ },
+ event
+ );
+ },
};
this.ingestHandler = createIngestHandler(
diff --git a/services/cloud-agent-next/src/persistence/cloud-agent-attention-scheduler.ts b/services/cloud-agent-next/src/persistence/cloud-agent-attention-scheduler.ts
new file mode 100644
index 0000000000..e4ef4823bf
--- /dev/null
+++ b/services/cloud-agent-next/src/persistence/cloud-agent-attention-scheduler.ts
@@ -0,0 +1,94 @@
+/**
+ * Synchronous scheduling helper for Cloud Agent attention events.
+ *
+ * This module owns the thin adapter that forwards classified wrapper
+ * attention events (question/permission raises and resolves) to the
+ * SESSION_INGEST service. It is kept separate from CloudAgentSession so
+ * the private-field-heavy DO can be excluded from unit tests: the contract
+ * only needs a session identity, a metadata accessor, and the service binding.
+ */
+import type {
+ RecordCloudAgentSessionAttentionParams,
+ RecordCloudAgentSessionAttentionResult,
+} from '@kilocode/session-ingest-contracts';
+import { logger } from '../logger.js';
+import type { AttentionEvent } from '../websocket/ingest-attention-classifier.js';
+import type { SessionId } from '../types/ids.js';
+import type { SessionMetadata } from './session-metadata.js';
+
+export type CloudAgentAttentionDeps = {
+ sessionId: SessionId | undefined;
+ getMetadata: () => Promise;
+ env: {
+ SESSION_INGEST: {
+ recordCloudAgentSessionAttention: (
+ params: RecordCloudAgentSessionAttentionParams
+ ) => Promise;
+ };
+ };
+};
+
+/**
+ * Synchronously hand an attention event off to `ctx.waitUntil`. The async body
+ * fetches session metadata, validates identity, maps the classified intent to
+ * the ingest contract shape, and calls `recordCloudAgentSessionAttention`.
+ *
+ * Failures are non-fatal and privacy-safe: no `requestId`, reason, payload,
+ * or error message is ever logged — only the session identity, which the worker
+ * already knows. Duplicates are forwarded each time; the outbox downstream owns
+ * deduplication.
+ */
+export function scheduleCloudAgentAttention(
+ ctx: { waitUntil: (promise: Promise) => void },
+ deps: CloudAgentAttentionDeps,
+ event: AttentionEvent
+): void {
+ ctx.waitUntil(runCloudAgentAttentionEvent(deps, event));
+}
+
+async function runCloudAgentAttentionEvent(
+ deps: CloudAgentAttentionDeps,
+ event: AttentionEvent
+): Promise {
+ try {
+ const metadata = await deps.getMetadata();
+ if (!metadata) {
+ logger
+ .withFields({ sessionId: deps.sessionId })
+ .warn('Cloud Agent attention event dropped: metadata missing');
+ return;
+ }
+ const kiloUserId = metadata.identity.userId;
+ const kiloSessionId = metadata.auth.kiloSessionId;
+ if (!kiloUserId || !kiloSessionId) {
+ logger
+ .withFields({ sessionId: deps.sessionId })
+ .warn('Cloud Agent attention event dropped: session identity missing');
+ return;
+ }
+ if (event.sourceKiloSessionId !== kiloSessionId) {
+ logger
+ .withFields({ sessionId: deps.sessionId })
+ .warn('Cloud Agent attention event dropped: source session mismatch');
+ return;
+ }
+ const intent =
+ 'raise' in event.intent
+ ? { kind: 'raise' as const, reason: event.intent.raise }
+ : { kind: 'resolve' as const, reason: event.intent.resolve };
+ const result = await deps.env.SESSION_INGEST.recordCloudAgentSessionAttention({
+ kiloUserId,
+ kiloSessionId,
+ requestId: event.requestId,
+ intent,
+ });
+ if (!result.accepted) {
+ logger
+ .withFields({ sessionId: deps.sessionId })
+ .warn('Cloud Agent attention event not accepted');
+ }
+ } catch {
+ // Privacy-safe: do not log requestId, reason, or error message.
+ logger.withFields({ sessionId: deps.sessionId }).warn('Cloud Agent attention event failed');
+ }
+}
diff --git a/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts
new file mode 100644
index 0000000000..9bc5a77ebd
--- /dev/null
+++ b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.test.ts
@@ -0,0 +1,290 @@
+import { describe, it, expect } from 'vitest';
+import { classifyAttentionKilocodeEvent } from './ingest-attention-classifier.js';
+
+const SOURCE_SESSION_ID = 'kilo_session_source';
+
+describe('classifyAttentionKilocodeEvent', () => {
+ describe('raise mappings', () => {
+ it.each([
+ ['question.asked', 'question'],
+ ['permission.asked', 'permission'],
+ ] as const)('%s with nested properties.id raises %s', (eventName, kind) => {
+ const result = classifyAttentionKilocodeEvent({
+ event: eventName,
+ properties: { id: 'req_nested', sessionID: SOURCE_SESSION_ID },
+ });
+ expect(result).toEqual({
+ requestId: 'req_nested',
+ intent: { raise: kind },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ });
+
+ it.each([
+ ['question.asked', 'question'],
+ ['permission.asked', 'permission'],
+ ] as const)('%s with direct data.id fallback raises %s', (eventName, kind) => {
+ const result = classifyAttentionKilocodeEvent({
+ event: eventName,
+ id: 'req_direct',
+ sessionID: SOURCE_SESSION_ID,
+ });
+ expect(result).toEqual({
+ requestId: 'req_direct',
+ intent: { raise: kind },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ });
+
+ it('prefers properties.id over data.id for raise', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.asked',
+ id: 'req_direct',
+ sessionID: 'top_session',
+ properties: { id: 'req_nested', sessionID: SOURCE_SESSION_ID },
+ });
+ expect(result).toEqual({
+ requestId: 'req_nested',
+ intent: { raise: 'question' },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ });
+ });
+
+ describe('resolve mappings', () => {
+ it.each([
+ ['question.replied', 'question'],
+ ['question.rejected', 'question'],
+ ['permission.replied', 'permission'],
+ ] as const)('%s with nested properties.requestID resolves as %s', (eventName, reason) => {
+ const result = classifyAttentionKilocodeEvent({
+ event: eventName,
+ properties: { requestID: 'req_nested', sessionID: SOURCE_SESSION_ID },
+ });
+ expect(result).toEqual({
+ requestId: 'req_nested',
+ intent: { resolve: reason },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ });
+
+ it.each([
+ ['question.replied', 'question'],
+ ['question.rejected', 'question'],
+ ['permission.replied', 'permission'],
+ ] as const)(
+ '%s with direct top-level data.requestID fallback resolves as %s',
+ (eventName, reason) => {
+ const result = classifyAttentionKilocodeEvent({
+ event: eventName,
+ requestID: 'req_direct',
+ sessionID: SOURCE_SESSION_ID,
+ });
+ expect(result).toEqual({
+ requestId: 'req_direct',
+ intent: { resolve: reason },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ }
+ );
+
+ it.each([
+ ['question.replied', 'question'],
+ ['question.rejected', 'question'],
+ ['permission.replied', 'permission'],
+ ] as const)(
+ '%s prefers nested properties.requestID over top-level requestID',
+ (eventName, reason) => {
+ const result = classifyAttentionKilocodeEvent({
+ event: eventName,
+ requestID: 'req_top_requestID',
+ sessionID: 'top_session',
+ id: 'req_top_id',
+ properties: {
+ id: 'req_nested_id',
+ requestID: 'req_nested_requestID',
+ sessionID: SOURCE_SESSION_ID,
+ },
+ });
+ expect(result).toEqual({
+ requestId: 'req_nested_requestID',
+ intent: { resolve: reason },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ }
+ );
+
+ it.each([
+ ['question.replied', 'question'],
+ ['question.rejected', 'question'],
+ ['permission.replied', 'permission'],
+ ] as const)(
+ '%s prefers requestID over id when nested properties carries both',
+ (eventName, reason) => {
+ const result = classifyAttentionKilocodeEvent({
+ event: eventName,
+ properties: {
+ id: 'req_nested_id',
+ requestID: 'req_nested_requestID',
+ sessionID: SOURCE_SESSION_ID,
+ },
+ });
+ expect(result).toEqual({
+ requestId: 'req_nested_requestID',
+ intent: { resolve: reason },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ }
+ );
+
+ it.each(['question.replied', 'question.rejected', 'permission.replied'])(
+ '%s returns null when no resolve id is present anywhere',
+ eventName => {
+ expect(
+ classifyAttentionKilocodeEvent({
+ event: eventName,
+ properties: { sessionID: SOURCE_SESSION_ID },
+ })
+ ).toBeNull();
+ }
+ );
+
+ it.each(['question.replied', 'question.rejected', 'permission.replied'])(
+ '%s returns null when source sessionID is missing even with requestID',
+ eventName => {
+ expect(
+ classifyAttentionKilocodeEvent({
+ event: eventName,
+ properties: { requestID: 'req_missing_session' },
+ })
+ ).toBeNull();
+ }
+ );
+ });
+
+ describe('ignored event types', () => {
+ it.each([
+ 'session.status',
+ 'session.idle',
+ 'session.diff',
+ 'session.completed',
+ 'session.error',
+ 'session.network.asked',
+ 'session.network.restored',
+ 'message.part.delta',
+ 'message.part.updated',
+ 'message.updated',
+ 'message.part.removed',
+ 'session.created',
+ 'session.updated',
+ 'session.turn.close',
+ 'permission.ask', // partial
+ 'question.ask', // partial
+ 'retry.foo',
+ 'error.bar',
+ 'suggestion.shown',
+ 'suggestion.accepted',
+ 'suggestion.dismissed',
+ 'unknown',
+ ])('ignores %s', eventName => {
+ const result = classifyAttentionKilocodeEvent({
+ event: eventName,
+ id: 'req_1',
+ properties: { id: 'req_1' },
+ });
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('missing or invalid ID', () => {
+ it('ignores qualifying event with no id anywhere', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.asked',
+ properties: { sessionID: SOURCE_SESSION_ID },
+ });
+ expect(result).toBeNull();
+ });
+
+ it('ignores qualifying event with empty string id', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.asked',
+ id: '',
+ sessionID: SOURCE_SESSION_ID,
+ properties: { id: '' },
+ });
+ expect(result).toBeNull();
+ });
+
+ it('ignores a resolve event with no requestID anywhere', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.replied',
+ properties: { sessionID: SOURCE_SESSION_ID },
+ });
+ expect(result).toBeNull();
+ });
+
+ it('ignores qualifying event when source sessionID is missing', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.asked',
+ properties: { id: 'req_present' },
+ });
+ expect(result).toBeNull();
+ });
+
+ it('ignores qualifying event when source sessionID is empty', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.asked',
+ properties: { id: 'req_present', sessionID: '' },
+ });
+ expect(result).toBeNull();
+ });
+
+ it('ignores qualifying event with non-string id', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.asked',
+ id: 123,
+ sessionID: SOURCE_SESSION_ID,
+ properties: { id: { foo: 'bar' }, sessionID: SOURCE_SESSION_ID },
+ });
+ expect(result).toBeNull();
+ });
+
+ it('ignores when properties is not an object', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.asked',
+ properties: 'not-an-object',
+ id: 'req_direct',
+ sessionID: SOURCE_SESSION_ID,
+ });
+ expect(result).toEqual({
+ requestId: 'req_direct',
+ intent: { raise: 'question' },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ });
+
+ it('returns null when properties is null and no top-level id', () => {
+ const result = classifyAttentionKilocodeEvent({
+ event: 'question.asked',
+ properties: null,
+ });
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('non-object input', () => {
+ it.each([null, undefined, 'string', 42, true, []])('returns null for %s', value => {
+ expect(classifyAttentionKilocodeEvent(value)).toBeNull();
+ });
+ });
+
+ describe('missing event name', () => {
+ it('returns null when event is missing', () => {
+ expect(classifyAttentionKilocodeEvent({ id: 'req_1' })).toBeNull();
+ });
+
+ it('returns null when event is not a string', () => {
+ expect(classifyAttentionKilocodeEvent({ event: 42, id: 'req_1' })).toBeNull();
+ });
+ });
+});
diff --git a/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts
new file mode 100644
index 0000000000..547c40c30d
--- /dev/null
+++ b/services/cloud-agent-next/src/websocket/ingest-attention-classifier.ts
@@ -0,0 +1,111 @@
+/**
+ * Attention event classifier for kilocode ingest events.
+ *
+ * Classifies question and permission events into raise/resolve intents
+ * for the session attention system. The classifier is pure and synchronous,
+ * returning a stable requestId, intent, and (when present) the source
+ * `kiloSessionId` so the caller can filter out events from child sessions
+ * of this Cloud Agent run, or null for non-attention events.
+ *
+ * Authoritative id source per event family:
+ * - raises (`question.asked`, `permission.asked`) → `properties.id`,
+ * top-level `data.id` is the fallback (the wrapper's real-time shape
+ * spreads properties at the top level of `data`).
+ * - resolves (`question.replied`, `question.rejected`,
+ * `permission.replied`) → `properties.requestID`, top-level
+ * `data.requestID` is the fallback.
+ *
+ * The source `kiloSessionId` is extracted from `properties.sessionID`
+ * (authoritative) or top-level `data.sessionID` (fallback). A qualifying
+ * event without a non-empty source `sessionID` is ignored because the
+ * scheduler cannot verify it belongs to the root session.
+ */
+
+export type AttentionIntent =
+ | { raise: 'question' | 'permission' }
+ | { resolve: 'question' | 'permission' };
+
+export type AttentionEvent = {
+ requestId: string;
+ intent: AttentionIntent;
+ sourceKiloSessionId: string;
+};
+
+const RAISE_KILO_EVENTS: ReadonlyMap = new Map([
+ ['question.asked', 'question'],
+ ['permission.asked', 'permission'],
+]);
+
+const RESOLVE_KILO_EVENTS: ReadonlyMap = new Map([
+ ['question.replied', 'question'],
+ ['question.rejected', 'question'],
+ ['permission.replied', 'permission'],
+]);
+
+function readNonEmptyString(
+ record: Record | null,
+ key: string
+): string | undefined {
+ if (!record) return undefined;
+ const value = record[key];
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+function readSessionIdFromRecord(record: Record | null): string | undefined {
+ if (!record) return undefined;
+ return readNonEmptyString(record, 'sessionID');
+}
+
+/**
+ * Classify a kilocode ingest event for the attention system.
+ *
+ * Returns a stable requestId and intent, or null when the event is not
+ * attention-relevant. The wrapper's real-time shape spreads properties at
+ * the top level of `data`, so we read the event-specific id from the
+ * nested `properties` first and fall back to the top-level field. Resolves
+ * prefer `requestID` over `id` (legacy); raises prefer `id`.
+ *
+ * @param data - The kilocode event data (already validated as an object)
+ * @returns AttentionEvent with requestId and intent, or null
+ */
+export function classifyAttentionKilocodeEvent(data: unknown): AttentionEvent | null {
+ if (typeof data !== 'object' || data === null) return null;
+ const dataRecord = data as Record;
+ const eventName = typeof dataRecord.event === 'string' ? dataRecord.event : undefined;
+ if (!eventName) return null;
+
+ const raiseReason = RAISE_KILO_EVENTS.get(eventName);
+ const resolveReason = RESOLVE_KILO_EVENTS.get(eventName);
+ if (raiseReason === undefined && resolveReason === undefined) return null;
+
+ const intent: AttentionIntent =
+ raiseReason !== undefined
+ ? { raise: raiseReason }
+ : { resolve: resolveReason as 'question' | 'permission' };
+ const isResolve = resolveReason !== undefined;
+
+ const properties =
+ typeof dataRecord.properties === 'object' && dataRecord.properties !== null
+ ? (dataRecord.properties as Record)
+ : null;
+
+ // Authoritative nested id; top-level fallback for the wrapper's
+ // real-time spread shape. Resolves use requestID only — the upstream
+ // contract guarantees `properties.requestID` and never the legacy `id`,
+ // and the wrapper spreads `properties` at the top level so the fallback
+ // still resolves to the same value. Raises use `id`.
+ let requestId: string | undefined;
+ if (isResolve) {
+ requestId =
+ readNonEmptyString(properties, 'requestID') ?? readNonEmptyString(dataRecord, 'requestID');
+ } else {
+ requestId = readNonEmptyString(properties, 'id') ?? readNonEmptyString(dataRecord, 'id');
+ }
+ if (!requestId) return null;
+
+ const sourceKiloSessionId =
+ readSessionIdFromRecord(properties) ?? readSessionIdFromRecord(dataRecord);
+ if (!sourceKiloSessionId) return null;
+
+ return { requestId, intent, sourceKiloSessionId };
+}
diff --git a/services/cloud-agent-next/src/websocket/ingest.test.ts b/services/cloud-agent-next/src/websocket/ingest.test.ts
index 03f64f8e55..d37697185e 100644
--- a/services/cloud-agent-next/src/websocket/ingest.test.ts
+++ b/services/cloud-agent-next/src/websocket/ingest.test.ts
@@ -6,6 +6,7 @@ import type { SessionId } from '../types/ids.js';
const SESSION_ID = 'sess_test' as SessionId;
const WRAPPER_RUN_ID = 'wr_test_basic';
+const SOURCE_SESSION_ID = 'kilo_session_test';
const itWithWebSocketPair = typeof WebSocketPair === 'undefined' ? it.skip : it;
function createFakeState() {
@@ -266,6 +267,321 @@ describe('createIngestHandler', () => {
);
});
+ // --- kilocode events: attention event classification ---
+
+ function makeKilocodeData(
+ eventName: string,
+ properties: Record,
+ options: { spreadId?: boolean } = {}
+ ): Record {
+ // Real-time shape spreads properties at the top level of `data`;
+ // snapshot/replay shape nests them only under `properties`.
+ const props = { sessionID: SOURCE_SESSION_ID, ...properties };
+ return options.spreadId
+ ? { ...props, event: eventName, type: eventName, properties: props }
+ : { event: eventName, type: eventName, properties: props };
+ }
+
+ function makeKilocodeMessageFromData(data: Record): string {
+ return JSON.stringify({
+ streamEventType: 'kilocode',
+ data,
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ function createAttentionContext(): {
+ doContext: IngestDOContext;
+ onAttentionEvent: ReturnType;
+ } {
+ const doContext = createFakeDOContext();
+ const onAttentionEvent = vi.fn();
+ return { doContext: { ...doContext, onAttentionEvent }, onAttentionEvent };
+ }
+
+ async function runAttentionMessage(
+ doContext: IngestDOContext,
+ data: Record
+ ): Promise<{ broadcastFn: ReturnType; eventQueries: EventQueries }> {
+ const eventQueries = createFakeEventQueries();
+ const broadcastFn = vi.fn();
+ const handler = createIngestHandler(
+ createFakeState(),
+ eventQueries,
+ SESSION_ID,
+ broadcastFn,
+ doContext
+ );
+ const ws = createFakeWebSocket(makeAttachment());
+ await handler.handleIngestMessage(ws, makeKilocodeMessageFromData(data));
+ return { broadcastFn, eventQueries };
+ }
+
+ it.each([
+ { eventName: 'question.asked', expected: { raise: 'question' } },
+ { eventName: 'permission.asked', expected: { raise: 'permission' } },
+ ])('raise $eventName with nested properties.id', async ({ eventName, expected }) => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ const { broadcastFn, eventQueries } = await runAttentionMessage(
+ doContext,
+ makeKilocodeData(eventName, { id: 'req_nested' })
+ );
+ expect(onAttentionEvent).toHaveBeenCalledTimes(1);
+ expect(onAttentionEvent).toHaveBeenCalledWith({
+ requestId: 'req_nested',
+ intent: expected,
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ expect(broadcastFn).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 0, stream_event_type: 'kilocode' })
+ );
+ expect(eventQueries.insert).not.toHaveBeenCalled();
+ expect(eventQueries.upsert).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ { eventName: 'question.asked', expected: { raise: 'question' } },
+ { eventName: 'permission.asked', expected: { raise: 'permission' } },
+ ])(
+ 'raise $eventName with direct top-level data.id (real-time spread shape)',
+ async ({ eventName, expected }) => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ await runAttentionMessage(
+ doContext,
+ makeKilocodeData(eventName, { id: 'req_direct' }, { spreadId: true })
+ );
+ expect(onAttentionEvent).toHaveBeenCalledTimes(1);
+ expect(onAttentionEvent).toHaveBeenCalledWith({
+ requestId: 'req_direct',
+ intent: expected,
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ }
+ );
+
+ it.each([
+ ['question.replied', 'question'],
+ ['question.rejected', 'question'],
+ ['permission.replied', 'permission'],
+ ] as const)('resolve %s with nested properties.requestID', async (eventName, reason) => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ const { broadcastFn } = await runAttentionMessage(
+ doContext,
+ makeKilocodeData(eventName, { requestID: 'req_nested' })
+ );
+ expect(onAttentionEvent).toHaveBeenCalledTimes(1);
+ expect(onAttentionEvent).toHaveBeenCalledWith({
+ requestId: 'req_nested',
+ intent: { resolve: reason },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ expect(broadcastFn).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 0, stream_event_type: 'kilocode' })
+ );
+ });
+
+ it.each([
+ ['question.replied', 'question'],
+ ['question.rejected', 'question'],
+ ['permission.replied', 'permission'],
+ ] as const)(
+ 'resolve %s with direct top-level data.requestID (real-time spread shape)',
+ async (eventName, reason) => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ await runAttentionMessage(
+ doContext,
+ makeKilocodeData(eventName, { requestID: 'req_direct' }, { spreadId: true })
+ );
+ expect(onAttentionEvent).toHaveBeenCalledTimes(1);
+ expect(onAttentionEvent).toHaveBeenCalledWith({
+ requestId: 'req_direct',
+ intent: { resolve: reason },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ }
+ );
+
+ it.each([
+ 'session.status',
+ 'session.idle',
+ 'session.diff',
+ 'session.completed',
+ 'session.error',
+ 'session.network.asked',
+ 'message.part.delta',
+ 'message.part.updated',
+ 'message.updated',
+ 'message.part.removed',
+ 'session.created',
+ 'session.updated',
+ 'session.turn.close',
+ ])('does not invoke onAttentionEvent for ignored %s', async eventName => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ await runAttentionMessage(
+ doContext,
+ makeKilocodeData(eventName, { id: 'req_1' }, { spreadId: true })
+ );
+ expect(onAttentionEvent).not.toHaveBeenCalled();
+ });
+
+ it('does not invoke onAttentionEvent when qualifying event has no id', async () => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ const { broadcastFn } = await runAttentionMessage(doContext, {
+ event: 'question.asked',
+ type: 'question.asked',
+ properties: {},
+ });
+ expect(onAttentionEvent).not.toHaveBeenCalled();
+ expect(broadcastFn).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 0, stream_event_type: 'kilocode' })
+ );
+ });
+
+ it('does not invoke onAttentionEvent when qualifying event has empty string id', async () => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ await runAttentionMessage(doContext, {
+ event: 'question.asked',
+ type: 'question.asked',
+ id: '',
+ properties: { id: '' },
+ });
+ expect(onAttentionEvent).not.toHaveBeenCalled();
+ });
+
+ it('invokes callback while broadcast payload remains unchanged', async () => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ const eventQueries = createFakeEventQueries();
+ const broadcastFn = vi.fn();
+ const handler = createIngestHandler(
+ createFakeState(),
+ eventQueries,
+ SESSION_ID,
+ broadcastFn,
+ doContext
+ );
+ const ws = createFakeWebSocket(makeAttachment());
+ const data = makeKilocodeData('question.asked', { id: 'req_nested' }, { spreadId: true });
+ await handler.handleIngestMessage(ws, makeKilocodeMessageFromData(data));
+ const baselinePayload = JSON.stringify(data);
+ expect(onAttentionEvent).toHaveBeenCalledTimes(1);
+ expect(onAttentionEvent).toHaveBeenCalledWith({
+ requestId: 'req_nested',
+ intent: { raise: 'question' },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ const broadcastCall = vi.mocked(broadcastFn).mock.calls[0]?.[0];
+ expect(broadcastCall).toBeDefined();
+ expect(broadcastCall?.id).toBe(0);
+ expect(broadcastCall?.stream_event_type).toBe('kilocode');
+ // Broadcast payload remains exactly what sanitization produced —
+ // adding the attention callback did not change what /stream clients receive.
+ expect(broadcastCall?.payload).toBe(baselinePayload);
+ expect(eventQueries.insert).not.toHaveBeenCalled();
+ expect(eventQueries.upsert).not.toHaveBeenCalled();
+ });
+
+ it('invokes callback on duplicate same qualifying event (outbox owns dedup)', async () => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ const eventQueries = createFakeEventQueries();
+ const broadcastFn = vi.fn();
+ const handler = createIngestHandler(
+ createFakeState(),
+ eventQueries,
+ SESSION_ID,
+ broadcastFn,
+ doContext
+ );
+ const ws = createFakeWebSocket(makeAttachment());
+ const message = makeKilocodeMessageFromData(
+ makeKilocodeData('question.asked', { id: 'req_dup' }, { spreadId: true })
+ );
+
+ await handler.handleIngestMessage(ws, message);
+ await handler.handleIngestMessage(ws, message);
+
+ expect(onAttentionEvent).toHaveBeenCalledTimes(2);
+ expect(onAttentionEvent).toHaveBeenNthCalledWith(1, {
+ requestId: 'req_dup',
+ intent: { raise: 'question' },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ expect(onAttentionEvent).toHaveBeenNthCalledWith(2, {
+ requestId: 'req_dup',
+ intent: { raise: 'question' },
+ sourceKiloSessionId: SOURCE_SESSION_ID,
+ });
+ });
+
+ it('does not invoke callback when no onAttentionEvent is registered', async () => {
+ const doContext = createFakeDOContext(); // no onAttentionEvent
+ const eventQueries = createFakeEventQueries();
+ const broadcastFn = vi.fn();
+ const handler = createIngestHandler(
+ createFakeState(),
+ eventQueries,
+ SESSION_ID,
+ broadcastFn,
+ doContext
+ );
+ const ws = createFakeWebSocket(makeAttachment());
+ await handler.handleIngestMessage(
+ ws,
+ makeKilocodeMessageFromData(
+ makeKilocodeData('question.asked', { id: 'req_1' }, { spreadId: true })
+ )
+ );
+ expect(broadcastFn).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 0, stream_event_type: 'kilocode' })
+ );
+ expect(eventQueries.insert).not.toHaveBeenCalled();
+ });
+
+ it('does not invoke onAttentionEvent when source sessionID is missing', async () => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ const { broadcastFn } = await runAttentionMessage(doContext, {
+ event: 'question.asked',
+ type: 'question.asked',
+ properties: { id: 'req_no_session' },
+ });
+ expect(onAttentionEvent).not.toHaveBeenCalled();
+ expect(broadcastFn).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 0, stream_event_type: 'kilocode' })
+ );
+ });
+
+ it('invokes callback with child source sessionID while broadcast remains unchanged', async () => {
+ const { doContext, onAttentionEvent } = createAttentionContext();
+ const eventQueries = createFakeEventQueries();
+ const broadcastFn = vi.fn();
+ const handler = createIngestHandler(
+ createFakeState(),
+ eventQueries,
+ SESSION_ID,
+ broadcastFn,
+ doContext
+ );
+ const ws = createFakeWebSocket(makeAttachment());
+ const childSessionId = 'kilo_child_session';
+ const data = makeKilocodeData(
+ 'question.asked',
+ { id: 'req_child', sessionID: childSessionId },
+ { spreadId: true }
+ );
+ await handler.handleIngestMessage(ws, makeKilocodeMessageFromData(data));
+ const baselinePayload = JSON.stringify(data);
+ expect(onAttentionEvent).toHaveBeenCalledTimes(1);
+ expect(onAttentionEvent).toHaveBeenCalledWith({
+ requestId: 'req_child',
+ intent: { raise: 'question' },
+ sourceKiloSessionId: childSessionId,
+ });
+ const broadcastCall = vi.mocked(broadcastFn).mock.calls[0]?.[0];
+ expect(broadcastCall).toBeDefined();
+ expect(broadcastCall?.payload).toBe(baselinePayload);
+ expect(eventQueries.insert).not.toHaveBeenCalled();
+ expect(eventQueries.upsert).not.toHaveBeenCalled();
+ });
+
// --- non-kilocode: plain insert path (PERSISTED_STREAM_EVENT_TYPES) ---
it.each(['complete', 'interrupted', 'error', 'autocommit_started', 'autocommit_completed'])(
diff --git a/services/cloud-agent-next/src/websocket/ingest.ts b/services/cloud-agent-next/src/websocket/ingest.ts
index ab4b564b40..9a6af4483a 100644
--- a/services/cloud-agent-next/src/websocket/ingest.ts
+++ b/services/cloud-agent-next/src/websocket/ingest.ts
@@ -36,6 +36,10 @@ import type { WrapperSupervisor, WrapperTerminalEvent } from '../session/wrapper
import type { TerminalizeParams } from '../session/session-message-state.js';
import { classifyAssistantFailureMessage } from '../session/safe-failure-projection.js';
import { parseModelNotFoundRuntimeDiagnostics } from '../shared/runtime-model-diagnostics.js';
+import {
+ classifyAttentionKilocodeEvent,
+ type AttentionEvent,
+} from './ingest-attention-classifier.js';
// ---------------------------------------------------------------------------
// Ingest Attachment
@@ -253,6 +257,13 @@ export type IngestDOContext = {
) => Promise;
/** Persist the slash-command catalog so connecting clients can be hydrated. */
setAvailableCommands: (commands: SlashCommandInfo[]) => Promise;
+ /**
+ * Optional callback invoked for qualifying question/permission kilocode
+ * events. Synchronous or returns void; CloudAgentSession later owns
+ * waitUntil for any external notification IO. Duplicate snapshots replay
+ * the callback each time; the outbox owns dedup.
+ */
+ onAttentionEvent?: (event: AttentionEvent) => void;
};
// ---------------------------------------------------------------------------
@@ -731,6 +742,10 @@ export function createIngestHandler(
}
);
ws.serializeAttachment(attachment);
+ const attention = classifyAttentionKilocodeEvent(ingestEvent.data);
+ if (attention) {
+ doContext.onAttentionEvent?.(attention);
+ }
} else {
console.warn('Invalid kilocode event payload');
}
diff --git a/services/notifications/src/index.ts b/services/notifications/src/index.ts
index e8bce7b0a8..1656de8e2f 100644
--- a/services/notifications/src/index.ts
+++ b/services/notifications/src/index.ts
@@ -17,6 +17,8 @@ import {
type DispatchPushOutcome,
type SendCloudAgentSessionNotificationParams,
type SendCloudAgentSessionNotificationResult,
+ type SendSessionAttentionNotificationParams,
+ type SendSessionAttentionNotificationResult,
type SendSessionReadyNotificationParams,
type SendSessionReadyNotificationResult,
type SendInstanceLifecycleNotificationParams,
@@ -31,6 +33,7 @@ import {
import { authMiddleware, type AuthContext } from './auth';
import {
dispatchCloudAgentSessionPush,
+ dispatchSessionAttentionPush,
dispatchSessionReadyPush,
type DispatchCloudAgentSessionPushDeps,
} from './lib/cloud-agent-session-push';
@@ -237,6 +240,12 @@ export class NotificationsService extends WorkerEntrypoint {
return dispatchSessionReadyPush(params, this.cloudAgentSessionPushDeps());
}
+ async sendSessionAttentionNotification(
+ params: SendSessionAttentionNotificationParams
+ ): Promise {
+ return dispatchSessionAttentionPush(params, this.cloudAgentSessionPushDeps());
+ }
+
private cloudAgentSessionPushDeps(): DispatchCloudAgentSessionPushDeps {
let db: ReturnType | undefined;
const getDbForCall = () => (db ??= getWorkerDb(this.env.HYPERDRIVE.connectionString));
diff --git a/services/notifications/src/lib/cloud-agent-session-push.ts b/services/notifications/src/lib/cloud-agent-session-push.ts
index ce11537916..fcf4ee248f 100644
--- a/services/notifications/src/lib/cloud-agent-session-push.ts
+++ b/services/notifications/src/lib/cloud-agent-session-push.ts
@@ -1,13 +1,20 @@
-import { presenceContextForPlatform } from '@kilocode/event-service';
+import {
+ presenceContextForAgentSession,
+ presenceContextForPlatform,
+} from '@kilocode/event-service';
import {
sendCloudAgentSessionNotificationInputSchema,
+ sendSessionAttentionNotificationInputSchema,
sendSessionReadyNotificationInputSchema,
type DispatchPushInput,
type DispatchPushOutcome,
type SendCloudAgentSessionNotificationParams,
type SendCloudAgentSessionNotificationResult,
+ type SendSessionAttentionNotificationParams,
+ type SendSessionAttentionNotificationResult,
type SendSessionReadyNotificationParams,
type SendSessionReadyNotificationResult,
+ type SessionAttentionReason,
} from '@kilocode/notifications';
type CloudAgentNotificationSession = {
@@ -31,22 +38,36 @@ type SessionPushContent = {
body: string;
};
-async function dispatchSessionPush(
+/**
+ * Resolve the session for `cliSessionId` and confirm the caller still has
+ * current organization membership if the session belongs to an org. Returns
+ * `null` when the session is absent or access has been revoked, which the
+ * callers translate into a terminal `missing_session` result.
+ */
+async function loadOwnedSession(
userId: string,
cliSessionId: string,
- buildContent: (session: CloudAgentNotificationSession) => SessionPushContent,
deps: DispatchCloudAgentSessionPushDeps
-): Promise {
+): Promise {
const session = await deps.getSession(userId, cliSessionId);
-
- if (!session) {
- return { dispatched: false, reason: 'missing_session' };
- }
-
+ if (!session) return null;
if (
session.organizationId &&
!(await deps.hasOrganizationAccess(userId, session.organizationId))
) {
+ return null;
+ }
+ return session;
+}
+
+async function dispatchSessionPush(
+ userId: string,
+ cliSessionId: string,
+ buildContent: (session: CloudAgentNotificationSession) => SessionPushContent,
+ deps: DispatchCloudAgentSessionPushDeps
+): Promise {
+ const session = await loadOwnedSession(userId, cliSessionId, deps);
+ if (!session) {
return { dispatched: false, reason: 'missing_session' };
}
@@ -112,3 +133,75 @@ export async function dispatchSessionReadyPush(
deps
);
}
+
+// ── Session attention (actionable human waits) ──────────────────────
+
+/**
+ * Fixed, lock-screen-safe copy per attention reason. Producers must never
+ * supply a body or title — the notifications service rejects caller-provided
+ * text to keep pushes safe.
+ */
+const SESSION_ATTENTION_COPY: Record = {
+ question: {
+ title: 'Kilo session needs your input',
+ body: 'Your Kilo session is asking a question',
+ },
+ permission: {
+ title: 'Kilo session needs permission',
+ body: 'Your Kilo session is waiting for permission to continue',
+ },
+ blocking_suggestion: {
+ title: 'Kilo session needs a decision',
+ body: 'Your Kilo session has a suggestion that needs your review',
+ },
+ action_required: {
+ title: 'Kilo session needs you',
+ body: 'Your Kilo session is waiting for you to take action',
+ },
+};
+
+export type DispatchSessionAttentionPushDeps = DispatchCloudAgentSessionPushDeps;
+
+/**
+ * Notification sent when a Cloud Agent / remote CLI session hits a
+ * human-actionable wait (question, permission, blocking suggestion, or
+ * other action-required state). The presence context is the exact session,
+ * so the push is suppressed only when the user is actively viewing that
+ * session — not the whole app. The idempotency key includes the stable
+ * requestId so retries on the same upstream request collapse, but distinct
+ * requests stay independent.
+ */
+export async function dispatchSessionAttentionPush(
+ params: SendSessionAttentionNotificationParams,
+ deps: DispatchSessionAttentionPushDeps
+): Promise {
+ const parsed = sendSessionAttentionNotificationInputSchema.parse(params);
+ const copy = SESSION_ATTENTION_COPY[parsed.reason];
+ const session = await loadOwnedSession(parsed.userId, parsed.cliSessionId, deps);
+ if (!session) {
+ return { dispatched: false, reason: 'missing_session' };
+ }
+
+ const outcome = await deps.dispatchPush({
+ userId: parsed.userId,
+ presenceContext: presenceContextForAgentSession(parsed.cliSessionId),
+ idempotencyKey: `cloud-agent:${parsed.cliSessionId}:attention:${parsed.reason}:${parsed.requestId}`,
+ badge: null,
+ push: {
+ title: copy.title,
+ body: copy.body,
+ data: { type: 'cloud_agent_session', cliSessionId: parsed.cliSessionId },
+ sound: 'default',
+ priority: 'high',
+ },
+ } satisfies DispatchPushInput);
+
+ if (outcome.kind === 'suppressed_presence') {
+ return { dispatched: false, reason: 'suppressed_presence' };
+ }
+ if (outcome.kind === 'failed') {
+ return { dispatched: false, reason: 'dispatch_failed' };
+ }
+
+ return { dispatched: true };
+}
diff --git a/services/notifications/src/lib/notifications-service-cloud-agent.test.ts b/services/notifications/src/lib/notifications-service-cloud-agent.test.ts
index a0bd1f9bdf..a3844a2184 100644
--- a/services/notifications/src/lib/notifications-service-cloud-agent.test.ts
+++ b/services/notifications/src/lib/notifications-service-cloud-agent.test.ts
@@ -4,6 +4,7 @@ import { type DispatchPushInput, type DispatchPushOutcome } from '@kilocode/noti
import {
dispatchCloudAgentSessionPush,
+ dispatchSessionAttentionPush,
dispatchSessionReadyPush,
type DispatchCloudAgentSessionPushDeps,
} from './cloud-agent-session-push';
@@ -277,3 +278,242 @@ describe('dispatchSessionReadyPush', () => {
expect(result).toEqual({ dispatched: false, reason: 'dispatch_failed' });
});
});
+
+describe('dispatchSessionAttentionPush', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ mockDispatchPush.mockResolvedValue({ kind: 'delivered', tokenCount: 1 });
+ });
+
+ it.each([
+ ['question', 'Kilo session needs your input', 'Your Kilo session is asking a question'],
+ [
+ 'permission',
+ 'Kilo session needs permission',
+ 'Your Kilo session is waiting for permission to continue',
+ ],
+ [
+ 'blocking_suggestion',
+ 'Kilo session needs a decision',
+ 'Your Kilo session has a suggestion that needs your review',
+ ],
+ [
+ 'action_required',
+ 'Kilo session needs you',
+ 'Your Kilo session is waiting for you to take action',
+ ],
+ ] as const)(
+ 'dispatches fixed safe copy for reason=%s with exact-session presence',
+ async (reason, title, body) => {
+ const deps = createDeps({ session: { title: 'Title', organizationId: null } });
+
+ const result = await dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason },
+ deps
+ );
+
+ expect(result).toEqual({ dispatched: true });
+ expect(mockDispatchPush).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'user-1',
+ presenceContext: '/presence/agent-session/ses_1',
+ idempotencyKey: `cloud-agent:ses_1:attention:${reason}:req_1`,
+ badge: null,
+ push: {
+ title,
+ body,
+ data: { type: 'cloud_agent_session', cliSessionId: 'ses_1' },
+ sound: 'default',
+ priority: 'high',
+ },
+ })
+ );
+ }
+ );
+
+ it('ignores any caller-provided body and only uses fixed copy', async () => {
+ const deps = createDeps({ session: { title: 'Title', organizationId: null } });
+
+ // The schema has no text/body field and strips unknown keys, so any
+ // caller-provided text is silently dropped and the fixed copy is used.
+ await expect(
+ dispatchSessionAttentionPush(
+ {
+ userId: 'user-1',
+ cliSessionId: 'ses_1',
+ requestId: 'req_1',
+ reason: 'question',
+ // @ts-expect-error -- callers must not be able to inject free-form copy
+ body: 'leak this',
+ },
+ deps
+ )
+ ).resolves.toEqual({ dispatched: true });
+
+ const call = mockDispatchPush.mock.calls[0]?.[0];
+ expect(call?.push.body).toBe('Your Kilo session is asking a question');
+ expect(call?.push.body).not.toContain('leak this');
+ });
+
+ it('uses distinct idempotency keys for distinct requests and reasons', async () => {
+ const deps = createDeps();
+
+ await dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' },
+ deps
+ );
+ await dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_2', reason: 'question' },
+ deps
+ );
+ await dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'permission' },
+ deps
+ );
+
+ expect(mockDispatchPush).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:question:req_1' })
+ );
+ expect(mockDispatchPush).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:question:req_2' })
+ );
+ expect(mockDispatchPush).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:permission:req_1' })
+ );
+ });
+
+ it('reuses the same idempotency key when the same request is retried', async () => {
+ const deps = createDeps();
+
+ const params = {
+ userId: 'user-1',
+ cliSessionId: 'ses_1',
+ requestId: 'req_dup',
+ reason: 'question',
+ } as const;
+ await dispatchSessionAttentionPush(params, deps);
+ await dispatchSessionAttentionPush(params, deps);
+
+ expect(mockDispatchPush).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:question:req_dup' })
+ );
+ expect(mockDispatchPush).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ idempotencyKey: 'cloud-agent:ses_1:attention:question:req_dup' })
+ );
+ });
+
+ it('returns missing_session without dispatching when the session row is absent', async () => {
+ const deps = createDeps({ session: null });
+
+ const result = await dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_missing', requestId: 'req_1', reason: 'question' },
+ deps
+ );
+
+ expect(result).toEqual({ dispatched: false, reason: 'missing_session' });
+ expect(mockDispatchPush).not.toHaveBeenCalled();
+ });
+
+ it('returns missing_session when org membership has been revoked', async () => {
+ const deps = createDeps({
+ session: { title: 'Org session', organizationId: 'org-1' },
+ hasOrganizationAccess: false,
+ });
+
+ const result = await dispatchSessionAttentionPush(
+ {
+ userId: 'former-member',
+ cliSessionId: 'ses_org',
+ requestId: 'req_1',
+ reason: 'permission',
+ },
+ deps
+ );
+
+ expect(result).toEqual({ dispatched: false, reason: 'missing_session' });
+ expect(mockDispatchPush).not.toHaveBeenCalled();
+ expect(deps.hasOrganizationAccess).toHaveBeenCalledWith('former-member', 'org-1');
+ });
+
+ it('propagates presence-suppressed outcome from the recipient DO', async () => {
+ const deps = createDeps();
+ mockDispatchPush.mockResolvedValue({ kind: 'suppressed_presence' });
+
+ const result = await dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' },
+ deps
+ );
+
+ expect(result).toEqual({ dispatched: false, reason: 'suppressed_presence' });
+ });
+
+ it('propagates dispatch failures from the recipient notification channel', async () => {
+ const deps = createDeps();
+ mockDispatchPush.mockResolvedValue({ kind: 'failed', error: 'Expo unavailable' });
+
+ const result = await dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' },
+ deps
+ );
+
+ expect(result).toEqual({ dispatched: false, reason: 'dispatch_failed' });
+ });
+
+ it('treats delivered, duplicate, and no_tokens as dispatched', async () => {
+ const outcomes: DispatchPushOutcome[] = [
+ { kind: 'delivered', tokenCount: 2 },
+ { kind: 'duplicate' },
+ { kind: 'no_tokens' },
+ ];
+ for (const outcome of outcomes) {
+ const deps = createDeps();
+ mockDispatchPush.mockResolvedValueOnce(outcome);
+ const result = await dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' },
+ deps
+ );
+ expect(result).toEqual({ dispatched: true });
+ }
+ });
+
+ it('rejects invalid params (empty IDs, unknown reason) before reading session data', async () => {
+ const deps = createDeps();
+
+ await expect(
+ dispatchSessionAttentionPush(
+ { userId: '', cliSessionId: 'ses_1', requestId: 'req_1', reason: 'question' },
+ deps
+ )
+ ).rejects.toThrow();
+ await expect(
+ dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: '', requestId: 'req_1', reason: 'question' },
+ deps
+ )
+ ).rejects.toThrow();
+ await expect(
+ dispatchSessionAttentionPush(
+ { userId: 'user-1', cliSessionId: 'ses_1', requestId: '', reason: 'question' },
+ deps
+ )
+ ).rejects.toThrow();
+ await expect(
+ dispatchSessionAttentionPush(
+ {
+ userId: 'user-1',
+ cliSessionId: 'ses_1',
+ requestId: 'req_1',
+ // @ts-expect-error -- unknown reason must be rejected
+ reason: 'unknown',
+ },
+ deps
+ )
+ ).rejects.toThrow();
+ expect(deps.getSession).not.toHaveBeenCalled();
+ });
+});
diff --git a/services/session-ingest/drizzle/0003_session_attention_outbox.sql b/services/session-ingest/drizzle/0003_session_attention_outbox.sql
new file mode 100644
index 0000000000..6be72cda7f
--- /dev/null
+++ b/services/session-ingest/drizzle/0003_session_attention_outbox.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS `attention_outbox` (
+ `request_id` text PRIMARY KEY NOT NULL,
+ `reason` text NOT NULL,
+ `status` text NOT NULL DEFAULT 'pending',
+ `attempt_count` integer NOT NULL DEFAULT 0,
+ `next_attempt_at` integer,
+ `last_error` text,
+ `raised_at` integer NOT NULL,
+ `resolved_at` integer
+);
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS `attention_outbox_pending_idx` ON `attention_outbox` (`status`, `next_attempt_at`);
diff --git a/services/session-ingest/drizzle/meta/0003_snapshot.json b/services/session-ingest/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000000..c5b9b71061
--- /dev/null
+++ b/services/session-ingest/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,196 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "session-attention-outbox",
+ "prevId": "7f8bb7f8-414e-4875-bab2-3e6ce4de7a40",
+ "tables": {
+ "ingest_items": {
+ "name": "ingest_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_type": {
+ "name": "item_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_data": {
+ "name": "item_data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_data_r2_key": {
+ "name": "item_data_r2_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ingested_at": {
+ "name": "ingested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ingest_items_item_id_unique": {
+ "name": "ingest_items_item_id_unique",
+ "columns": ["item_id"],
+ "isUnique": true
+ },
+ "ingest_items_ingested_at_id_idx": {
+ "name": "ingest_items_ingested_at_id_idx",
+ "columns": ["ingested_at", "id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ingest_meta": {
+ "name": "ingest_meta",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "attention_outbox": {
+ "name": "attention_outbox",
+ "columns": {
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "raised_at": {
+ "name": "raised_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "attention_outbox_pending_idx": {
+ "name": "attention_outbox_pending_idx",
+ "columns": ["status", "next_attempt_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/services/session-ingest/drizzle/meta/_journal.json b/services/session-ingest/drizzle/meta/_journal.json
index 15e9d70a69..2e071b906d 100644
--- a/services/session-ingest/drizzle/meta/_journal.json
+++ b/services/session-ingest/drizzle/meta/_journal.json
@@ -22,6 +22,13 @@
"when": 1780499751344,
"tag": "0002_watery_venus",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "6",
+ "when": 1781800000000,
+ "tag": "0003_session_attention_outbox",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/services/session-ingest/drizzle/migrations.js b/services/session-ingest/drizzle/migrations.js
index 36e171cb3f..31ddab7ce8 100644
--- a/services/session-ingest/drizzle/migrations.js
+++ b/services/session-ingest/drizzle/migrations.js
@@ -2,6 +2,7 @@ import journal from './meta/_journal.json';
import m0000 from './0000_redundant_slyde.sql';
import m0001 from './0001_common_blackheart.sql';
import m0002 from './0002_watery_venus.sql';
+import m0003 from './0003_session_attention_outbox.sql';
export default {
journal,
@@ -9,5 +10,6 @@ export default {
m0000,
m0001,
m0002,
+ m0003,
},
};
diff --git a/services/session-ingest/src/attention-outbox-store.ts b/services/session-ingest/src/attention-outbox-store.ts
new file mode 100644
index 0000000000..142fc2191e
--- /dev/null
+++ b/services/session-ingest/src/attention-outbox-store.ts
@@ -0,0 +1,651 @@
+/**
+ * Durable per-session outbox for human-attention pushes.
+ *
+ * One row per stable upstream request id (a question id, permission id, or
+ * blocking-suggestion id). Rows are created lazily on the first `*.asked` and
+ * transitioned to a terminal status (`dispatched`, `suppressed_presence`,
+ * `missing_session`, `failed`, or `resolved`) by the alarm dispatch loop.
+ *
+ * Concurrency: this module is called from inside `SessionIngestDO` where the
+ * platform serializes input gate access per DO, so individual statements are
+ * safe to issue back-to-back without an explicit transaction. The DO alarm
+ * fires single-threaded per DO, which is what makes the dispatch loop's
+ * `pending → in_flight → terminal` sequence atomic at the row level.
+ *
+ * The store never stores event bodies, prompt text, or any envelope payload —
+ * only stable request ids, reasons, and the terminal status of the dispatch
+ * attempt. The raw event data is not logged or persisted for retries.
+ *
+ * Transition rules (enforced by the `apply*` helpers and the drizzle layer):
+ * - `recordRaiseIntent`: insert a fresh `pending` row; idempotent on
+ * request id (re-raise of a `resolved`/terminal row is a no-op).
+ * - `recordResolveIntent`: `pending`/`in_flight` → `resolved`, sets
+ * `resolvedAt`, clears `nextAttemptAt`/`lastError`. Terminal rows only
+ * gain `resolvedAt` (the status stays terminal) and stay no-op on a
+ * repeat resolve.
+ * - `claimNextDispatchable`: lease the next due `pending` row by flipping
+ * it to `in_flight`.
+ * - `markDispatched` / `markSuppressedByPresence` / `markMissingSession`:
+ * terminal transitions, idempotent.
+ * - `markRetry`: bump `attemptCount`, schedule next attempt; at
+ * `MAX_ATTEMPTS` park at terminal `failed` to stop the alarm loop.
+ * No-op for any terminal row.
+ * - `recoverStaleInFlightRows`: alarm-restart recovery — any row left in
+ * `in_flight` is bumped to `pending` with a fresh `nextAttemptAt`, or
+ * parked at `failed` when the bump hits `MAX_ATTEMPTS`. Downstream
+ * dispatch is deduped at the request-id level, so re-leasing a row
+ * that already pushed cannot double-fire a notification.
+ */
+
+import { and, asc, eq, gte, isNotNull, lte, sql } from 'drizzle-orm';
+import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite';
+
+import { attentionOutbox } from './db/sqlite-schema';
+import {
+ ATTENTION_MAX_DISPATCH_AGE_MS,
+ computeNextAttemptAt,
+ dispatchDeadline,
+ MAX_ATTEMPTS,
+ type AttentionReason,
+} from './attention-outbox';
+
+export type OutboxStatus =
+ | 'pending'
+ | 'in_flight'
+ | 'dispatched'
+ | 'suppressed_presence'
+ | 'missing_session'
+ | 'failed'
+ | 'resolved';
+
+const TERMINAL_STATUSES: OutboxStatus[] = [
+ 'resolved',
+ 'dispatched',
+ 'suppressed_presence',
+ 'missing_session',
+ 'failed',
+];
+
+function isTerminalStatus(status: OutboxStatus): boolean {
+ return TERMINAL_STATUSES.includes(status);
+}
+
+export type OutboxRow = {
+ requestId: string;
+ reason: AttentionReason;
+ status: OutboxStatus;
+ attemptCount: number;
+ nextAttemptAt: number | null;
+ lastError: string | null;
+ raisedAt: number;
+ resolvedAt: number | null;
+};
+
+export type RecordIntentParams = {
+ requestId: string;
+ reason: AttentionReason;
+ now: number;
+};
+
+/**
+ * Pure: build the row for a fresh raise. Does not enforce dedup; the
+ * drizzle layer checks for an existing row first and short-circuits.
+ */
+export function applyRaiseIntent(params: RecordIntentParams): OutboxRow {
+ return {
+ requestId: params.requestId,
+ reason: params.reason,
+ status: 'pending',
+ attemptCount: 0,
+ nextAttemptAt: params.now,
+ lastError: null,
+ raisedAt: params.now,
+ resolvedAt: null,
+ };
+}
+
+/**
+ * Idempotently record a raise. Returns the resulting row. A re-raise of the
+ * same request id is a no-op — the original `raised_at` and reason are
+ * preserved, so the user only sees one push per upstream request, even if
+ * the CLI replays the `*.asked` event. A raise against a `resolved` or
+ * terminal row is a no-op too: the user already saw the outcome, so we do
+ * not create a new pending push.
+ */
+export function recordRaiseIntent(
+ db: DrizzleSqliteDODatabase,
+ params: RecordIntentParams
+): OutboxRow {
+ const existing = selectByRequestId(db, params.requestId);
+ if (existing) return existing;
+
+ const row = applyRaiseIntent(params);
+ db.insert(attentionOutbox)
+ .values({
+ request_id: row.requestId,
+ reason: row.reason,
+ status: row.status,
+ attempt_count: row.attemptCount,
+ next_attempt_at: row.nextAttemptAt,
+ last_error: row.lastError,
+ raised_at: row.raisedAt,
+ resolved_at: row.resolvedAt,
+ })
+ .run();
+ return row;
+}
+
+export type RecordResolveParams = {
+ requestId: string;
+ reason: AttentionReason;
+ now: number;
+};
+
+/**
+ * Pure: compute the next row for a resolve intent. `pending` and
+ * `in_flight` rows collapse to terminal `resolved` and clear their retry
+ * bookkeeping. Terminal rows only gain `resolvedAt` (when missing) and
+ * keep their existing status; a repeat resolve is a no-op.
+ */
+export function applyResolveIntent(existing: OutboxRow, now: number): OutboxRow {
+ if (existing.status === 'pending' || existing.status === 'in_flight') {
+ return {
+ ...existing,
+ status: 'resolved',
+ nextAttemptAt: null,
+ lastError: null,
+ resolvedAt: now,
+ };
+ }
+ if (existing.resolvedAt !== null) return existing;
+ return { ...existing, resolvedAt: now };
+}
+
+/**
+ * Pure: build a terminal resolved tombstone for a request id that was
+ * never raised. The unknown-resolve path is the safe out-of-order case
+ * where the resolve arrives before the matching raise. The tombstone
+ * stamps `raisedAt` and `resolvedAt` to the same `now` so a subsequent
+ * raise cannot pick the row up as a real push — the outbox dispatch
+ * loop will see `status === 'resolved'` and skip it.
+ *
+ * `raisedAt === resolvedAt` and `attemptCount === 0` are the contract
+ * that distinguishes a tombstone from a real raise-then-resolve row.
+ */
+export function applyResolveIntentForUnknownRequest(params: {
+ requestId: string;
+ reason: AttentionReason;
+ now: number;
+}): OutboxRow {
+ return {
+ requestId: params.requestId,
+ reason: params.reason,
+ status: 'resolved',
+ attemptCount: 0,
+ nextAttemptAt: null,
+ lastError: null,
+ raisedAt: params.now,
+ resolvedAt: params.now,
+ };
+}
+
+/**
+ * Mark a request resolved. When the request id was never raised this
+ * inserts a terminal resolved tombstone (see
+ * `applyResolveIntentForUnknownRequest`) so a late raise cannot enqueue
+ * a notification. When a row already exists, the original `reason` is
+ * preserved and the row is collapsed to terminal `resolved` — a repeat
+ * resolve with a different reason is a no-op for the stored reason and
+ * never re-flaps status.
+ */
+export function recordResolveIntent(
+ db: DrizzleSqliteDODatabase,
+ params: RecordResolveParams
+): OutboxRow {
+ const existing = selectByRequestId(db, params.requestId);
+ if (!existing) {
+ const row = applyResolveIntentForUnknownRequest({
+ requestId: params.requestId,
+ reason: params.reason,
+ now: params.now,
+ });
+ db.insert(attentionOutbox)
+ .values({
+ request_id: row.requestId,
+ reason: row.reason,
+ status: row.status,
+ attempt_count: row.attemptCount,
+ next_attempt_at: row.nextAttemptAt,
+ last_error: row.lastError,
+ raised_at: row.raisedAt,
+ resolved_at: row.resolvedAt,
+ })
+ .run();
+ return row;
+ }
+
+ // Existing row: the original reason is preserved. A repeat resolve
+ // with a different reason must not overwrite the stored reason, and
+ // must not change status away from any terminal state.
+ const next = applyResolveIntent(existing, params.now);
+ if (next === existing) return existing;
+
+ db.update(attentionOutbox)
+ .set({
+ status: next.status,
+ resolved_at: next.resolvedAt,
+ next_attempt_at: next.nextAttemptAt,
+ last_error: next.lastError,
+ })
+ .where(eq(attentionOutbox.request_id, params.requestId))
+ .run();
+
+ return selectByRequestId(db, params.requestId) as OutboxRow;
+}
+
+export type ClaimDispatchParams = {
+ now: number;
+};
+
+/**
+ * Park every pending row whose `next_attempt_at` is at/over the
+ * absolute dispatch window (raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS)
+ * as terminal failed with `last_error: 'retry_window_expired'`. The
+ * window is the receiver's idempotency TTL bound
+ * (NotificationChannelDO = 60min; we cap dispatch at 55min) so any
+ * attempt at/after 60min could be silently dropped or duplicated. By
+ * parking first, the rest of the dispatch loop and the
+ * `earliestScheduledAttemptAt` scheduler see only rows that are still
+ * safely within the window.
+ *
+ * Defense in depth: `claimNextDispatchable` calls this on every lease
+ * attempt and the DO alarm also calls it at start-up, so a delayed
+ * alarm that fires long after the row crossed the window can never
+ * claim a stale row.
+ */
+export function parkExpiredPendingRows(
+ db: DrizzleSqliteDODatabase,
+ _params: ClaimDispatchParams
+): OutboxRow[] {
+ const candidates = db
+ .select()
+ .from(attentionOutbox)
+ .where(
+ and(
+ eq(attentionOutbox.status, 'pending'),
+ isNotNull(attentionOutbox.next_attempt_at),
+ gte(
+ attentionOutbox.next_attempt_at,
+ sql`${attentionOutbox.raised_at} + ${ATTENTION_MAX_DISPATCH_AGE_MS}`
+ )
+ )
+ )
+ .all();
+
+ const parked: OutboxRow[] = [];
+ for (const row of candidates) {
+ const existing = rowToOutbox(row);
+ db.update(attentionOutbox)
+ .set({
+ status: 'failed',
+ next_attempt_at: null,
+ last_error: 'retry_window_expired',
+ })
+ .where(eq(attentionOutbox.request_id, existing.requestId))
+ .run();
+ parked.push({
+ ...existing,
+ status: 'failed',
+ nextAttemptAt: null,
+ lastError: 'retry_window_expired',
+ });
+ }
+ return parked;
+}
+
+/**
+ * Claim the next due pending row for dispatch, transitioning it to
+ * `in_flight`. Returns the row or null when nothing is due.
+ *
+ * Two hard guards ensure the row is still within the receiver's
+ * idempotency window before any lease is granted:
+ * 1. `parkExpiredPendingRows` flips any pending row whose
+ * `next_attempt_at` is at/over the absolute deadline to terminal
+ * `failed` (defense in depth — also called at alarm start).
+ * 2. The remaining `pending` candidate must additionally satisfy
+ * `now < raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS` so a row whose
+ * deadline has just elapsed between the parking step and the
+ * claim is never picked.
+ *
+ * Atomic within the DO input gate — the SQLite update + select is the
+ * dispatch lease.
+ */
+export function claimNextDispatchable(
+ db: DrizzleSqliteDODatabase,
+ params: ClaimDispatchParams
+): OutboxRow | null {
+ parkExpiredPendingRows(db, params);
+
+ const candidates = db
+ .select()
+ .from(attentionOutbox)
+ .where(
+ and(
+ eq(attentionOutbox.status, 'pending'),
+ isNotNull(attentionOutbox.next_attempt_at),
+ lte(attentionOutbox.next_attempt_at, params.now)
+ )
+ )
+ .orderBy(asc(attentionOutbox.next_attempt_at))
+ .all();
+
+ for (const candidate of candidates) {
+ const existing = rowToOutbox(candidate);
+ if (params.now >= dispatchDeadline(existing.raisedAt)) {
+ // The candidate is "due" by next_attempt_at but the absolute
+ // window has elapsed. Park it and continue scanning — the
+ // earlier `next_attempt_at` does not imply earlier dispatch
+ // safety. Parking here covers the rare case where
+ // `next_attempt_at < raisedAt + maxAge` (so the parking step
+ // didn't catch it) but the wall clock is now past the deadline.
+ db.update(attentionOutbox)
+ .set({
+ status: 'failed',
+ next_attempt_at: null,
+ last_error: 'retry_window_expired',
+ })
+ .where(eq(attentionOutbox.request_id, existing.requestId))
+ .run();
+ continue;
+ }
+
+ db.update(attentionOutbox)
+ .set({ status: 'in_flight' })
+ .where(eq(attentionOutbox.request_id, existing.requestId))
+ .run();
+
+ return { ...existing, status: 'in_flight' };
+ }
+ return null;
+}
+
+export type MarkDispatchedParams = {
+ requestId: string;
+ now: number;
+};
+
+/**
+ * Mark a row terminal-dispatched. The dispatch succeeded, so no further
+ * retries will fire.
+ */
+export function markDispatched(db: DrizzleSqliteDODatabase, params: MarkDispatchedParams): void {
+ db.update(attentionOutbox)
+ .set({ status: 'dispatched', next_attempt_at: null })
+ .where(eq(attentionOutbox.request_id, params.requestId))
+ .run();
+}
+
+export type MarkSuppressedByPresenceParams = {
+ requestId: string;
+ now: number;
+};
+
+/**
+ * Terminal: the user is actively viewing this session, so the push was
+ * suppressed upstream. Don't retry — they'll see the result in-app.
+ */
+export function markSuppressedByPresence(
+ db: DrizzleSqliteDODatabase,
+ params: MarkSuppressedByPresenceParams
+): void {
+ db.update(attentionOutbox)
+ .set({ status: 'suppressed_presence', next_attempt_at: null })
+ .where(eq(attentionOutbox.request_id, params.requestId))
+ .run();
+}
+
+export type MarkMissingSessionParams = {
+ requestId: string;
+ now: number;
+};
+
+/**
+ * Terminal: the session row is gone or the user no longer has access. We
+ * could re-raise in the future if access is restored, but a stale push
+ * with the wrong recipient is worse than missing one.
+ */
+export function markMissingSession(
+ db: DrizzleSqliteDODatabase,
+ params: MarkMissingSessionParams
+): void {
+ db.update(attentionOutbox)
+ .set({ status: 'missing_session', next_attempt_at: null })
+ .where(eq(attentionOutbox.request_id, params.requestId))
+ .run();
+}
+
+export type MarkRetryParams = {
+ requestId: string;
+ now: number;
+ reason: string;
+};
+
+/**
+ * Pure: compute the next row for a retry outcome. Bumps `attemptCount`
+ * and either schedules the next attempt or parks the row at terminal
+ * `failed`. Two independent terminal triggers:
+ * 1. the attempt cap (`MAX_ATTEMPTS`) is reached, or
+ * 2. the absolute dispatch window has elapsed — `now` is at/after
+ * `raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS`, or the candidate
+ * next attempt timestamp would itself land at/after that deadline.
+ * Trigger (2) is the receiver's idempotency TTL bound
+ * (NotificationChannelDO is 60min; we cap dispatch at 55min) and trips
+ * regardless of the cap. Terminal rows (resolved, dispatched,
+ * suppressed_presence, missing_session, or failed) win and are returned
+ * unchanged so retries never resurrect finished work. The terminal
+ * `failed` row keeps `resolvedAt` null to match the markRetry park
+ * convention.
+ */
+export function applyRetry(existing: OutboxRow, now: number, reason: string): OutboxRow {
+ if (isTerminalStatus(existing.status)) {
+ return existing;
+ }
+ const nextAttemptCount = existing.attemptCount + 1;
+ const capReached = nextAttemptCount >= MAX_ATTEMPTS;
+ const deadline = dispatchDeadline(existing.raisedAt);
+ const candidateNext = computeNextAttemptAt(nextAttemptCount, now);
+ const windowExpired = now >= deadline || candidateNext >= deadline;
+ const isTerminal = capReached || windowExpired;
+ return {
+ ...existing,
+ status: isTerminal ? 'failed' : 'pending',
+ attemptCount: nextAttemptCount,
+ nextAttemptAt: isTerminal ? null : candidateNext,
+ lastError: reason.slice(0, 512),
+ };
+}
+
+/**
+ * Mark the row pending again, bumping the attempt counter and scheduling
+ * the next try. After `MAX_ATTEMPTS` the row is parked at terminal
+ * `failed` to keep the alarm from hot-looping. A row that has already
+ * reached a terminal status wins and is returned unchanged.
+ */
+export function markRetry(db: DrizzleSqliteDODatabase, params: MarkRetryParams): OutboxRow {
+ const existing = selectByRequestId(db, params.requestId);
+ if (!existing) {
+ const error = new Error('markRetry: unknown request_id');
+ (error as Error & { code: string }).code = 'ATTENTION_OUTBOX_UNKNOWN_REQUEST';
+ throw error;
+ }
+ const next = applyRetry(existing, params.now, params.reason);
+ if (next === existing) {
+ return existing;
+ }
+
+ db.update(attentionOutbox)
+ .set({
+ status: next.status,
+ attempt_count: next.attemptCount,
+ next_attempt_at: next.nextAttemptAt,
+ last_error: next.lastError,
+ })
+ .where(eq(attentionOutbox.request_id, params.requestId))
+ .run();
+
+ return selectByRequestId(db, params.requestId) as OutboxRow;
+}
+
+/**
+ * Pure: compute the next row for a stale `in_flight` recovery (alarm
+ * restart or crash between lease and ack). The row is bumped to
+ * `pending` with a fresh `nextAttemptAt`; the same two terminal
+ * triggers as `applyRetry` apply — the cap or the absolute dispatch
+ * window. Downstream dispatch is keyed on the stable request id, so a
+ * recovered row cannot double-push.
+ */
+export function applyStaleInFlightRecovery(existing: OutboxRow, now: number): OutboxRow {
+ const nextAttemptCount = existing.attemptCount + 1;
+ const capReached = nextAttemptCount >= MAX_ATTEMPTS;
+ const deadline = dispatchDeadline(existing.raisedAt);
+ const candidateNext = computeNextAttemptAt(nextAttemptCount, now);
+ const windowExpired = now >= deadline || candidateNext >= deadline;
+ const isTerminal = capReached || windowExpired;
+ if (isTerminal) {
+ return {
+ ...existing,
+ status: 'failed',
+ attemptCount: nextAttemptCount,
+ nextAttemptAt: null,
+ lastError: 'stale_in_flight_recovered',
+ };
+ }
+ return {
+ ...existing,
+ status: 'pending',
+ attemptCount: nextAttemptCount,
+ nextAttemptAt: candidateNext,
+ lastError: 'stale_in_flight_recovered',
+ };
+}
+
+export type RecoverStaleInFlightParams = {
+ now: number;
+};
+
+/**
+ * Recover any rows still in `in_flight` — typically because the DO was
+ * restarted (or the alarm was lost) between the lease and the dispatch
+ * ack. Each stale row is bumped to `pending` with a fresh schedule, or
+ * parked at `failed` once `MAX_ATTEMPTS` is reached. Returns the rows
+ * that were recovered (caller can use this to reschedule the alarm).
+ */
+export function recoverStaleInFlightRows(
+ db: DrizzleSqliteDODatabase,
+ params: RecoverStaleInFlightParams
+): OutboxRow[] {
+ const candidates = db
+ .select()
+ .from(attentionOutbox)
+ .where(eq(attentionOutbox.status, 'in_flight'))
+ .all();
+ const recovered: OutboxRow[] = [];
+ for (const row of candidates) {
+ const existing = rowToOutbox(row);
+ const next = applyStaleInFlightRecovery(existing, params.now);
+ db.update(attentionOutbox)
+ .set({
+ status: next.status,
+ attempt_count: next.attemptCount,
+ next_attempt_at: next.nextAttemptAt,
+ last_error: next.lastError,
+ })
+ .where(eq(attentionOutbox.request_id, existing.requestId))
+ .run();
+ recovered.push(next);
+ }
+ return recovered;
+}
+
+/**
+ * Earliest `next_attempt_at` across pending rows that are still
+ * dispatchable (have a `next_attempt_at` set). Returns null when no
+ * pending row is scheduled. Used by the alarm scheduler to pick a
+ * non-clobbering alarm time without re-doing the full claim.
+ */
+export function earliestScheduledAttemptAt(db: DrizzleSqliteDODatabase): number | null {
+ const row = db
+ .select({ next_attempt_at: attentionOutbox.next_attempt_at })
+ .from(attentionOutbox)
+ .where(and(eq(attentionOutbox.status, 'pending'), isNotNull(attentionOutbox.next_attempt_at)))
+ .orderBy(asc(attentionOutbox.next_attempt_at))
+ .limit(1)
+ .get();
+ return row?.next_attempt_at ?? null;
+}
+
+/**
+ * True while at least one row is still pending dispatch (including
+ * rows currently in_flight that may yet be returned by the alarm loop).
+ * Used by the DO alarm to keep scheduling attempts until the outbox
+ * drains.
+ */
+export function hasPendingAttempts(db: DrizzleSqliteDODatabase): boolean {
+ const row = db
+ .select({ request_id: attentionOutbox.request_id })
+ .from(attentionOutbox)
+ .where(eq(attentionOutbox.status, 'pending'))
+ .limit(1)
+ .get();
+ return Boolean(row);
+}
+
+/**
+ * True when at least one row is currently leased to the dispatch loop.
+ * Used to detect a stuck `in_flight` row that the alarm needs to
+ * recover. (We never use this for hot-loop scheduling — terminal
+ * failures have a clear `failed` state to prevent that.)
+ */
+export function hasInFlightRows(db: DrizzleSqliteDODatabase): boolean {
+ const row = db
+ .select({ request_id: attentionOutbox.request_id })
+ .from(attentionOutbox)
+ .where(eq(attentionOutbox.status, 'in_flight'))
+ .limit(1)
+ .get();
+ return Boolean(row);
+}
+
+/**
+ * Read the current outbox row for a request id. Returns null when the
+ * request id was never raised. Intended for the orchestration layer
+ * (e.g. resolving pending pushes from a different signal) and tests.
+ */
+export function getOutboxRow(db: DrizzleSqliteDODatabase, requestId: string): OutboxRow | null {
+ return selectByRequestId(db, requestId);
+}
+
+function selectByRequestId(db: DrizzleSqliteDODatabase, requestId: string): OutboxRow | null {
+ const row = db
+ .select()
+ .from(attentionOutbox)
+ .where(eq(attentionOutbox.request_id, requestId))
+ .get();
+ return row ? rowToOutbox(row) : null;
+}
+
+function rowToOutbox(row: typeof attentionOutbox.$inferSelect): OutboxRow {
+ // These values are controlled by this DO's validated writes and
+ // free-text SQLite columns, so targeted casts are sufficient; no
+ // per-row Zod reparse is needed.
+ return {
+ requestId: row.request_id,
+ reason: row.reason as AttentionReason,
+ status: row.status as OutboxStatus,
+ attemptCount: row.attempt_count,
+ nextAttemptAt: row.next_attempt_at,
+ lastError: row.last_error,
+ raisedAt: row.raised_at,
+ resolvedAt: row.resolved_at,
+ };
+}
diff --git a/services/session-ingest/src/attention-outbox.test.ts b/services/session-ingest/src/attention-outbox.test.ts
new file mode 100644
index 0000000000..94fca641a0
--- /dev/null
+++ b/services/session-ingest/src/attention-outbox.test.ts
@@ -0,0 +1,596 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ ATTENTION_MAX_DISPATCH_AGE_MS,
+ classifyAttentionEvent,
+ computeNextAttemptAt,
+ dispatchDeadline,
+ extractAttentionSignal,
+ extractStableRequestId,
+ isActionable,
+ isResolvedEvent,
+ MAX_ATTEMPTS,
+ type AttentionReason,
+} from './attention-outbox';
+import {
+ applyRaiseIntent,
+ applyResolveIntent,
+ applyResolveIntentForUnknownRequest,
+ applyRetry,
+ applyStaleInFlightRecovery,
+ type OutboxRow,
+ type OutboxStatus,
+} from './attention-outbox-store';
+
+describe('classifyAttentionEvent', () => {
+ it.each([
+ ['question.asked', 'raise', 'question'],
+ ['question.replied', 'resolve', 'question'],
+ ['question.rejected', 'resolve', 'question'],
+ ['permission.asked', 'raise', 'permission'],
+ ['permission.replied', 'resolve', 'permission'],
+ ] as const)('maps %s to %s', (event, kind, reason) => {
+ const result = classifyAttentionEvent(event, {});
+ expect(result?.kind).toBe(kind);
+ expect(result?.reason ?? null).toBe(reason);
+ });
+
+ describe('suggestion events', () => {
+ it('raises a blocking_suggestion when blocking:true is explicit', () => {
+ const result = classifyAttentionEvent('suggestion.shown', { blocking: true });
+ expect(result).toEqual({ kind: 'raise', reason: 'blocking_suggestion' });
+ });
+
+ it('ignores a suggestion.shown without blocking flag', () => {
+ expect(classifyAttentionEvent('suggestion.shown', {})).toBeNull();
+ });
+
+ it('ignores a suggestion.shown with blocking string "true"', () => {
+ expect(classifyAttentionEvent('suggestion.shown', { blocking: 'true' })).toBeNull();
+ });
+
+ it('ignores a suggestion.shown with blocking number 1', () => {
+ expect(classifyAttentionEvent('suggestion.shown', { blocking: 1 })).toBeNull();
+ });
+
+ it('ignores a suggestion.shown with blocking:false', () => {
+ expect(classifyAttentionEvent('suggestion.shown', { blocking: false })).toBeNull();
+ });
+
+ it('resolves a suggestion.accepted as blocking_suggestion', () => {
+ expect(classifyAttentionEvent('suggestion.accepted', { id: 's_1' })).toEqual({
+ kind: 'resolve',
+ reason: 'blocking_suggestion',
+ });
+ });
+
+ it('resolves a suggestion.dismissed as blocking_suggestion', () => {
+ expect(classifyAttentionEvent('suggestion.dismissed', { id: 's_1' })).toEqual({
+ kind: 'resolve',
+ reason: 'blocking_suggestion',
+ });
+ });
+
+ it('ignores a suggestion.accepted when blocking is false (advisory, not user-actionable)', () => {
+ // Advisory (non-blocking) suggestions are noise; the producer
+ // never raised them, so we should not synthesize a resolve either.
+ expect(
+ classifyAttentionEvent('suggestion.accepted', { id: 's_1', blocking: false })
+ ).toBeNull();
+ });
+ });
+
+ it('ignores session.status updates', () => {
+ expect(classifyAttentionEvent('session.status', { status: 'busy' })).toBeNull();
+ });
+
+ it('ignores message.updated events', () => {
+ expect(classifyAttentionEvent('message.updated', { id: 'msg_1' })).toBeNull();
+ });
+
+ it('ignores network wait and offline events', () => {
+ expect(classifyAttentionEvent('session.network.asked', { id: 'nw_1' })).toBeNull();
+ expect(classifyAttentionEvent('session.network.restored', { id: 'nw_1' })).toBeNull();
+ expect(classifyAttentionEvent('session.offline', {})).toBeNull();
+ });
+
+ it('ignores automatic retry events', () => {
+ expect(classifyAttentionEvent('session.retry', { attempt: 2 })).toBeNull();
+ });
+
+ it('ignores action_required typed events (no safe Kilo contract yet)', () => {
+ // The shared reason stays available in the schema, but the raw Kilo
+ // contract does not yet expose a stable request id and genuinely
+ // user-actionable typed action, so the producer ignores it.
+ expect(classifyAttentionEvent('action_required', { id: 'ar_1' })).toBeNull();
+ });
+
+ it('ignores unknown event names', () => {
+ expect(classifyAttentionEvent('question.exploded', {})).toBeNull();
+ expect(classifyAttentionEvent('', {})).toBeNull();
+ });
+});
+
+describe('isActionable', () => {
+ it('returns true for raise events', () => {
+ expect(isActionable({ kind: 'raise', reason: 'question' })).toBe(true);
+ expect(isActionable({ kind: 'raise', reason: 'permission' })).toBe(true);
+ expect(isActionable({ kind: 'raise', reason: 'blocking_suggestion' })).toBe(true);
+ });
+
+ it('returns false for resolve events', () => {
+ expect(isActionable({ kind: 'resolve', reason: 'question' })).toBe(false);
+ });
+});
+
+describe('isResolvedEvent', () => {
+ it('returns true for resolve intents only', () => {
+ expect(isResolvedEvent({ kind: 'resolve', reason: 'question' })).toBe(true);
+ expect(isResolvedEvent({ kind: 'raise', reason: 'question' })).toBe(false);
+ });
+});
+
+describe('computeNextAttemptAt', () => {
+ const NOW = 1_000_000;
+
+ it('schedules the first attempt immediately', () => {
+ expect(computeNextAttemptAt(0, NOW)).toBe(NOW);
+ });
+
+ it('backs off exponentially up to MAX_ATTEMPTS', () => {
+ expect(computeNextAttemptAt(1, NOW)).toBe(NOW + 5_000);
+ expect(computeNextAttemptAt(2, NOW)).toBe(NOW + 15_000);
+ expect(computeNextAttemptAt(3, NOW)).toBe(NOW + 60_000);
+ expect(computeNextAttemptAt(4, NOW)).toBe(NOW + 5 * 60_000);
+ expect(computeNextAttemptAt(5, NOW)).toBe(NOW + 15 * 60_000);
+ // The helper caps at attempt 6; attempt 7 is parked at terminal failed by
+ // callers, but the clamped safety-net value is still the cap.
+ expect(computeNextAttemptAt(7, NOW)).toBe(NOW + 60 * 60_000);
+ });
+
+ it('exposes MAX_ATTEMPTS as a stable ceiling for the caller', () => {
+ expect(MAX_ATTEMPTS).toBeGreaterThan(0);
+ });
+});
+
+describe('AttentionReason re-exports', () => {
+ it('exposes the supported reasons for downstream code', () => {
+ const reasons: AttentionReason[] = ['question', 'permission', 'blocking_suggestion'];
+ expect(reasons).toHaveLength(3);
+ });
+});
+
+/**
+ * Pure transition helpers extracted from `attention-outbox-store.ts`.
+ * The drizzle layer in the store is the one that persists these, so we
+ * exercise the rules here without touching SQLite. Drizzle-backed
+ * integration coverage is expected to land alongside the DO wiring.
+ */
+describe('outbox store pure transitions', () => {
+ const NOW = 1_700_000_000_000;
+
+ function baseRow(overrides: Partial = {}): OutboxRow {
+ return {
+ requestId: 'req_1',
+ reason: 'question',
+ status: 'pending',
+ attemptCount: 0,
+ nextAttemptAt: NOW,
+ lastError: null,
+ raisedAt: NOW,
+ resolvedAt: null,
+ ...overrides,
+ };
+ }
+
+ describe('applyRaiseIntent', () => {
+ it('builds a fresh pending row with no retry history', () => {
+ const row = applyRaiseIntent({ requestId: 'req_1', reason: 'question', now: NOW });
+ expect(row).toEqual({
+ requestId: 'req_1',
+ reason: 'question',
+ status: 'pending',
+ attemptCount: 0,
+ nextAttemptAt: NOW,
+ lastError: null,
+ raisedAt: NOW,
+ resolvedAt: null,
+ });
+ });
+ });
+
+ describe('applyResolveIntent', () => {
+ it('collapses a pending row to terminal resolved and clears retry state', () => {
+ const next = applyResolveIntent(baseRow({ status: 'pending' }), NOW + 1);
+ expect(next.status).toBe('resolved');
+ expect(next.resolvedAt).toBe(NOW + 1);
+ expect(next.nextAttemptAt).toBeNull();
+ expect(next.lastError).toBeNull();
+ // Original raise metadata is preserved for audit.
+ expect(next.raisedAt).toBe(NOW);
+ });
+
+ it('collapses an in_flight row to terminal resolved and clears retry state', () => {
+ const next = applyResolveIntent(
+ baseRow({ status: 'in_flight', attemptCount: 2, lastError: 'boom' }),
+ NOW + 1
+ );
+ expect(next.status).toBe('resolved');
+ expect(next.resolvedAt).toBe(NOW + 1);
+ expect(next.nextAttemptAt).toBeNull();
+ expect(next.lastError).toBeNull();
+ });
+
+ it('keeps a terminal status and only stamps resolvedAt when missing', () => {
+ const next = applyResolveIntent(baseRow({ status: 'dispatched' }), NOW + 5);
+ expect(next.status).toBe('dispatched');
+ expect(next.resolvedAt).toBe(NOW + 5);
+ });
+
+ it('keeps a parked-failed terminal status and only stamps resolvedAt when missing', () => {
+ const next = applyResolveIntent(
+ baseRow({ status: 'failed', attemptCount: MAX_ATTEMPTS }),
+ NOW + 5
+ );
+ expect(next.status).toBe('failed');
+ expect(next.resolvedAt).toBe(NOW + 5);
+ });
+
+ it('is a no-op when called twice on a terminal row', () => {
+ const once = applyResolveIntent(baseRow({ status: 'dispatched' }), NOW + 5);
+ const twice = applyResolveIntent(once, NOW + 50);
+ expect(twice).toBe(once);
+ });
+
+ it('is a no-op when called twice on a resolved row', () => {
+ const once = applyResolveIntent(baseRow({ status: 'pending' }), NOW + 5);
+ const twice = applyResolveIntent(once, NOW + 50);
+ expect(twice).toBe(once);
+ expect(twice.status).toBe('resolved');
+ expect(twice.resolvedAt).toBe(NOW + 5);
+ });
+ });
+
+ describe('applyResolveIntentForUnknownRequest', () => {
+ // The unknown-resolve path handles out-of-order events where the
+ // resolve arrives before the matching raise. The row must be a
+ // terminal tombstone: no scheduled dispatch, no attempt count, and
+ // `raisedAt === resolvedAt` so a subsequent raise cannot pick the row
+ // up as a real push.
+ it('produces a terminal resolved tombstone with the supplied reason and now as both timestamps', () => {
+ const next = applyResolveIntentForUnknownRequest({
+ requestId: 'req_unknown',
+ reason: 'question',
+ now: NOW,
+ });
+ expect(next).toEqual({
+ requestId: 'req_unknown',
+ reason: 'question',
+ status: 'resolved',
+ attemptCount: 0,
+ nextAttemptAt: null,
+ lastError: null,
+ raisedAt: NOW,
+ resolvedAt: NOW,
+ });
+ });
+
+ it.each(['question', 'permission', 'blocking_suggestion'] as const)(
+ 'preserves the supplied resolve reason %s on the tombstone',
+ reason => {
+ const next = applyResolveIntentForUnknownRequest({
+ requestId: `req_${reason}`,
+ reason,
+ now: NOW,
+ });
+ expect(next.reason).toBe(reason);
+ expect(next.status).toBe('resolved');
+ expect(next.raisedAt).toBe(NOW);
+ expect(next.resolvedAt).toBe(NOW);
+ }
+ );
+ });
+
+ describe('applyRetry', () => {
+ it('bumps attemptCount, schedules the next attempt, and keeps status pending', () => {
+ const next = applyRetry(baseRow({ attemptCount: 1 }), NOW + 100, 'transient');
+ expect(next.status).toBe('pending');
+ expect(next.attemptCount).toBe(2);
+ expect(next.nextAttemptAt).toBe(computeNextAttemptAt(2, NOW + 100));
+ expect(next.lastError).toBe('transient');
+ });
+
+ it('preserves retry behavior for in_flight rows', () => {
+ const next = applyRetry(
+ baseRow({ status: 'in_flight', attemptCount: 2 }),
+ NOW + 100,
+ 'transient'
+ );
+ expect(next.status).toBe('pending');
+ expect(next.attemptCount).toBe(3);
+ expect(next.nextAttemptAt).toBe(computeNextAttemptAt(3, NOW + 100));
+ expect(next.lastError).toBe('transient');
+ });
+
+ it('parks at terminal failed when the cap is reached', () => {
+ const next = applyRetry(
+ baseRow({ attemptCount: MAX_ATTEMPTS - 1 }),
+ NOW + 100,
+ 'still failing'
+ );
+ expect(next.status).toBe('failed');
+ expect(next.attemptCount).toBe(MAX_ATTEMPTS);
+ expect(next.nextAttemptAt).toBeNull();
+ expect(next.lastError).toBe('still failing');
+ });
+
+ it.each([
+ 'resolved',
+ 'dispatched',
+ 'suppressed_presence',
+ 'missing_session',
+ 'failed',
+ ] as OutboxStatus[])('is a no-op for terminal status %s', status => {
+ const existing = baseRow({ status, attemptCount: 3 });
+ const next = applyRetry(existing, NOW + 100, 'should not apply');
+ expect(next).toBe(existing);
+ expect(next.status).toBe(status);
+ expect(next.attemptCount).toBe(3);
+ expect(next.lastError).toBeNull();
+ expect(next.nextAttemptAt).toBe(existing.nextAttemptAt);
+ });
+
+ it('truncates oversized lastError values to keep the row bounded', () => {
+ const huge = 'x'.repeat(2000);
+ const next = applyRetry(baseRow({ attemptCount: 0 }), NOW, huge);
+ expect(next.lastError?.length).toBe(512);
+ });
+
+ // Kilobot finding 5: never schedule a dispatch at/after the absolute
+ // deadline derived from `raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS`.
+ // The cap is enforced even when the cap-by-attempt-count wouldn't
+ // trip, because the receiver's TTL is the actual hard limit.
+ it('parks at terminal failed when `now` reaches the absolute deadline', () => {
+ const raisedAt = NOW;
+ const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS;
+ const next = applyRetry(baseRow({ attemptCount: 2, raisedAt }), deadline, 'transient');
+ expect(next.status).toBe('failed');
+ expect(next.nextAttemptAt).toBeNull();
+ expect(next.attemptCount).toBe(3);
+ expect(next.lastError).toBe('transient');
+ });
+
+ it('parks at terminal failed when `now` is strictly past the absolute deadline', () => {
+ const raisedAt = NOW;
+ const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS;
+ const next = applyRetry(baseRow({ attemptCount: 1, raisedAt }), deadline + 1, 'transient');
+ expect(next.status).toBe('failed');
+ expect(next.nextAttemptAt).toBeNull();
+ });
+
+ it('still schedules normally when the next backoff fits inside the window', () => {
+ const raisedAt = NOW;
+ const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS;
+ // attempt 0 → next attempt 1 (5s backoff) lands well before the
+ // deadline, so we must still schedule normally.
+ const fitsBefore = deadline - 10_000;
+ const next = applyRetry(baseRow({ attemptCount: 0, raisedAt }), fitsBefore, 'transient');
+ expect(next.status).toBe('pending');
+ expect(next.nextAttemptAt).toBe(computeNextAttemptAt(1, fitsBefore));
+ expect(next.nextAttemptAt).toBeLessThan(deadline);
+ });
+
+ it('parks at terminal failed when the next attempt would cross the deadline', () => {
+ // The cap-by-attempt-count would still allow attempt 6, but its
+ // backoff is 60min, which is past the 55min absolute deadline, so
+ // we park at failed without scheduling.
+ const raisedAt = NOW;
+ const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS;
+ const justInside = deadline - 1;
+ // attempt 5 → next attempt 6 → computeNextAttemptAt(6, justInside)
+ // = justInside + 60*60_000, which is > deadline.
+ const next = applyRetry(baseRow({ attemptCount: 5, raisedAt }), justInside, 'transient');
+ expect(next.status).toBe('failed');
+ expect(next.nextAttemptAt).toBeNull();
+ expect(next.attemptCount).toBe(6);
+ expect(next.lastError).toBe('transient');
+ });
+ });
+
+ describe('applyStaleInFlightRecovery', () => {
+ it('moves an in_flight row back to pending with a fresh schedule', () => {
+ const next = applyStaleInFlightRecovery(
+ baseRow({ status: 'in_flight', attemptCount: 0 }),
+ NOW + 100
+ );
+ expect(next.status).toBe('pending');
+ expect(next.attemptCount).toBe(1);
+ expect(next.nextAttemptAt).toBe(computeNextAttemptAt(1, NOW + 100));
+ expect(next.lastError).toBe('stale_in_flight_recovered');
+ });
+
+ it('parks at terminal failed when the recovery bump hits MAX_ATTEMPTS', () => {
+ const next = applyStaleInFlightRecovery(
+ baseRow({
+ status: 'in_flight',
+ attemptCount: MAX_ATTEMPTS - 1,
+ lastError: 'previous error',
+ }),
+ NOW + 100
+ );
+ expect(next.status).toBe('failed');
+ expect(next.attemptCount).toBe(MAX_ATTEMPTS);
+ expect(next.nextAttemptAt).toBeNull();
+ expect(next.lastError).toBe('stale_in_flight_recovered');
+ expect(next.resolvedAt).toBeNull();
+ });
+
+ // Kilobot finding 5: stale-recovery must respect the absolute
+ // deadline. A row past its window must not be rescheduled.
+ it('parks at terminal failed when `now` is at the absolute deadline', () => {
+ const raisedAt = NOW;
+ const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS;
+ const next = applyStaleInFlightRecovery(
+ baseRow({ status: 'in_flight', attemptCount: 1, raisedAt }),
+ deadline
+ );
+ expect(next.status).toBe('failed');
+ expect(next.nextAttemptAt).toBeNull();
+ expect(next.attemptCount).toBe(2);
+ });
+
+ it('parks at terminal failed when the recovered schedule would cross the deadline', () => {
+ const raisedAt = NOW;
+ const deadline = raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS;
+ // attempt 5 → next attempt 6 → schedule crosses deadline.
+ const justInside = deadline - 1;
+ const next = applyStaleInFlightRecovery(
+ baseRow({ status: 'in_flight', attemptCount: 5, raisedAt }),
+ justInside
+ );
+ expect(next.status).toBe('failed');
+ expect(next.nextAttemptAt).toBeNull();
+ });
+ });
+
+ describe('idempotent raise contract', () => {
+ it('applyRaiseIntent always produces a fresh pending row (dedup lives in the store)', () => {
+ // recordRaiseIntent short-circuits on an existing row before this
+ // helper is called, so the pure helper itself is intentionally
+ // unconditional. This test documents that contract so future
+ // refactors do not move dedup into the helper by accident.
+ const fresh = applyRaiseIntent({ requestId: 'req_1', reason: 'question', now: NOW });
+ expect(fresh.status).toBe('pending');
+ expect(fresh.resolvedAt).toBeNull();
+ expect(fresh.attemptCount).toBe(0);
+ expect(fresh.lastError).toBeNull();
+ });
+ });
+});
+
+describe('extractStableRequestId', () => {
+ it('reads a direct id', () => {
+ expect(extractStableRequestId({ id: 'r_1' })).toBe('r_1');
+ });
+
+ it('reads a direct requestId', () => {
+ expect(extractStableRequestId({ requestId: 'r_2' })).toBe('r_2');
+ });
+
+ it('reads a direct requestID', () => {
+ expect(extractStableRequestId({ requestID: 'r_3' })).toBe('r_3');
+ });
+
+ it('falls back to nested properties', () => {
+ expect(extractStableRequestId({ properties: { id: 'nested_1' } })).toBe('nested_1');
+ });
+
+ it('prefers the top-level id over nested properties', () => {
+ expect(extractStableRequestId({ id: 'top', properties: { id: 'nested' } })).toBe('top');
+ });
+
+ it('returns null for malformed or missing ids', () => {
+ expect(extractStableRequestId(null)).toBeNull();
+ expect(extractStableRequestId('string')).toBeNull();
+ expect(extractStableRequestId([])).toBeNull();
+ expect(extractStableRequestId({ id: '' })).toBeNull();
+ expect(extractStableRequestId({})).toBeNull();
+ expect(extractStableRequestId({ properties: {} })).toBeNull();
+ expect(extractStableRequestId({ properties: { id: '' } })).toBeNull();
+ });
+});
+
+describe('extractAttentionSignal', () => {
+ it('raises a question', () => {
+ expect(extractAttentionSignal('question.asked', { id: 'q_1' })).toEqual({
+ intent: { kind: 'raise', reason: 'question' },
+ requestId: 'q_1',
+ });
+ });
+
+ it('raises a permission', () => {
+ expect(extractAttentionSignal('permission.asked', { requestId: 'p_1' })).toEqual({
+ intent: { kind: 'raise', reason: 'permission' },
+ requestId: 'p_1',
+ });
+ });
+
+ it('raises an explicit blocking suggestion', () => {
+ expect(extractAttentionSignal('suggestion.shown', { id: 's_1', blocking: true })).toEqual({
+ intent: { kind: 'raise', reason: 'blocking_suggestion' },
+ requestId: 's_1',
+ });
+ });
+
+ it('resolves question.replied and question.rejected', () => {
+ expect(extractAttentionSignal('question.replied', { id: 'q_1' })).toEqual({
+ intent: { kind: 'resolve', reason: 'question' },
+ requestId: 'q_1',
+ });
+ expect(extractAttentionSignal('question.rejected', { id: 'q_1' })).toEqual({
+ intent: { kind: 'resolve', reason: 'question' },
+ requestId: 'q_1',
+ });
+ });
+
+ it('resolves permission.replied', () => {
+ expect(extractAttentionSignal('permission.replied', { id: 'p_1' })).toEqual({
+ intent: { kind: 'resolve', reason: 'permission' },
+ requestId: 'p_1',
+ });
+ });
+
+ it('resolves suggestion.accepted and suggestion.dismissed as blocking_suggestion', () => {
+ expect(extractAttentionSignal('suggestion.accepted', { id: 's_1' })).toEqual({
+ intent: { kind: 'resolve', reason: 'blocking_suggestion' },
+ requestId: 's_1',
+ });
+ expect(extractAttentionSignal('suggestion.dismissed', { id: 's_1' })).toEqual({
+ intent: { kind: 'resolve', reason: 'blocking_suggestion' },
+ requestId: 's_1',
+ });
+ });
+
+ it('ignores a nonblocking suggestion', () => {
+ expect(extractAttentionSignal('suggestion.shown', { id: 's_1', blocking: false })).toBeNull();
+ });
+
+ it('ignores a missing request id', () => {
+ expect(extractAttentionSignal('question.asked', {})).toBeNull();
+ });
+
+ it('ignores network and retry events', () => {
+ expect(extractAttentionSignal('session.network.asked', { id: 'nw_1' })).toBeNull();
+ expect(extractAttentionSignal('session.retry', { id: 'r_1' })).toBeNull();
+ });
+
+ it('ignores action_required events', () => {
+ expect(extractAttentionSignal('action_required', { id: 'ar_1' })).toBeNull();
+ });
+});
+
+const NOW_FOR_DEADLINE_TESTS = 1_700_000_000_000;
+
+/**
+ * Kilobot finding 5: notification downstream idempotency is bounded at
+ * 60min. Producers must not attempt dispatch at/after 55min from the
+ * raise timestamp so the row can never cross the receiver's TTL
+ * (NotificationChannelDO). The constant is a service-internal knob and
+ * is not re-exported across the package boundary; tests below import it
+ * from the same module so we exercise the actual value without coupling
+ * sibling packages.
+ */
+describe('ATTENTION_MAX_DISPATCH_AGE_MS', () => {
+ it('keeps the absolute dispatch deadline strictly under 60 minutes', () => {
+ // NotificationChannelDO TTL is 60min; we must never schedule dispatch
+ // at/after 55min from raisedAt, with a 5min safety margin.
+ expect(ATTENTION_MAX_DISPATCH_AGE_MS).toBeLessThan(60 * 60_000);
+ expect(60 * 60_000 - ATTENTION_MAX_DISPATCH_AGE_MS).toBe(5 * 60_000);
+ });
+
+ it('exposes a helper that adds the cap to a raise timestamp', () => {
+ expect(dispatchDeadline(NOW_FOR_DEADLINE_TESTS)).toBe(
+ NOW_FOR_DEADLINE_TESTS + ATTENTION_MAX_DISPATCH_AGE_MS
+ );
+ });
+});
diff --git a/services/session-ingest/src/attention-outbox.ts b/services/session-ingest/src/attention-outbox.ts
new file mode 100644
index 0000000000..e29ddb34bc
--- /dev/null
+++ b/services/session-ingest/src/attention-outbox.ts
@@ -0,0 +1,220 @@
+/**
+ * Pure classification and backoff helpers for the per-session attention outbox.
+ *
+ * Decoupled from drizzle and the DO surface so the rules are cheap to unit-test
+ * and easy to reason about. The DO reads envelope data through
+ * `extractAttentionRequest` (also pure) and feeds the resulting pieces to
+ * `classifyAttentionEvent` and `isActionable` / `isResolvedEvent`.
+ *
+ * The outbox records a single row per stable upstream request id, so:
+ * - Duplicate replays of the same `*.asked` event collapse (no-op).
+ * - A `rejected`/`replied`/etc. on a never-raised id is a no-op too.
+ * - Resolving an already-resolved request is a no-op.
+ * - Re-raising an already-resolved request is a no-op.
+ * Distinct requests stay independent.
+ *
+ * Stable request id sources by reason:
+ * - `question.asked` / `question.replied` / `question.rejected` → envelope id
+ * (raw property `id` or `requestId`, mapped by the DO).
+ * - `permission.asked` / `permission.replied` → envelope id.
+ * - `suggestion.shown` / `suggestion.accepted` / `suggestion.dismissed` →
+ * envelope id; raise only when `blocking` is the boolean `true`. String
+ * or numeric truthy values are ignored because the upstream typed
+ * contract is boolean.
+ *
+ * Events that look actionable to a human but do not currently expose a
+ * stable request id, or are non-actionable transport noise, are ignored
+ * outright so we never push on a request we can't reconcile with a
+ * subsequent resolve/replay.
+ */
+
+import { z } from 'zod';
+
+export const attentionReasonSchema = z.enum(['question', 'permission', 'blocking_suggestion']);
+export type AttentionReason = z.infer;
+
+export type AttentionIntent =
+ | { kind: 'raise'; reason: AttentionReason }
+ | { kind: 'resolve'; reason: AttentionReason };
+
+export const MAX_ATTEMPTS = 7;
+
+/**
+ * Maximum age (in ms) between a row's `raisedAt` and the moment a
+ * dispatch attempt is allowed to fire. The downstream receiver
+ * (NotificationChannelDO) deduplicates push requests with a 60-minute
+ * idempotency TTL, so any attempt at/after 60 minutes from `raisedAt`
+ * can be silently dropped or duplicated by a retry that crosses the
+ * window. We hold a 5-minute safety margin and refuse to attempt
+ * dispatch at/after 55 minutes from `raisedAt`.
+ *
+ * `dispatchDeadline(raisedAt)` returns the absolute wall-clock
+ * deadline; the outbox and alarm code must never schedule a row at or
+ * past that timestamp, and must never claim one whose deadline has
+ * arrived. The constant is exported so tests in the same package can
+ * reference it directly without coupling across the package boundary.
+ */
+export const ATTENTION_MAX_DISPATCH_AGE_MS = 55 * 60_000;
+
+export function dispatchDeadline(raisedAt: number): number {
+ return raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS;
+}
+
+/**
+ * Map a raw event name + envelope data to a raise/resolve intent. Returns
+ * null when the event is out of scope for human-attention pushes (network
+ * waits, automatic retries, status pings, message metadata, etc.).
+ *
+ * `data` is treated as an opaque object — producers may pass either
+ * `raw.properties` from the kilocode wrapper payload or the inner body
+ * of a `cloud_agent` envelope; the classifier only reads the fields it
+ * needs (`id`, `requestId`, `blocking`).
+ */
+export function classifyAttentionEvent(eventName: string, data: unknown): AttentionIntent | null {
+ const props = readRecord(data);
+
+ switch (eventName) {
+ case 'question.asked':
+ return { kind: 'raise', reason: 'question' };
+ case 'question.replied':
+ case 'question.rejected':
+ return { kind: 'resolve', reason: 'question' };
+
+ case 'permission.asked':
+ return { kind: 'raise', reason: 'permission' };
+ case 'permission.replied':
+ return { kind: 'resolve', reason: 'permission' };
+
+ case 'suggestion.shown':
+ // Only blocking suggestions are user-actionable. Non-blocking
+ // suggestions are advisory and would spam the lock screen.
+ return props?.blocking === true ? { kind: 'raise', reason: 'blocking_suggestion' } : null;
+ case 'suggestion.accepted':
+ case 'suggestion.dismissed':
+ // A suggestion explicitly marked `blocking: false` is advisory; the
+ // producer never raised it, so we must not synthesize a resolve for
+ // it either. For every other shape (no blocking field, blocking:true,
+ // or any non-false value) we synthesize a blocking_suggestion resolve
+ // so a late raise cannot enqueue a notification.
+ return props?.blocking === false ? null : { kind: 'resolve', reason: 'blocking_suggestion' };
+
+ default:
+ return null;
+ }
+}
+
+export function isActionable(intent: AttentionIntent): boolean {
+ return intent.kind === 'raise';
+}
+
+export function isResolvedEvent(intent: AttentionIntent): boolean {
+ return intent.kind === 'resolve';
+}
+
+/**
+ * Compute the next attempt timestamp. `attemptCount` is the number of
+ * attempts that have already been made (0 before the first try).
+ *
+ * Attempts are numbered 0 through 6. Once a caller has made attempt 6 and
+ * still needs to retry, the row is parked at the terminal `failed` status
+ * (attempt 7 is terminal), so this helper is never asked to schedule
+ * attempt 7 or beyond. The clamped return value here is a defensive safety
+ * net only.
+ *
+ * Backoff sequence (ms after `now`):
+ * attempt 0 → 0
+ * attempt 1 → 5_000
+ * attempt 2 → 15_000
+ * attempt 3 → 60_000
+ * attempt 4 → 300_000
+ * attempt 5 → 900_000
+ * attempt 6 → 3_600_000 (capped)
+ *
+ * The intent is to push immediately on first raise (the user just
+ * reached a wait state), then back off aggressively so retries don't
+ * pile up during long-lived waits.
+ */
+export function computeNextAttemptAt(attemptCount: number, now: number): number {
+ const schedule = [0, 5_000, 15_000, 60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000];
+ const idx = Math.min(Math.max(attemptCount, 0), schedule.length - 1);
+ return now + (schedule[idx] ?? 0);
+}
+
+/**
+ * Read a stable request id from the raw envelope. Looks at the fields the
+ * upstream producers actually use (`id` on kilocode payloads,
+ * `requestId`/`requestID` on cloud-agent envelopes) and returns the
+ * first non-empty string found, or null when the envelope is missing
+ * the id. The DO layer never logs the id value — it only forwards it
+ * to the outbox and notifications service.
+ */
+export function extractRequestId(data: unknown): string | null {
+ return extractStableRequestId(data);
+}
+
+/**
+ * Read a stable request id from a remote CLI event envelope.
+ *
+ * Authoritative shape: the kilocode CLI producer forwards
+ * `{ type: 'event', data: event.properties }` (the event's properties are
+ * the envelope), so the id lives at the top level of `data`
+ * (`data.id`, or `data.requestId` / `data.requestID`).
+ *
+ * Compatibility shape: the cloud-agent-next wrapper sends
+ * `{ ...properties, event, type, properties }` and therefore exposes the id
+ * at both the top level and under `data.properties`. We read the top-level
+ * first and only fall back to `data.properties` when the top level is
+ * missing the field, so the two shapes are interchangeable.
+ *
+ * Returns the first non-empty string value, or null when the envelope
+ * does not carry a usable id. The DO layer never logs the id value.
+ */
+export function extractStableRequestId(data: unknown): string | null {
+ const id = readIdFromRecord(data);
+ if (id !== null) return id;
+ const props = readRecord(data);
+ if (!props) return null;
+ const nested = readRecord(props.properties);
+ if (!nested) return null;
+ return readIdFromRecord(nested);
+}
+
+function readIdFromRecord(data: unknown): string | null {
+ const props = readRecord(data);
+ if (!props) return null;
+ for (const key of ['requestId', 'requestID', 'id']) {
+ const value = props[key];
+ if (typeof value === 'string' && value.length > 0) return value;
+ }
+ return null;
+}
+
+/**
+ * Resolve a remote CLI event into an attention signal suitable for the
+ * per-session outbox. Returns `null` when the event is out of scope, the
+ * envelope is malformed, or the event has no stable request id (we
+ * never push on a request we can't reconcile with a later resolve).
+ *
+ * Pure helper: callers pass the raw `event` name and the `data` envelope
+ * exactly as the CLI delivered them. The helper reads the id from the
+ * authoritative direct shape with a nested `properties` fallback for
+ * producers that wrap the body (see `extractStableRequestId`).
+ */
+export function extractAttentionSignal(
+ event: string,
+ data: unknown
+): {
+ intent: AttentionIntent;
+ requestId: string;
+} | null {
+ const intent = classifyAttentionEvent(event, data);
+ if (intent === null) return null;
+ const requestId = extractStableRequestId(data);
+ if (requestId === null) return null;
+ return { intent, requestId };
+}
+
+function readRecord(value: unknown): Record | null {
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
+ return value as Record;
+}
diff --git a/services/session-ingest/src/db/sqlite-schema.ts b/services/session-ingest/src/db/sqlite-schema.ts
index 2b4e145805..05906a750a 100644
--- a/services/session-ingest/src/db/sqlite-schema.ts
+++ b/services/session-ingest/src/db/sqlite-schema.ts
@@ -21,3 +21,24 @@ export const ingestMeta = sqliteTable('ingest_meta', {
export const sessions = sqliteTable('sessions', {
session_id: text('session_id').primaryKey(),
});
+
+/**
+ * Per-session durable outbox for human-attention pushes. One row per stable
+ * request id (e.g. a question id from the CLI). Status is set to a terminal
+ * value once dispatch is no longer needed; `pending` rows drive the alarm
+ * retry loop.
+ */
+export const attentionOutbox = sqliteTable(
+ 'attention_outbox',
+ {
+ request_id: text('request_id').primaryKey(),
+ reason: text('reason').notNull(),
+ status: text('status').notNull().default('pending'),
+ attempt_count: integer('attempt_count').notNull().default(0),
+ next_attempt_at: integer('next_attempt_at'),
+ last_error: text('last_error'),
+ raised_at: integer('raised_at').notNull(),
+ resolved_at: integer('resolved_at'),
+ },
+ table => [index('attention_outbox_pending_idx').on(table.status, table.next_attempt_at)]
+);
diff --git a/services/session-ingest/src/dos/SessionIngestDO.test.ts b/services/session-ingest/src/dos/SessionIngestDO.test.ts
index 75588abda7..42dd5fa2ef 100644
--- a/services/session-ingest/src/dos/SessionIngestDO.test.ts
+++ b/services/session-ingest/src/dos/SessionIngestDO.test.ts
@@ -38,6 +38,12 @@ describe('SessionIngestDO ingest ordering', () => {
from: vi.fn(() => selectQuery),
where: vi.fn(() => selectQuery),
get: vi.fn(() => undefined),
+ all: vi.fn(() => [
+ { key: 'metricsAlarmAt', value: '9999999999999' },
+ { key: 'metricsEmitted', value: 'false' },
+ ]),
+ orderBy: vi.fn(() => selectQuery),
+ limit: vi.fn(() => selectQuery),
};
const db = {
select: vi.fn(() => selectQuery),
@@ -105,6 +111,12 @@ describe('SessionIngestDO ingest ordering', () => {
from: vi.fn(() => selectQuery),
where: vi.fn(() => selectQuery),
get: vi.fn(() => getResults.shift()),
+ all: vi.fn(() => [
+ { key: 'metricsAlarmAt', value: '9999999999999' },
+ { key: 'metricsEmitted', value: 'false' },
+ ]),
+ orderBy: vi.fn(() => selectQuery),
+ limit: vi.fn(() => selectQuery),
};
const db = {
select: vi.fn(() => selectQuery),
@@ -174,6 +186,12 @@ describe('SessionIngestDO ingest ordering', () => {
from: vi.fn(() => selectQuery),
where: vi.fn(() => selectQuery),
get: vi.fn(() => undefined),
+ all: vi.fn(() => [
+ { key: 'metricsAlarmAt', value: '9999999999999' },
+ { key: 'metricsEmitted', value: 'false' },
+ ]),
+ orderBy: vi.fn(() => selectQuery),
+ limit: vi.fn(() => selectQuery),
};
const db = {
select: vi.fn(() => selectQuery),
diff --git a/services/session-ingest/src/dos/SessionIngestDO.ts b/services/session-ingest/src/dos/SessionIngestDO.ts
index 7adc3a7b13..5d28bcaf37 100644
--- a/services/session-ingest/src/dos/SessionIngestDO.ts
+++ b/services/session-ingest/src/dos/SessionIngestDO.ts
@@ -24,6 +24,20 @@ import {
type TerminationReason,
} from './session-metrics';
import migrations from '../../drizzle/migrations';
+import {
+ claimNextDispatchable,
+ getOutboxRow,
+ markDispatched,
+ markMissingSession,
+ markRetry,
+ markSuppressedByPresence,
+ parkExpiredPendingRows,
+ recordRaiseIntent,
+ recordResolveIntent,
+ recoverStaleInFlightRows,
+} from '../attention-outbox-store';
+import { recordAttentionEventInputSchema } from './attention-event-input';
+import { computeNextAlarmTime, shouldEmitMetricsFromAttentionAlarm } from './attention-alarm';
import {
readKiloSdkMessages,
readKiloSdkSessionSnapshot,
@@ -37,9 +51,17 @@ type IngestMetaKey =
| 'ingestVersion'
| 'closeReason'
| 'metricsEmitted'
+ | 'metricsAlarmAt'
| 'deleted'
| 'sessionReadyNotified';
+/**
+ * Bound the outbox dispatch work per alarm so a hot loop can't run the alarm
+ * out of wallclock. The alarm reschedules itself to the earliest remaining
+ * `next_attempt_at` when it stops short.
+ */
+const MAX_DISPATCH_PER_ALARM = 25;
+
type ExtractableMetaKey =
| 'title'
| 'parentId'
@@ -258,19 +280,19 @@ export class SessionIngestDO extends DurableObject {
.delete(ingestMeta)
.where(inArray(ingestMeta.key, ['metricsEmitted', 'closeReason']))
.run();
- await this.ctx.storage.setAlarm(Date.now() + INACTIVITY_TIMEOUT_MS);
+ await this.setSharedAlarm(Date.now() + INACTIVITY_TIMEOUT_MS);
} else {
writeIngestMetaIfChanged(this.db, {
key: 'closeReason',
incomingValue: event.reason,
});
- await this.ctx.storage.setAlarm(Date.now() + POST_CLOSE_DRAIN_MS);
+ await this.setSharedAlarm(Date.now() + POST_CLOSE_DRAIN_MS);
}
}
// Events without open/close (stragglers) don't touch the alarm.
} else {
// v0 (legacy): no open/close signals, rely on inactivity timeout.
- await this.ctx.storage.setAlarm(Date.now() + INACTIVITY_TIMEOUT_MS);
+ await this.setSharedAlarm(Date.now() + INACTIVITY_TIMEOUT_MS);
}
const changes: Changes = [];
@@ -343,6 +365,66 @@ export class SessionIngestDO extends DurableObject {
);
}
+ /**
+ * Direct DO RPC: record a human-attention event for this session.
+ *
+ * Accepts only IDs and a classified raise/resolve intent — the DO never
+ * sees prompt text, permission arguments, or any other envelope payload.
+ * Raise intents are recorded into the per-session outbox; resolve intents
+ * cancel or stamp a terminal row. The alarm is rescheduled so the outbox
+ * dispatches on the next tick.
+ *
+ * Idempotency: a re-raise of the same request id is a no-op; resolving a
+ * request that was never raised is a no-op; re-raising a resolved row is
+ * a no-op (the user already saw the outcome). The dispatch path is the
+ * sole owner of network IO, so the RPC itself never calls the
+ * notifications service — failures there are contained to the alarm.
+ */
+ async recordAttentionEvent(
+ rawParams: unknown
+ ): Promise<{ accepted: true } | { accepted: false; reason: 'invalid_input' | 'deleted' }> {
+ const parsed = recordAttentionEventInputSchema.safeParse(rawParams);
+ if (!parsed.success) {
+ return { accepted: false, reason: 'invalid_input' };
+ }
+ const params = parsed.data;
+
+ const deletedRow = this.db
+ .select({ value: ingestMeta.value })
+ .from(ingestMeta)
+ .where(eq(ingestMeta.key, 'deleted'))
+ .get();
+ if (deletedRow?.value === 'true') {
+ return { accepted: false, reason: 'deleted' };
+ }
+
+ writeIngestMetaIfChanged(this.db, { key: 'kiloUserId', incomingValue: params.kiloUserId });
+ writeIngestMetaIfChanged(this.db, { key: 'sessionId', incomingValue: params.sessionId });
+
+ const now = Date.now();
+ if (params.intent.kind === 'raise') {
+ recordRaiseIntent(this.db, {
+ requestId: params.requestId,
+ reason: params.intent.reason,
+ now,
+ });
+ } else {
+ recordResolveIntent(this.db, {
+ requestId: params.requestId,
+ reason: params.intent.reason,
+ now,
+ });
+ }
+
+ // Always reschedule: a raise wants to fire immediately; a resolve may
+ // free the alarm entirely (no remaining work) or shift the next attempt
+ // to an earlier pending row. `rescheduleAlarm` picks the correct
+ // outcome.
+ await this.rescheduleAlarm();
+
+ return { accepted: true };
+ }
+
async readKiloSdkSessionSnapshot(): Promise {
return readKiloSdkSessionSnapshot(this.db, this.env.SESSION_INGEST_R2);
}
@@ -483,7 +565,13 @@ export class SessionIngestDO extends DurableObject {
/**
* Compute and emit session metrics to the o11y worker.
- * Returns true if metrics were emitted, false if already emitted.
+ * Returns true if metrics were emitted, false if already emitted or no data.
+ *
+ * Emits a fresh alarm afterward via `rescheduleAlarm` so a metrics fire does
+ * not cancel any still-pending outbox work. The metrics alarm time is
+ * cleared once emission succeeds; the shared alarm scheduler reschedules
+ * itself to the earliest remaining outbox attempt (or deletes the alarm
+ * only when no work remains).
*/
private async emitSessionMetrics(
kiloUserId: string,
@@ -548,22 +636,134 @@ export class SessionIngestDO extends DurableObject {
...metrics,
});
- // Mark metrics as emitted to prevent duplicates
+ // Mark metrics as emitted to prevent duplicates and clear the metrics
+ // alarm time so the shared scheduler no longer considers it.
this.db
.insert(ingestMeta)
.values({ key: 'metricsEmitted', value: 'true' })
.onConflictDoUpdate({ target: ingestMeta.key, set: { value: 'true' } })
.run();
-
- await this.ctx.storage.deleteAlarm();
+ this.db.delete(ingestMeta).where(eq(ingestMeta.key, 'metricsAlarmAt')).run();
return true;
}
/**
- * Alarm fires either after POST_CLOSE_DRAIN_MS (session closed) or
- * INACTIVITY_TIMEOUT_MS (no activity). Reads the close reason from
- * ingest_meta if present, otherwise falls back to 'abandoned'.
+ * Schedule (or reschedule) the single DO alarm to fire at the earliest
+ * pending work: the metrics alarm time when metrics have not yet emitted,
+ * or the earliest outbox `next_attempt_at`. When both are empty the alarm
+ * is deleted entirely.
+ */
+ private async rescheduleAlarm(): Promise {
+ const next = computeNextAlarmTime(this.db);
+ if (next === null) {
+ await this.ctx.storage.deleteAlarm();
+ } else {
+ await this.ctx.storage.setAlarm(next);
+ }
+ }
+
+ /**
+ * Persist a new metrics alarm time and re-run the shared alarm scheduler.
+ * Every place ingest previously called `setAlarm` directly must use this
+ * helper so the outbox can still preempt the metrics timer.
+ */
+ private async setSharedAlarm(when: number): Promise {
+ this.db
+ .insert(ingestMeta)
+ .values({ key: 'metricsAlarmAt', value: String(when) })
+ .onConflictDoUpdate({ target: ingestMeta.key, set: { value: String(when) } })
+ .run();
+ await this.rescheduleAlarm();
+ }
+
+ /**
+ * Process up to `MAX_DISPATCH_PER_ALARM` outbox rows for the alarm's
+ * session. Each row is claimed, dispatched via the notifications binding,
+ * and transitioned to a terminal status. Rows that were resolved
+ * mid-flight are not overwritten. The caller is responsible for the final
+ * reschedule.
+ */
+ private async dispatchOutboxBatch(params: {
+ kiloUserId: string;
+ sessionId: string;
+ }): Promise {
+ for (let i = 0; i < MAX_DISPATCH_PER_ALARM; i++) {
+ const claimed = claimNextDispatchable(this.db, { now: Date.now() });
+ if (!claimed) return;
+
+ let result: {
+ dispatched: boolean;
+ reason?: 'missing_session' | 'dispatch_failed' | 'suppressed_presence';
+ };
+ try {
+ result = await this.env.NOTIFICATIONS.sendSessionAttentionNotification({
+ userId: params.kiloUserId,
+ cliSessionId: params.sessionId,
+ requestId: claimed.requestId,
+ reason: claimed.reason,
+ });
+ } catch (_error) {
+ // A thrown error is treated like `dispatch_failed`: bounded retry
+ // through `markRetry` (terminal `failed` once `MAX_ATTEMPTS` is hit).
+ // We never log the request id or reason, and we never store the raw
+ // thrown message in the outbox — only fixed safe codes.
+ console.error('SessionIngestDO attention dispatch threw', {
+ sessionId: params.sessionId,
+ kiloUserId: params.kiloUserId,
+ attemptCount: claimed.attemptCount,
+ code: 'rpc_error',
+ });
+ // If markRetry throws (unknown row), it is an invariant violation
+ // intentionally surfaced to the platform alarm retry; do not wrap
+ // or suppress it here.
+ markRetry(this.db, {
+ requestId: claimed.requestId,
+ now: Date.now(),
+ reason: 'rpc_error',
+ });
+ continue;
+ }
+
+ // A resolve that landed between the claim and the await must win.
+ const fresh = getOutboxRow(this.db, claimed.requestId);
+ if (fresh?.status === 'resolved') continue;
+
+ if (result.dispatched) {
+ markDispatched(this.db, { requestId: claimed.requestId, now: Date.now() });
+ continue;
+ }
+
+ switch (result.reason) {
+ case 'suppressed_presence':
+ markSuppressedByPresence(this.db, { requestId: claimed.requestId, now: Date.now() });
+ break;
+ case 'missing_session':
+ markMissingSession(this.db, { requestId: claimed.requestId, now: Date.now() });
+ break;
+ case 'dispatch_failed':
+ default:
+ markRetry(this.db, {
+ requestId: claimed.requestId,
+ now: Date.now(),
+ reason: 'dispatch_failed',
+ });
+ break;
+ }
+ }
+ }
+
+ /**
+ * Alarm fires either after POST_CLOSE_DRAIN_MS (session closed),
+ * INACTIVITY_TIMEOUT_MS (no activity), or immediately after a
+ * `recordAttentionEvent` raise. The same alarm is shared between the
+ * metrics lifecycle and the outbox dispatch loop — every scheduling
+ * decision goes through `rescheduleAlarm` so the two cannot clobber
+ * each other.
+ *
+ * The body is intentionally resilient: dispatch errors are caught,
+ * logged, and translated into a bounded retry; only `emitSessionMetrics`
+ * errors propagate so the platform can retry the alarm itself.
*/
async alarm(): Promise {
const metaRows = this.db
@@ -576,38 +776,84 @@ export class SessionIngestDO extends DurableObject {
'closeReason',
'ingestVersion',
'deleted',
+ 'metricsEmitted',
+ 'metricsAlarmAt',
])
)
.all();
const meta = Object.fromEntries(metaRows.map(r => [r.key, r.value]));
- if (meta['deleted'] === 'true') return;
+ if (meta['deleted'] === 'true') {
+ await this.ctx.storage.deleteAlarm();
+ return;
+ }
const kiloUserId = meta['kiloUserId'];
const sessionId = meta['sessionId'];
- if (!kiloUserId || !sessionId) return;
+ if (!kiloUserId || !sessionId) {
+ await this.ctx.storage.deleteAlarm();
+ return;
+ }
+
+ // Park any pending rows whose `next_attempt_at` has crossed the
+ // absolute dispatch window (raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS).
+ // Doing this at alarm start (and again inside `claimNextDispatchable`
+ // for defense in depth) keeps `earliestScheduledAttemptAt` from
+ // pointing the next alarm at a row that can no longer be safely
+ // dispatched under the receiver's 60min idempotency TTL.
+ parkExpiredPendingRows(this.db, { now: Date.now() });
+
+ // Recover any rows left in `in_flight` from a previous alarm that was
+ // lost (DO restart, crash between claim and ack). Each stale row is
+ // bumped back to `pending` with a fresh schedule, or parked at
+ // terminal `failed` if the cap is reached. Downstream dispatch is
+ // keyed on the stable request id, so a recovered row cannot double
+ // push.
+ recoverStaleInFlightRows(this.db, { now: Date.now() });
+
+ await this.dispatchOutboxBatch({ kiloUserId, sessionId });
+
+ // Re-read the metrics state AFTER `dispatchOutboxBatch` returns. The
+ // batch awaits external IO (the notifications RPC), during which a
+ // concurrent ingest can update `metricsAlarmAt` (e.g. a new
+ // session_open resetting the inactivity timer) or another alarm path
+ // can mark `metricsEmitted`. Emitting metrics on stale state read at
+ // the top of `alarm()` would publish prematurely when the immediate
+ // outbox alarm preempted a far-future metrics deadline, or would
+ // double-publish when emission was already done elsewhere.
+ const freshMeta = this.db
+ .select({ key: ingestMeta.key, value: ingestMeta.value })
+ .from(ingestMeta)
+ .where(inArray(ingestMeta.key, ['metricsEmitted', 'metricsAlarmAt']))
+ .all();
+ const freshMetricsEmitted = freshMeta.find(row => row.key === 'metricsEmitted')?.value ?? null;
+ const freshMetricsAlarmAt = freshMeta.find(row => row.key === 'metricsAlarmAt')?.value ?? null;
const closeReason = (meta['closeReason'] ?? 'abandoned') as TerminationReason;
const ingestVersion = Number(meta['ingestVersion'] ?? '0') || 0;
- // DO alarm exceptions don't populate the Exceptions array in logpush traces,
- // so without this catch we get outcome=exception with zero diagnostics.
- try {
- await this.emitSessionMetrics(kiloUserId, sessionId, closeReason, ingestVersion);
- } catch (error) {
- console.error('SessionIngestDO alarm failed', {
- sessionId,
- kiloUserId,
- closeReason,
- ingestVersion,
- error: error instanceof Error ? error.message : String(error),
- stack: error instanceof Error ? error.stack : undefined,
- });
+ if (shouldEmitMetricsFromAttentionAlarm(freshMetricsEmitted, freshMetricsAlarmAt, Date.now())) {
+ // DO alarm exceptions don't populate the Exceptions array in logpush traces,
+ // so without this catch we get outcome=exception with zero diagnostics.
+ try {
+ await this.emitSessionMetrics(kiloUserId, sessionId, closeReason, ingestVersion);
+ } catch (error) {
+ console.error('SessionIngestDO alarm failed', {
+ sessionId,
+ kiloUserId,
+ closeReason,
+ ingestVersion,
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ });
- throw error;
+ throw error;
+ }
}
+
+ await this.rescheduleAlarm();
}
/** Returns true when no ingest data has been stored for this session. */
diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts
index 83fddbf466..ff094fad78 100644
--- a/services/session-ingest/src/dos/UserConnectionDO.test.ts
+++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts
@@ -12,6 +12,14 @@ vi.mock('cloudflare:workers', () => ({
},
}));
+// Mock withDORetry to a no-retry pass-through so attention recording tests do
+// not exercise backoff or internal worker-utils logging.
+vi.mock('@kilocode/worker-utils', () => ({
+ withDORetry: vi.fn(async (getStub: () => T, operation: (stub: T) => Promise) =>
+ operation(getStub())
+ ),
+}));
+
import { MAX_CATALOG_RESULT_BYTES, UserConnectionDO } from './UserConnectionDO';
// ---------------------------------------------------------------------------
@@ -153,7 +161,11 @@ function connectWebSocket(doInstance: UserConnectionDO, connectionId: string): M
return server;
}
-function connectCliSocket(doInstance: UserConnectionDO, connectionId: string): MockWS {
+function connectCliSocket(
+ doInstance: UserConnectionDO,
+ connectionId: string,
+ kiloUserId?: string
+): MockWS {
const client = createMockWs();
const server = createMockWs();
vi.stubGlobal(
@@ -170,8 +182,11 @@ function connectCliSocket(doInstance: UserConnectionDO, connectionId: string): M
}
);
+ const url = new URL(`http://local/cli?connectionId=${connectionId}`);
+ if (kiloUserId) url.searchParams.set('kiloUserId', kiloUserId);
+
doInstance.fetch(
- new Request(`http://local/cli?connectionId=${connectionId}`, {
+ new Request(url.toString(), {
headers: { Upgrade: 'websocket' },
})
);
@@ -182,9 +197,11 @@ function connectCliSocket(doInstance: UserConnectionDO, connectionId: string): M
function addCliSocket(
mockCtx: ReturnType,
connectionId: string,
- sessions: Array<{ id: string; status: string; title: string }> = []
+ sessions: Array<{ id: string; status: string; title: string }> = [],
+ kiloUserId?: string
): MockWS {
- const attachment = { role: 'cli' as const, connectionId, sessions };
+ const attachment: Record = { role: 'cli', connectionId, sessions };
+ if (kiloUserId) attachment.kiloUserId = kiloUserId;
const ws = createMockWs(['cli'], attachment);
mockCtx.addSocket(ws);
return ws;
@@ -2241,4 +2258,245 @@ describe('UserConnectionDO', () => {
expect(server.deserializeAttachment()).toMatchObject({ role: 'cli', kiloUserId: 'usr_1' });
});
});
+
+ // -------------------------------------------------------------------------
+ // Attention outbox relay
+ // -------------------------------------------------------------------------
+
+ describe('attention outbox relay', () => {
+ function setupWithAttentionDO() {
+ const mockCtx = createMockCtx();
+ const ctx = mockCtx.build();
+ const recordAttentionEvent = vi.fn();
+ const env = {
+ SESSION_INGEST_DO: {
+ idFromName: vi.fn((name: string) => name),
+ get: vi.fn(() => ({ recordAttentionEvent })),
+ },
+ };
+ const doInstance = new UserConnectionDO(ctx as never, env as never);
+ return { doInstance, mockCtx, ctx, recordAttentionEvent };
+ }
+
+ function sendCliEvent(
+ doInstance: UserConnectionDO,
+ cliWs: MockWS,
+ opts: { sessionId: string; parentSessionId?: string; event: string; data: unknown }
+ ) {
+ doInstance.webSocketMessage(cliWs as never, JSON.stringify({ type: 'event', ...opts }));
+ }
+
+ it('records a root question raise even with no web subscribers', () => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event: 'question.asked',
+ data: { id: 'q_1' },
+ });
+
+ expect(recordAttentionEvent).toHaveBeenCalledTimes(1);
+ expect(recordAttentionEvent).toHaveBeenCalledWith({
+ kiloUserId: 'usr_1',
+ sessionId: 'ses_1',
+ requestId: 'q_1',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ });
+
+ it('records a root permission raise even with no web subscribers', () => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event: 'permission.asked',
+ data: { requestId: 'p_1' },
+ });
+
+ expect(recordAttentionEvent).toHaveBeenCalledWith({
+ kiloUserId: 'usr_1',
+ sessionId: 'ses_1',
+ requestId: 'p_1',
+ intent: { kind: 'raise', reason: 'permission' },
+ });
+ });
+
+ it('records a root blocking suggestion raise even with no web subscribers', () => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event: 'suggestion.shown',
+ data: { id: 's_1', blocking: true },
+ });
+
+ expect(recordAttentionEvent).toHaveBeenCalledWith({
+ kiloUserId: 'usr_1',
+ sessionId: 'ses_1',
+ requestId: 's_1',
+ intent: { kind: 'raise', reason: 'blocking_suggestion' },
+ });
+ });
+
+ it.each([
+ ['question.replied', 'q_1'],
+ ['question.rejected', 'q_1'],
+ ['permission.replied', 'p_1'],
+ ['suggestion.accepted', 's_1'],
+ ['suggestion.dismissed', 's_1'],
+ ] as const)('resolves a %s event', (event, requestId) => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event,
+ data: { id: requestId },
+ });
+
+ expect(recordAttentionEvent).toHaveBeenCalledWith({
+ kiloUserId: 'usr_1',
+ sessionId: 'ses_1',
+ requestId,
+ intent: { kind: 'resolve' },
+ });
+ });
+
+ it.each([
+ ['nonblocking suggestion', 'suggestion.shown', { id: 's_1', blocking: false }],
+ ['missing request id', 'question.asked', {}],
+ ['network event', 'session.network.asked', { id: 'nw_1' }],
+ ['retry event', 'session.retry', { id: 'r_1' }],
+ ['action_required event', 'action_required', { id: 'ar_1' }],
+ ] as const)('skips a %s', (_label, event, data) => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event,
+ data,
+ });
+
+ expect(recordAttentionEvent).not.toHaveBeenCalled();
+ });
+
+ it('skips child events and still relays them to subscribers', () => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+ const webWs = addWebSocket(mockCtx, 'web-1', ['child_1']);
+
+ const eventData = { id: 'q_1', properties: { question: 'what?' } };
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'child_1',
+ parentSessionId: 'parent_1',
+ event: 'question.asked',
+ data: eventData,
+ });
+
+ expect(recordAttentionEvent).not.toHaveBeenCalled();
+ expect(parseSent(webWs)).toEqual({
+ type: 'event',
+ sessionId: 'child_1',
+ parentSessionId: 'parent_1',
+ event: 'question.asked',
+ data: eventData,
+ });
+ });
+
+ it('skips recording when the CLI attachment has no kiloUserId', () => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const webWs = addWebSocket(mockCtx, 'web-1', ['ses_1']);
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event: 'question.asked',
+ data: { id: 'q_1' },
+ });
+
+ expect(recordAttentionEvent).not.toHaveBeenCalled();
+ expect(parseSent(webWs)).toMatchObject({ event: 'question.asked' });
+ });
+
+ it('relays the exact event and data to web subscribers', () => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+ const webWs = addWebSocket(mockCtx, 'web-1', ['ses_1']);
+
+ const eventData = { id: 'q_1', nested: { value: 'keep-me' } };
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event: 'question.asked',
+ data: eventData,
+ });
+
+ expect(recordAttentionEvent).toHaveBeenCalled();
+ expect(parseSent(webWs)).toEqual({
+ type: 'event',
+ sessionId: 'ses_1',
+ event: 'question.asked',
+ data: eventData,
+ });
+ });
+
+ it('does not block relay when recording rejects', () => {
+ const { doInstance, mockCtx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+ const webWs = addWebSocket(mockCtx, 'web-1', ['ses_1']);
+ recordAttentionEvent.mockRejectedValue(new Error('outbox failure'));
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event: 'question.asked',
+ data: { id: 'q_1' },
+ });
+
+ expect(parseSent(webWs)).toMatchObject({ event: 'question.asked' });
+ });
+
+ it('logs recording failures without request id, payload, or error', async () => {
+ const { doInstance, mockCtx, ctx, recordAttentionEvent } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+ const webWs = addWebSocket(mockCtx, 'web-1', ['ses_1']);
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ recordAttentionEvent.mockRejectedValue(new Error('secret-outbox-error'));
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event: 'question.asked',
+ data: { id: 'q_1', body: 'secret-payload' },
+ });
+
+ const waitUntilPromise = ctx.waitUntil.mock.calls[0][0] as Promise;
+ await waitUntilPromise;
+
+ const errorCall = errorSpy.mock.calls.find(
+ call => call[0] === 'Failed to record attention event (non-fatal)'
+ );
+ expect(errorCall).toBeDefined();
+ const loggedArgs = JSON.stringify(errorCall);
+ expect(loggedArgs).not.toContain('q_1');
+ expect(loggedArgs).not.toContain('secret-payload');
+ expect(loggedArgs).not.toContain('secret-outbox-error');
+ expect(parseSent(webWs)).toMatchObject({ event: 'question.asked' });
+ });
+
+ it('schedules attention recording via waitUntil', () => {
+ const { doInstance, mockCtx, ctx } = setupWithAttentionDO();
+ const cliWs = addCliSocket(mockCtx, 'cli-1', [], 'usr_1');
+ ctx.waitUntil.mockClear();
+
+ sendCliEvent(doInstance, cliWs, {
+ sessionId: 'ses_1',
+ event: 'question.asked',
+ data: { id: 'q_1' },
+ });
+
+ expect(ctx.waitUntil).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts
index e79014dc7f..4fe8a5f6b1 100644
--- a/services/session-ingest/src/dos/UserConnectionDO.ts
+++ b/services/session-ingest/src/dos/UserConnectionDO.ts
@@ -1,7 +1,9 @@
import { DurableObject } from 'cloudflare:workers';
+import { withDORetry } from '@kilocode/worker-utils';
import type { Env } from '../env';
import { getSessionIngestDO } from './SessionIngestDO';
+import { extractAttentionSignal, type AttentionIntent } from '../attention-outbox';
import {
CLIOutboundMessageSchema,
type CLIInboundMessage,
@@ -324,7 +326,13 @@ export class UserConnectionDO extends DurableObject {
this.handleHeartbeat(ws, attachment, msg.sessions, msg.protocolVersion);
break;
case 'event':
- this.handleCliEvent(msg.sessionId, msg.parentSessionId, msg.event, msg.data);
+ this.handleCliEvent(
+ msg.sessionId,
+ msg.parentSessionId,
+ msg.event,
+ msg.data,
+ attachment.kiloUserId
+ );
break;
case 'response':
this.handleCliResponse(ws, msg.id, msg.result, msg.error);
@@ -435,8 +443,20 @@ export class UserConnectionDO extends DurableObject {
sessionId: string,
parentSessionId: string | undefined,
event: string,
- data: unknown
+ data: unknown,
+ kiloUserId: string | undefined
): void {
+ // Record human-attention signals durably BEFORE the no-subscriber
+ // early return so a session that briefly has no web subscribers
+ // (race on subscribe) still gets a push. Child events are skipped:
+ // mobile cannot safely answer requests originating from a subagent.
+ if (!parentSessionId) {
+ const signal = extractAttentionSignal(event, data);
+ if (signal !== null) {
+ this.recordAttentionEvent(kiloUserId, sessionId, signal.intent, signal.requestId);
+ }
+ }
+
const childSubs = this.webSubscriptions.get(sessionId);
const parentSubs = parentSessionId ? this.webSubscriptions.get(parentSessionId) : undefined;
if (!childSubs && !parentSubs) return;
@@ -458,6 +478,47 @@ export class UserConnectionDO extends DurableObject {
}
}
+ /**
+ * Fire-and-forget outbox write for a human-attention signal. When
+ * `kiloUserId` is unavailable (legacy attachment) the relay continues
+ * unchanged but no push is recorded — the in-session UI is still
+ * forwarded to any web subscribers by the caller. The DO's `accepted:false`
+ * outcome is a terminal structural no-op (deleted/invalid session) and
+ * is intentionally not logged to avoid per-event noise; thrown availability
+ * failures retain fixed-code logging. The request id, prompt content, and
+ * arbitrary thrown message text are never written to logs.
+ */
+ private recordAttentionEvent(
+ kiloUserId: string | undefined,
+ sessionId: string,
+ intent: AttentionIntent,
+ requestId: string
+ ): void {
+ if (!kiloUserId) return;
+ this.ctx.waitUntil(
+ withDORetry(
+ () => getSessionIngestDO(this.env, { kiloUserId, sessionId }),
+ async stub => {
+ await stub.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId,
+ intent:
+ intent.kind === 'raise'
+ ? { kind: 'raise', reason: intent.reason }
+ : { kind: 'resolve' },
+ });
+ },
+ 'SessionIngestDO.recordAttentionEvent'
+ ).catch(() => {
+ console.error('Failed to record attention event (non-fatal)', {
+ sessionId,
+ code: 'record_attention_failed',
+ });
+ })
+ );
+ }
+
private handleCliResponse(
respondingWs: WebSocket,
id: string,
diff --git a/services/session-ingest/src/dos/attention-alarm.test.ts b/services/session-ingest/src/dos/attention-alarm.test.ts
new file mode 100644
index 0000000000..098130d616
--- /dev/null
+++ b/services/session-ingest/src/dos/attention-alarm.test.ts
@@ -0,0 +1,128 @@
+/**
+ * Tests for the shared alarm scheduler that coordinates the attention outbox
+ * with the session metrics lifecycle.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { ingestMeta, attentionOutbox } from '../db/sqlite-schema';
+import {
+ computeNextAlarmTime,
+ minPendingAlarmTime,
+ shouldEmitMetricsFromAttentionAlarm,
+} from './attention-alarm';
+
+describe('minPendingAlarmTime', () => {
+ it('returns only metrics when outbox is null', () => {
+ expect(minPendingAlarmTime(1000, false, null)).toBe(1000);
+ });
+
+ it('returns only outbox when metrics is null', () => {
+ expect(minPendingAlarmTime(null, false, 2000)).toBe(2000);
+ });
+
+ it('returns the minimum when both are present', () => {
+ expect(minPendingAlarmTime(5000, false, 2000)).toBe(2000);
+ expect(minPendingAlarmTime(1000, false, 2000)).toBe(1000);
+ });
+
+ it('ignores metrics when metricsEmitted is true', () => {
+ expect(minPendingAlarmTime(1000, true, null)).toBeNull();
+ expect(minPendingAlarmTime(1000, true, 2000)).toBe(2000);
+ });
+
+ it('ignores metrics when metricsEmitted is the string "true"', () => {
+ expect(minPendingAlarmTime(1000, 'true', null)).toBeNull();
+ expect(minPendingAlarmTime(1000, 'true', 2000)).toBe(2000);
+ });
+
+ it('ignores invalid numeric metricsAlarmAt values', () => {
+ expect(minPendingAlarmTime(NaN, false, null)).toBeNull();
+ expect(minPendingAlarmTime('', false, null)).toBeNull();
+ expect(minPendingAlarmTime('invalid', false, null)).toBeNull();
+ expect(minPendingAlarmTime(0, false, null)).toBeNull();
+ expect(minPendingAlarmTime(-1, false, null)).toBeNull();
+ expect(minPendingAlarmTime(undefined, false, null)).toBeNull();
+ });
+
+ it('returns null when both inputs are absent', () => {
+ expect(minPendingAlarmTime(null, false, null)).toBeNull();
+ expect(minPendingAlarmTime(undefined, false, null)).toBeNull();
+ });
+});
+
+describe('computeNextAlarmTime', () => {
+ it('wires DB meta and outbox to minPendingAlarmTime', () => {
+ const metaRows = [
+ { key: 'metricsAlarmAt', value: '5000' },
+ { key: 'metricsEmitted', value: 'false' },
+ ];
+ const outboxRow = { next_attempt_at: 2000 };
+
+ let currentTable: string | null = null;
+ const selectChain = {
+ from: (table: unknown) => {
+ currentTable =
+ table === ingestMeta
+ ? 'ingest_meta'
+ : table === attentionOutbox
+ ? 'attention_outbox'
+ : null;
+ return selectChain;
+ },
+ where: () => selectChain,
+ orderBy: () => selectChain,
+ limit: () => selectChain,
+ all: () => (currentTable === 'ingest_meta' ? metaRows : []),
+ get: () => (currentTable === 'attention_outbox' ? outboxRow : undefined),
+ };
+
+ const db = {
+ select: () => selectChain,
+ };
+
+ expect(computeNextAlarmTime(db as never)).toBe(2000);
+ });
+});
+
+describe('shouldEmitMetricsFromAttentionAlarm', () => {
+ // Pure helper used by the alarm() body to decide whether the immediate
+ // attention alarm should also drive a metrics emission. It must ignore
+ // calls when the persisted metrics deadline is still in the future or
+ // missing entirely (legacy platform alarms without a persisted deadline).
+ const NOW = 1_700_000_000_000;
+
+ it('emits when metricsAlarmAt is finite positive and <= now', () => {
+ expect(shouldEmitMetricsFromAttentionAlarm('false', String(NOW - 1), NOW)).toBe(true);
+ expect(shouldEmitMetricsFromAttentionAlarm('false', String(NOW), NOW)).toBe(true);
+ });
+
+ it('skips when metricsAlarmAt is strictly in the future', () => {
+ expect(shouldEmitMetricsFromAttentionAlarm('false', String(NOW + 1), NOW)).toBe(false);
+ expect(shouldEmitMetricsFromAttentionAlarm(null, String(NOW + 60_000), NOW)).toBe(false);
+ });
+
+ it('skips when metricsEmitted is already true', () => {
+ expect(shouldEmitMetricsFromAttentionAlarm('true', String(NOW - 100), NOW)).toBe(false);
+ });
+
+ it('skips when metricsAlarmAt is missing (legacy platform alarm)', () => {
+ expect(shouldEmitMetricsFromAttentionAlarm('false', null, NOW)).toBe(false);
+ expect(shouldEmitMetricsFromAttentionAlarm(null, null, NOW)).toBe(false);
+ });
+
+ it('skips when metricsAlarmAt is non-numeric or non-positive', () => {
+ expect(shouldEmitMetricsFromAttentionAlarm('false', 'not-a-number', NOW)).toBe(false);
+ expect(shouldEmitMetricsFromAttentionAlarm('false', '', NOW)).toBe(false);
+ expect(shouldEmitMetricsFromAttentionAlarm('false', '0', NOW)).toBe(false);
+ expect(shouldEmitMetricsFromAttentionAlarm('false', '-1', NOW)).toBe(false);
+ });
+
+ it('treats the boolean-true metricsEmitted as already emitted (defense in depth)', () => {
+ // The DO reads metricsEmitted as a string column; this guard makes the
+ // helper safe if a future refactor passes the raw row value through.
+ expect(
+ shouldEmitMetricsFromAttentionAlarm(true as unknown as string, String(NOW - 1), NOW)
+ ).toBe(false);
+ });
+});
diff --git a/services/session-ingest/src/dos/attention-alarm.ts b/services/session-ingest/src/dos/attention-alarm.ts
new file mode 100644
index 0000000000..bcb8c5e31d
--- /dev/null
+++ b/services/session-ingest/src/dos/attention-alarm.ts
@@ -0,0 +1,99 @@
+/**
+ * Shared alarm scheduling for the attention outbox and session metrics lifecycle.
+ *
+ * The DO uses one alarm for both concerns so the outbox can preempt the
+ * metrics drain and vice versa. The helpers here are intentionally pure
+ * except for `computeNextAlarmTime`, which is a thin wrapper that reads the
+ * current DB state and delegates to the testable `minPendingAlarmTime` rule.
+ */
+
+import { inArray } from 'drizzle-orm';
+import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite';
+
+import { ingestMeta } from '../db/sqlite-schema';
+import { earliestScheduledAttemptAt } from '../attention-outbox-store';
+
+/**
+ * Pure: pick the earliest pending alarm time from the metrics alarm and the
+ * outbox schedule. Ignores metrics when they have already emitted, and
+ * ignores non-numeric or non-positive alarm times.
+ */
+export function minPendingAlarmTime(
+ metricsAlarmAt: number | string | null | undefined,
+ metricsEmitted: boolean | string | null | undefined,
+ outboxAt: number | null
+): number | null {
+ const candidates: number[] = [];
+
+ if (outboxAt !== null && Number.isFinite(outboxAt)) {
+ candidates.push(outboxAt);
+ }
+
+ if (metricsEmitted !== true && metricsEmitted !== 'true') {
+ const parsed = typeof metricsAlarmAt === 'number' ? metricsAlarmAt : Number(metricsAlarmAt);
+ if (Number.isFinite(parsed) && parsed > 0) {
+ candidates.push(parsed);
+ }
+ }
+
+ if (candidates.length === 0) return null;
+ return Math.min(...candidates);
+}
+
+/**
+ * Read the current session state and return the earliest alarm time that
+ * still has work pending. Returns null when both metrics and outbox are idle.
+ */
+export function computeNextAlarmTime(db: DrizzleSqliteDODatabase): number | null {
+ const metaRows = db
+ .select()
+ .from(ingestMeta)
+ .where(inArray(ingestMeta.key, ['metricsAlarmAt', 'metricsEmitted']))
+ .all();
+ const meta = Object.fromEntries(metaRows.map(r => [r.key, r.value]));
+ const outboxAt = earliestScheduledAttemptAt(db);
+
+ return minPendingAlarmTime(
+ meta['metricsAlarmAt'] !== undefined ? Number(meta['metricsAlarmAt']) : null,
+ meta['metricsEmitted'],
+ outboxAt
+ );
+}
+
+/**
+ * Decide whether the immediate-attention outbox alarm should additionally
+ * drive a session-metrics emission on the current tick.
+ *
+ * The attention outbox and the session-metrics lifecycle share one DO alarm
+ * so the outbox can preempt the metrics timer. When the alarm fires
+ * because a raise was recorded, the metrics deadline is almost always still
+ * in the future; emitting at that tick would publish metrics prematurely.
+ *
+ * The DO re-reads `metricsEmitted` and `metricsAlarmAt` from SQLite after
+ * `dispatchOutboxBatch` returns so a concurrent update that arrives during
+ * the awaited notification RPC is respected. This helper encapsulates the
+ * decision so the alarm body stays small and the rule is unit-testable.
+ *
+ * Rules:
+ * - `metricsEmitted === 'true'` → skip (already published).
+ * - `metricsAlarmAt` missing or non-numeric → skip (legacy platform alarm
+ * that reached the DO without a persisted deadline; emitting now would
+ * be premature).
+ * - `metricsAlarmAt <= 0` → skip (non-positive deadline is invalid).
+ * - `metricsAlarmAt > now` → skip (deadline still in the future; preserve).
+ * - otherwise → emit.
+ */
+export function shouldEmitMetricsFromAttentionAlarm(
+ metricsEmitted: string | null | boolean,
+ metricsAlarmAt: string | null,
+ now: number
+): boolean {
+ // Defense in depth: the DO reads `metricsEmitted` as a string column,
+ // but accept the boolean `true` as well so a future refactor that
+ // passes the parsed value through stays safe.
+ if (metricsEmitted === 'true' || metricsEmitted === true) return false;
+ if (metricsAlarmAt === null || metricsAlarmAt === undefined) return false;
+ const parsed = Number(metricsAlarmAt);
+ if (!Number.isFinite(parsed) || parsed <= 0) return false;
+ return parsed <= now;
+}
diff --git a/services/session-ingest/src/dos/attention-event-input.test.ts b/services/session-ingest/src/dos/attention-event-input.test.ts
new file mode 100644
index 0000000000..94268f36cb
--- /dev/null
+++ b/services/session-ingest/src/dos/attention-event-input.test.ts
@@ -0,0 +1,113 @@
+/**
+ * Tests for the runtime-validated input of `SessionIngestDO.recordAttentionEvent`.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { recordAttentionEventInputSchema } from './attention-event-input';
+
+function validBase() {
+ return {
+ kiloUserId: 'u_1',
+ sessionId: 's_1',
+ requestId: 'r_1',
+ };
+}
+
+describe('recordAttentionEventInputSchema', () => {
+ it('accepts a valid raise intent', () => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.intent).toEqual({ kind: 'raise', reason: 'question' });
+ }
+ });
+
+ it.each(['question', 'permission', 'blocking_suggestion'] as const)(
+ 'accepts a valid resolve intent with reason %s',
+ reason => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'resolve', reason },
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).toEqual({
+ ...validBase(),
+ intent: { kind: 'resolve', reason },
+ });
+ }
+ }
+ );
+
+ it('rejects a resolve intent without a reason', () => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'resolve' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects a resolve intent with an unknown reason', () => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'resolve', reason: 'action_required' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects a resolve intent with a non-string reason', () => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'resolve', reason: 123 },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects an unknown reason', () => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'raise', reason: 'unknown' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects an empty reason', () => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'raise', reason: '' },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects an invalid reason type', () => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'raise', reason: 123 },
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('strips any raw body or envelope payload', () => {
+ const result = recordAttentionEventInputSchema.safeParse({
+ ...validBase(),
+ intent: { kind: 'raise', reason: 'question' },
+ body: 'raw prompt text that must be stripped',
+ promptText: 'another raw field',
+ envelope: { id: 'e_1', payload: 'secret' },
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).not.toHaveProperty('body');
+ expect(result.data).not.toHaveProperty('promptText');
+ expect(result.data).not.toHaveProperty('envelope');
+ expect(result.data).toEqual({
+ ...validBase(),
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ }
+ });
+});
diff --git a/services/session-ingest/src/dos/attention-event-input.ts b/services/session-ingest/src/dos/attention-event-input.ts
new file mode 100644
index 0000000000..7225ca4f74
--- /dev/null
+++ b/services/session-ingest/src/dos/attention-event-input.ts
@@ -0,0 +1,34 @@
+/**
+ * Runtime-validated input for `SessionIngestDO.recordAttentionEvent`.
+ *
+ * The DO only accepts IDs and a classified raise/resolve intent — it never
+ * sees prompt text, permission arguments, or any other envelope payload. The
+ * notifications service renders copy and looks up presence; this DO is
+ * responsible for turning the producer's per-request signal into a durable
+ * outbox row.
+ */
+import { z } from 'zod';
+
+import { attentionReasonSchema } from '../attention-outbox';
+
+// The resolve intent must carry the same reason the matching raise would
+// have. Without a reason, an out-of-order resolve arriving before a raise
+// cannot insert a tombstone with the correct `reason`, and the
+// outbox-level "preserve original reason" rule has nothing to compare
+// against. `action_required` is intentionally excluded — the shared
+// reason stays available in the schema, but the raw Kilo contract does
+// not yet expose a stable request id and genuinely user-actionable typed
+// action, so the producer ignores it.
+export const attentionIntentSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('raise'), reason: attentionReasonSchema }),
+ z.object({ kind: z.literal('resolve'), reason: attentionReasonSchema }),
+]);
+export type AttentionIntentInput = z.infer;
+
+export const recordAttentionEventInputSchema = z.object({
+ kiloUserId: z.string().min(1),
+ sessionId: z.string().min(1),
+ requestId: z.string().min(1),
+ intent: attentionIntentSchema,
+});
+export type RecordAttentionEventInput = z.infer;
diff --git a/services/session-ingest/src/notifications-binding.ts b/services/session-ingest/src/notifications-binding.ts
index deb6749e6d..beb5765d56 100644
--- a/services/session-ingest/src/notifications-binding.ts
+++ b/services/session-ingest/src/notifications-binding.ts
@@ -7,6 +7,8 @@
*/
import type {
+ SendSessionAttentionNotificationParams,
+ SendSessionAttentionNotificationResult,
SendSessionReadyNotificationParams,
SendSessionReadyNotificationResult,
} from '@kilocode/notifications';
@@ -15,4 +17,7 @@ export type NotificationsBinding = Fetcher & {
sendSessionReadyNotification(
params: SendSessionReadyNotificationParams
): Promise;
+ sendSessionAttentionNotification(
+ params: SendSessionAttentionNotificationParams
+ ): Promise;
};
diff --git a/services/session-ingest/src/session-ingest-rpc.test.ts b/services/session-ingest/src/session-ingest-rpc.test.ts
index 0a2c549544..b1f55c448b 100644
--- a/services/session-ingest/src/session-ingest-rpc.test.ts
+++ b/services/session-ingest/src/session-ingest-rpc.test.ts
@@ -51,6 +51,7 @@ import {
} from '@kilocode/session-ingest-contracts';
import { desc, gte, isNotNull, or } from 'drizzle-orm';
import { getSessionIngestDO } from './dos/SessionIngestDO';
+import type { RecordAttentionEventInput } from './dos/attention-event-input';
import { SessionIngestRPC } from './session-ingest-rpc';
const sdkSessionInfoFixture = {
@@ -922,3 +923,147 @@ describe('SessionIngestRPC.listCloudAgentRootSessions', () => {
expect(db.select).not.toHaveBeenCalled();
});
});
+
+describe('SessionIngestRPC.recordCloudAgentSessionAttention', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('maps kiloSessionId to the DO sessionId and forwards only safe fields for a raise', async () => {
+ const { db } = makeDbFakes([]);
+ const recordAttentionEvent = vi.fn(async () => ({ accepted: true }) as const);
+ const ingestStub = { recordAttentionEvent };
+ vi.mocked(getSessionIngestDO).mockReturnValue(ingestStub as never);
+ const rpc = makeRpc(db);
+
+ const result = await rpc.recordCloudAgentSessionAttention({
+ kiloUserId: 'usr_owner',
+ kiloSessionId: sdkSessionInfoFixture.id,
+ requestId: 'req_attn_01',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ expect(result).toEqual({ accepted: true });
+ expect(getSessionIngestDO).toHaveBeenCalledWith(expect.anything(), {
+ kiloUserId: 'usr_owner',
+ sessionId: sdkSessionInfoFixture.id,
+ });
+ expect(recordAttentionEvent).toHaveBeenCalledTimes(1);
+ expect(recordAttentionEvent).toHaveBeenCalledWith({
+ kiloUserId: 'usr_owner',
+ sessionId: sdkSessionInfoFixture.id,
+ requestId: 'req_attn_01',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ });
+
+ it('forwards a resolve intent with the reason field', async () => {
+ const { db } = makeDbFakes([]);
+ const recordAttentionEvent = vi.fn(async () => ({ accepted: true }) as const);
+ vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never);
+ const rpc = makeRpc(db);
+
+ const result = await rpc.recordCloudAgentSessionAttention({
+ kiloUserId: 'usr_owner',
+ kiloSessionId: sdkSessionInfoFixture.id,
+ requestId: 'req_attn_02',
+ intent: { kind: 'resolve', reason: 'question' },
+ });
+
+ expect(result).toEqual({ accepted: true });
+ expect(recordAttentionEvent).toHaveBeenCalledWith({
+ kiloUserId: 'usr_owner',
+ sessionId: sdkSessionInfoFixture.id,
+ requestId: 'req_attn_02',
+ intent: { kind: 'resolve', reason: 'question' },
+ });
+ });
+
+ it('returns accepted false without calling the DO when IDs are invalid', async () => {
+ const { db } = makeDbFakes([]);
+ const recordAttentionEvent = vi.fn();
+ vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never);
+ const rpc = makeRpc(db);
+
+ const result = await rpc.recordCloudAgentSessionAttention({
+ kiloUserId: 'usr_owner',
+ kiloSessionId: 'not-a-session',
+ requestId: 'req_attn_01',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ expect(result).toEqual({ accepted: false, reason: 'invalid_input' });
+ expect(getSessionIngestDO).not.toHaveBeenCalled();
+ expect(recordAttentionEvent).not.toHaveBeenCalled();
+ });
+
+ it('returns accepted false without calling the DO when the reason is invalid', async () => {
+ const { db } = makeDbFakes([]);
+ const recordAttentionEvent = vi.fn();
+ vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never);
+ const rpc = makeRpc(db);
+
+ const result = await rpc.recordCloudAgentSessionAttention({
+ kiloUserId: 'usr_owner',
+ kiloSessionId: sdkSessionInfoFixture.id,
+ requestId: 'req_attn_01',
+ // Cast through unknown so the test exercises the runtime validator, not
+ // the compile-time type.
+ intent: { kind: 'raise', reason: 'blocking_suggestion' },
+ } as never);
+
+ expect(result).toEqual({ accepted: false, reason: 'invalid_input' });
+ expect(getSessionIngestDO).not.toHaveBeenCalled();
+ expect(recordAttentionEvent).not.toHaveBeenCalled();
+ });
+
+ it('does not forward extra body or envelope fields to the DO', async () => {
+ const { db } = makeDbFakes([]);
+ const recordAttentionEvent = vi.fn(
+ async (_input: RecordAttentionEventInput) => ({ accepted: true }) as const
+ );
+ vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never);
+ const rpc = makeRpc(db);
+
+ const result = await rpc.recordCloudAgentSessionAttention({
+ kiloUserId: 'usr_owner',
+ kiloSessionId: sdkSessionInfoFixture.id,
+ requestId: 'req_attn_01',
+ intent: { kind: 'raise', reason: 'question' },
+ // The contract schema strips these out — the DO must never see them.
+ body: 'raw prompt text that must be stripped',
+ permissionArgs: { command: 'rm -rf /' },
+ } as never);
+
+ expect(result).toEqual({ accepted: true });
+ expect(recordAttentionEvent).toHaveBeenCalledTimes(1);
+ const forwarded = recordAttentionEvent.mock.calls[0]?.[0];
+ expect(forwarded).toEqual({
+ kiloUserId: 'usr_owner',
+ sessionId: sdkSessionInfoFixture.id,
+ requestId: 'req_attn_01',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ expect(forwarded).not.toHaveProperty('body');
+ expect(forwarded).not.toHaveProperty('permissionArgs');
+ });
+
+ it('preserves a DO-returned accepted false reason through the normalized result', async () => {
+ const { db } = makeDbFakes([]);
+ const recordAttentionEvent = vi.fn(
+ async () => ({ accepted: false, reason: 'deleted' }) as const
+ );
+ vi.mocked(getSessionIngestDO).mockReturnValue({ recordAttentionEvent } as never);
+ const rpc = makeRpc(db);
+
+ const result = await rpc.recordCloudAgentSessionAttention({
+ kiloUserId: 'usr_owner',
+ kiloSessionId: sdkSessionInfoFixture.id,
+ requestId: 'req_attn_01',
+ intent: { kind: 'resolve', reason: 'permission' },
+ });
+
+ expect(result).toEqual({ accepted: false, reason: 'deleted' });
+ expect(recordAttentionEvent).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/services/session-ingest/src/session-ingest-rpc.ts b/services/session-ingest/src/session-ingest-rpc.ts
index 34252535a7..67db79963a 100644
--- a/services/session-ingest/src/session-ingest-rpc.ts
+++ b/services/session-ingest/src/session-ingest-rpc.ts
@@ -9,6 +9,7 @@ import {
kiloSdkSessionSnapshotOutcomeSchema,
listCloudAgentRootSessionsSchema,
persistedKiloSdkMessageHistorySchema,
+ recordCloudAgentSessionAttentionSchema,
resolveCloudAgentRootSessionSchema,
type CloudAgentRootSessionSnapshot,
type CloudAgentRootSessionSummary,
@@ -19,6 +20,8 @@ import {
type GetCloudAgentRootSessionSnapshotParams,
type GetCloudAgentRootSessionSnapshotResult,
type ListCloudAgentRootSessionsParams,
+ type RecordCloudAgentSessionAttentionParams,
+ type RecordCloudAgentSessionAttentionResult,
type ResolveCloudAgentRootSessionForKiloSessionParams,
type ResolveCloudAgentRootSessionForKiloSessionResult,
type SessionIngestRpcMethods,
@@ -396,4 +399,46 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn
});
}
}
+
+ /**
+ * RPC method: record a Cloud Agent attention signal (question/permission
+ * raise or resolve) for the per-session SessionIngestDO. Used by the
+ * cloud-agent-next attention adapter to forward wrapper events after
+ * the wrapper's policy filter has already suppressed auto-approved and
+ * plan-followup/code-review events.
+ *
+ * Maps the Cloud Agent boundary (`kiloSessionId`) to the DO's
+ * `sessionId` field. The DO validates the rest of the payload against
+ * `recordAttentionEventInputSchema` and owns the durable outbox plus
+ * notification dispatch; the RPC is a thin shim that returns the
+ * accepted-or-not outcome the adapter forwards to its logs.
+ */
+ async recordCloudAgentSessionAttention(
+ params: RecordCloudAgentSessionAttentionParams
+ ): Promise {
+ const parsed = recordCloudAgentSessionAttentionSchema.safeParse(params);
+ if (!parsed.success) {
+ return { accepted: false, reason: 'invalid_input' };
+ }
+ return withDORetry(
+ () =>
+ getSessionIngestDO(this.env, {
+ kiloUserId: parsed.data.kiloUserId,
+ sessionId: parsed.data.kiloSessionId,
+ }),
+ async stub => {
+ const result = await stub.recordAttentionEvent({
+ kiloUserId: parsed.data.kiloUserId,
+ sessionId: parsed.data.kiloSessionId,
+ requestId: parsed.data.requestId,
+ intent:
+ parsed.data.intent.kind === 'raise'
+ ? { kind: 'raise', reason: parsed.data.intent.reason }
+ : { kind: 'resolve', reason: parsed.data.intent.reason },
+ });
+ return result.accepted ? { accepted: true } : { accepted: false, reason: result.reason };
+ },
+ 'SessionIngestDO.recordAttentionEvent'
+ );
+ }
}
diff --git a/services/session-ingest/test/integration/session-attention-outbox.test.ts b/services/session-ingest/test/integration/session-attention-outbox.test.ts
new file mode 100644
index 0000000000..405df6be99
--- /dev/null
+++ b/services/session-ingest/test/integration/session-attention-outbox.test.ts
@@ -0,0 +1,1351 @@
+import { env, runInDurableObject } from 'cloudflare:test';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite';
+import type {
+ SendSessionAttentionNotificationParams,
+ SendSessionAttentionNotificationResult,
+} from '@kilocode/notifications';
+import {
+ MAX_ATTEMPTS,
+ ATTENTION_MAX_DISPATCH_AGE_MS,
+ dispatchDeadline,
+} from '../../src/attention-outbox';
+import { markRetry } from '../../src/attention-outbox-store';
+
+type OutboxRow = {
+ request_id: string;
+ reason: string;
+ status: string;
+ attempt_count: number;
+ next_attempt_at: number | null;
+ last_error: string | null;
+ raised_at: number;
+ resolved_at: number | null;
+};
+
+type MockO11Y = {
+ ingestSessionMetrics: ReturnType;
+};
+
+type MockNotifications = {
+ sendSessionAttentionNotification: ReturnType;
+ sendSessionReadyNotification: ReturnType;
+};
+
+function getStub(kiloUserId: string, sessionId: string) {
+ const doKey = `${kiloUserId}/${sessionId}`;
+ const id = env.SESSION_INGEST_DO.idFromName(doKey);
+ return env.SESSION_INGEST_DO.get(id);
+}
+
+function readOutboxRows(state: DurableObjectState): OutboxRow[] {
+ return [
+ ...state.storage.sql.exec('SELECT * FROM attention_outbox ORDER BY request_id'),
+ ];
+}
+
+function readMeta(state: DurableObjectState, key: string): string | null {
+ const cursor = state.storage.sql.exec<{ value: string | null }>(
+ 'SELECT value FROM ingest_meta WHERE key = ?',
+ key
+ );
+ const row = cursor.next().value;
+ return row?.value ?? null;
+}
+
+function getStoreDb(instance: DurableObject): DrizzleSqliteDODatabase {
+ return (instance as unknown as { db: DrizzleSqliteDODatabase }).db;
+}
+
+function makeMockNotifications(
+ calls: SendSessionAttentionNotificationParams[],
+ behavior: (
+ params: SendSessionAttentionNotificationParams
+ ) => Promise = async () => ({
+ dispatched: true,
+ })
+): MockNotifications {
+ return {
+ sendSessionAttentionNotification: vi.fn(
+ async (params: SendSessionAttentionNotificationParams) => {
+ calls.push(params);
+ return behavior(params);
+ }
+ ),
+ sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })),
+ };
+}
+
+function makeMockO11Y(): MockO11Y {
+ return {
+ ingestSessionMetrics: vi.fn(async () => {}),
+ };
+}
+
+function createDeferred() {
+ let resolve: (value: T) => void = () => {};
+ let reject: (reason: unknown) => void = () => {};
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function installMocks(instance: DurableObject, mock: MockNotifications, o11y: MockO11Y) {
+ const typed = instance as unknown as {
+ env: { NOTIFICATIONS: MockNotifications; O11Y: MockO11Y };
+ };
+ typed.env.NOTIFICATIONS = mock;
+ typed.env.O11Y = o11y;
+}
+
+function runAlarm(instance: DurableObject): Promise {
+ return (instance as unknown as { alarm: () => Promise }).alarm();
+}
+
+const kiloUserId = 'usr_attention_integration';
+
+async function seedSession(stub: ReturnType, sessionId: string): Promise {
+ return runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await instance.ingest(
+ [{ type: 'session', data: { title: 'Attention Test Session' } }],
+ kiloUserId,
+ sessionId,
+ 1
+ );
+ await state.storage.deleteAlarm();
+ });
+}
+
+describe('SessionIngestDO attention outbox integration', () => {
+ const createdSessionIds: string[] = [];
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(async () => {
+ vi.useRealTimers();
+ for (const sessionId of createdSessionIds) {
+ const stub = getStub(kiloUserId, sessionId);
+ // Delete any scheduled alarm before clearing so cleanup cannot trigger an
+ // alarm with real (or absent) bindings installed.
+ await runInDurableObject(stub, async (_instance, state) => {
+ await state.storage.deleteAlarm();
+ });
+ await stub.clear();
+ }
+ createdSessionIds.length = 0;
+ });
+
+ function trackSession(sessionId: string) {
+ createdSessionIds.push(sessionId);
+ return sessionId;
+ }
+
+ it('records one outbox row for duplicate raises and dispatches on alarm', async () => {
+ const sessionId = trackSession('ses_attention_duplicate_resolve');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_dup_resolve',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_dup_resolve',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ await runAlarm(instance);
+ const rows = readOutboxRows(state);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ request_id: 'req_dup_resolve',
+ status: 'dispatched',
+ attempt_count: 0,
+ });
+ });
+ });
+
+ it('keeps distinct request ids as separate rows', async () => {
+ const sessionId = trackSession('ses_attention_distinct');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_a',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_b',
+ intent: { kind: 'raise', reason: 'permission' },
+ });
+ await runAlarm(instance);
+ const rows = readOutboxRows(state);
+ expect(rows).toHaveLength(2);
+ expect(rows.map(r => r.request_id)).toEqual(['req_a', 'req_b']);
+ expect(rows.every(r => r.status === 'dispatched')).toBe(true);
+ });
+ });
+
+ it('resolves a pending request before dispatch and no-ops on re-raise', async () => {
+ const sessionId = trackSession('ses_attention_resolve_first');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_resolved_first',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_resolved_first',
+ intent: { kind: 'resolve', reason: 'question' },
+ });
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_resolved_first',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ await runAlarm(instance);
+ const rows = readOutboxRows(state);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ request_id: 'req_resolved_first',
+ status: 'resolved',
+ resolved_at: expect.any(Number),
+ });
+ expect(calls).toHaveLength(0);
+ });
+ });
+
+ // Kilobot finding 4: an unknown resolve arriving before a raise must
+ // persist a terminal resolved tombstone so a later raise cannot notify.
+ // The original resolve reason is preserved on the tombstone; subsequent
+ // resolves (even with a different reason) and the late raise are all
+ // no-ops, and the dispatch loop never sees the row.
+ it.each(['question', 'permission', 'blocking_suggestion'] as const)(
+ 'unknown resolve for reason %s persists a tombstone and a later raise is a no-op',
+ async reason => {
+ const sessionId = trackSession(`ses_attention_unknown_resolve_${reason}`);
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ // 1) Unknown resolve arrives first — no matching raise yet.
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_unknown_first',
+ intent: { kind: 'resolve', reason },
+ });
+ const rowsAfterResolve = readOutboxRows(state);
+ expect(rowsAfterResolve).toHaveLength(1);
+ expect(rowsAfterResolve[0]).toMatchObject({
+ request_id: 'req_unknown_first',
+ reason,
+ status: 'resolved',
+ attempt_count: 0,
+ next_attempt_at: null,
+ last_error: null,
+ });
+ // raisedAt and resolvedAt are both the unknown-resolve timestamp
+ // so the tombstone is unambiguously a "we saw a resolve but never
+ // a raise" row.
+ expect(rowsAfterResolve[0]?.raised_at).toBe(rowsAfterResolve[0]?.resolved_at);
+
+ // 2) Late raise arrives — must NOT enqueue a notification.
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_unknown_first',
+ intent: { kind: 'raise', reason },
+ });
+ await runAlarm(instance);
+
+ const rowsAfterLateRaise = readOutboxRows(state);
+ expect(rowsAfterLateRaise).toHaveLength(1);
+ expect(rowsAfterLateRaise[0]).toMatchObject({
+ request_id: 'req_unknown_first',
+ reason,
+ status: 'resolved',
+ attempt_count: 0,
+ next_attempt_at: null,
+ });
+ // The tombstone's reason is preserved (the late raise's reason
+ // does not overwrite it) and no notification was ever dispatched.
+ expect(calls).toHaveLength(0);
+ });
+ }
+ );
+
+ it('repeat unknown resolve is stable and does not change the tombstone reason or status', async () => {
+ const sessionId = trackSession('ses_attention_unknown_resolve_repeat');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_repeat_resolve',
+ intent: { kind: 'resolve', reason: 'question' },
+ });
+ // Repeat unknown resolve with a different reason — the original
+ // reason must be preserved, and the row must not flap status.
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_repeat_resolve',
+ intent: { kind: 'resolve', reason: 'permission' },
+ });
+ await runAlarm(instance);
+
+ const rows = readOutboxRows(state);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ request_id: 'req_repeat_resolve',
+ reason: 'question',
+ status: 'resolved',
+ attempt_count: 0,
+ next_attempt_at: null,
+ last_error: null,
+ });
+ expect(calls).toHaveLength(0);
+ });
+ });
+
+ it('unknown resolve does not schedule a fresh outbox alarm of its own', async () => {
+ const sessionId = trackSession('ses_attention_unknown_resolve_no_alarm');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ // No prior outbox work exists, no metrics deadline.
+ expect(await state.storage.getAlarm()).toBeNull();
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_unknown_no_alarm',
+ intent: { kind: 'resolve', reason: 'question' },
+ });
+ // The tombstone carries no scheduled attempt and no raise, so the
+ // shared alarm scheduler must NOT reschedule on its own.
+ expect(await state.storage.getAlarm()).toBeNull();
+ });
+ });
+
+ it('returns accepted:false for invalid input or a deleted session', async () => {
+ const sessionId = trackSession('ses_attention_invalid_deleted');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async instance => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await expect(
+ instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_1',
+ intent: { kind: 'raise', reason: 'unknown_reason' },
+ } as unknown as Record)
+ ).resolves.toEqual({ accepted: false, reason: 'invalid_input' });
+ });
+
+ await stub.clear();
+
+ await runInDurableObject(stub, async instance => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await expect(
+ instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_1',
+ intent: { kind: 'raise', reason: 'question' },
+ })
+ ).resolves.toEqual({ accepted: false, reason: 'deleted' });
+ });
+ });
+
+ it('schedules an immediate alarm for a raise while preserving a later metrics deadline', async () => {
+ const sessionId = trackSession('ses_attention_alarm_preempt');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await instance.ingest(
+ [{ type: 'session_close', data: { reason: 'completed' } }],
+ kiloUserId,
+ sessionId,
+ 1
+ );
+
+ const metricsAlarmBefore = Number(readMeta(state, 'metricsAlarmAt') ?? '0');
+ expect(metricsAlarmBefore).toBeGreaterThan(0);
+ expect(Number.isFinite(metricsAlarmBefore)).toBe(true);
+
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_preempt',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ const alarmTime = await state.storage.getAlarm();
+ expect(alarmTime).not.toBeNull();
+ expect(Number.isFinite(alarmTime)).toBe(true);
+ expect(alarmTime).toBeLessThan(metricsAlarmBefore);
+ expect(readMeta(state, 'metricsAlarmAt')).toBe(String(metricsAlarmBefore));
+ await state.storage.deleteAlarm();
+ });
+ });
+
+ it('lets the earlier metrics deadline win when no outbox work is due sooner', async () => {
+ const sessionId = trackSession('ses_attention_metrics_wins');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await instance.ingest(
+ [{ type: 'session_close', data: { reason: 'completed' } }],
+ kiloUserId,
+ sessionId,
+ 1
+ );
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_metrics_later',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ const metricsAlarm = Number(readMeta(state, 'metricsAlarmAt') ?? '0');
+ expect(metricsAlarm).toBeGreaterThan(0);
+ expect(Number.isFinite(metricsAlarm)).toBe(true);
+
+ let alarmTime = await state.storage.getAlarm();
+ expect(alarmTime).not.toBeNull();
+ expect(Number.isFinite(alarmTime)).toBe(true);
+ expect(alarmTime).toBeLessThan(metricsAlarm);
+
+ // Manually advance the outbox next_attempt_at to after the metrics alarm
+ // so the metrics deadline is the next candidate.
+ state.storage.sql.exec(
+ 'UPDATE attention_outbox SET next_attempt_at = ? WHERE request_id = ?',
+ metricsAlarm + 10_000,
+ 'req_metrics_later'
+ );
+ // Trigger a reschedule without changing the outbox state by resolving a
+ // request id that was never raised. The tombstone carries no
+ // scheduled attempt, so the alarm should move to the earlier
+ // metrics deadline because the outbox is now due later.
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_nonexistent_for_reschedule',
+ intent: { kind: 'resolve', reason: 'question' },
+ });
+ alarmTime = await state.storage.getAlarm();
+ expect(alarmTime).toBe(metricsAlarm);
+ await state.storage.deleteAlarm();
+ });
+ });
+
+ it('alarm sends only userId, cliSessionId, requestId, and reason to notifications', async () => {
+ const sessionId = trackSession('ses_attention_payload_shape');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_payload_shape',
+ intent: { kind: 'raise', reason: 'blocking_suggestion' },
+ });
+ await runAlarm(instance);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]).toEqual({
+ userId: kiloUserId,
+ cliSessionId: sessionId,
+ requestId: 'req_payload_shape',
+ reason: 'blocking_suggestion',
+ });
+ });
+ });
+
+ it('transitions dispatched, suppressed_presence, and missing_session to terminal', async () => {
+ const sessionId = trackSession('ses_attention_terminal');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_dispatched',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_suppressed',
+ intent: { kind: 'raise', reason: 'permission' },
+ });
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_missing',
+ intent: { kind: 'raise', reason: 'blocking_suggestion' },
+ });
+
+ const resultsByRequestId: Record = {
+ req_dispatched: { dispatched: true },
+ req_suppressed: { dispatched: false, reason: 'suppressed_presence' },
+ req_missing: { dispatched: false, reason: 'missing_session' },
+ };
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ installMocks(
+ instance,
+ makeMockNotifications(
+ calls,
+ async params => resultsByRequestId[params.requestId] ?? { dispatched: true }
+ ),
+ makeMockO11Y()
+ );
+ await runAlarm(instance);
+ const rows = readOutboxRows(state);
+ expect(rows).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ request_id: 'req_dispatched', status: 'dispatched' }),
+ expect.objectContaining({
+ request_id: 'req_suppressed',
+ status: 'suppressed_presence',
+ }),
+ expect.objectContaining({ request_id: 'req_missing', status: 'missing_session' }),
+ ])
+ );
+ });
+ });
+
+ it.each(['resolved', 'dispatched', 'suppressed_presence', 'missing_session', 'failed'])(
+ 'markRetry is a no-op for terminal status %s',
+ async status => {
+ const sessionId = trackSession(`ses_attention_mark_retry_${status}`);
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ const db = getStoreDb(instance);
+
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_terminal',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ // Force the row into a terminal status so we can verify retries cannot
+ // resurrect finished work.
+ state.storage.sql.exec(
+ 'UPDATE attention_outbox SET status = ?, attempt_count = ? WHERE request_id = ?',
+ status,
+ 3,
+ 'req_terminal'
+ );
+
+ const before = readOutboxRows(state)[0];
+ expect(before?.status).toBe(status);
+ expect(before?.attempt_count).toBe(3);
+
+ markRetry(db, {
+ requestId: 'req_terminal',
+ now: Date.now(),
+ reason: 'should not apply',
+ });
+
+ const after = readOutboxRows(state)[0];
+ expect(after).toMatchObject({
+ request_id: 'req_terminal',
+ status,
+ attempt_count: 3,
+ last_error: before?.last_error ?? null,
+ });
+ });
+ }
+ );
+
+ it('retries dispatch_failed with bounded backoff and parks at failed after max attempts', async () => {
+ // Each iteration advances just past the next backoff. The full
+ // 6-alarm sequence completes in well under the 55min absolute
+ // dispatch window so the cap-by-attempt-count is the trigger
+ // (not the window).
+ const sessionId = trackSession('ses_attention_retry_cap');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(
+ instance,
+ makeMockNotifications(calls, async () => ({
+ dispatched: false,
+ reason: 'dispatch_failed',
+ })),
+ makeMockO11Y()
+ );
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_retry_cap',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ // 6min per alarm × 10 iterations = 60min total. The first 5
+ // dispatches happen inside the 55min window; the 6th retry's
+ // 60min backoff crosses the absolute deadline, so markRetry
+ // parks the row as failed without scheduling a 7th dispatch.
+ for (let i = 0; i < MAX_ATTEMPTS + 3; i++) {
+ vi.advanceTimersByTime(6 * 60 * 1000);
+ await runAlarm(instance);
+ }
+
+ const row = readOutboxRows(state)[0];
+ expect(row).toMatchObject({
+ request_id: 'req_retry_cap',
+ status: 'failed',
+ next_attempt_at: null,
+ last_error: 'dispatch_failed',
+ });
+ // 6 dispatches total — 7th never happens because the window
+ // parks the row first.
+ expect(calls).toHaveLength(6);
+ expect(row?.attempt_count).toBe(6);
+ });
+ });
+
+ it('retries a thrown RPC error with bounded backoff and parks at failed after max attempts', async () => {
+ const sessionId = trackSession('ses_attention_rpc_error');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(
+ instance,
+ {
+ sendSessionAttentionNotification: vi.fn(async () => {
+ throw new Error('this sensitive upstream message must not be stored');
+ }),
+ sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })),
+ },
+ makeMockO11Y()
+ );
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_rpc_error',
+ intent: { kind: 'raise', reason: 'permission' },
+ });
+ for (let i = 0; i < MAX_ATTEMPTS + 3; i++) {
+ vi.advanceTimersByTime(6 * 60 * 1000);
+ await runAlarm(instance);
+ }
+
+ const row = readOutboxRows(state)[0];
+ expect(row).toMatchObject({
+ request_id: 'req_rpc_error',
+ status: 'failed',
+ next_attempt_at: null,
+ last_error: 'rpc_error',
+ });
+ expect(row?.attempt_count).toBe(6);
+ });
+ });
+
+ it('recovers stale in_flight rows and retries them safely', async () => {
+ const sessionId = trackSession('ses_attention_stale_recovery');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_stale',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ // Simulate a crash: mark the row in_flight without dispatching.
+ state.storage.sql.exec(
+ "UPDATE attention_outbox SET status = 'in_flight' WHERE request_id = 'req_stale'"
+ );
+ expect(readOutboxRows(state)[0]?.status).toBe('in_flight');
+
+ // First alarm recovers the stale row to pending with a backoff.
+ await runAlarm(instance);
+ const rowAfterRecovery = readOutboxRows(state)[0];
+ expect(rowAfterRecovery).toMatchObject({
+ request_id: 'req_stale',
+ status: 'pending',
+ attempt_count: 1,
+ });
+ expect(calls).toHaveLength(0);
+
+ // Advance past the recovery backoff and retry.
+ vi.advanceTimersByTime(10_000);
+ await runAlarm(instance);
+
+ expect(calls).toHaveLength(1);
+ const row = readOutboxRows(state)[0];
+ expect(row).toMatchObject({
+ request_id: 'req_stale',
+ status: 'dispatched',
+ attempt_count: 1,
+ });
+ });
+ });
+
+ it('resolve during awaited RPC wins and is not overwritten', async () => {
+ const sessionId = trackSession('ses_attention_resolve_race');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const deferred = createDeferred();
+ const mock: MockNotifications = {
+ sendSessionAttentionNotification: vi.fn(async () => deferred.promise),
+ sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })),
+ };
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, mock, makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_resolve_race',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ const alarmPromise = runAlarm(instance);
+ // The alarm has claimed the row and is awaiting the RPC promise.
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_resolve_race',
+ intent: { kind: 'resolve', reason: 'question' },
+ });
+
+ expect(readOutboxRows(state)[0]?.status).toBe('resolved');
+
+ deferred.resolve({ dispatched: true });
+ await alarmPromise;
+
+ const row = readOutboxRows(state)[0];
+ expect(row).toMatchObject({
+ request_id: 'req_resolve_race',
+ status: 'resolved',
+ });
+ expect(mock.sendSessionAttentionNotification).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('resolve during a thrown RPC is not overwritten by retry', async () => {
+ const sessionId = trackSession('ses_attention_resolve_race_error');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const deferred = createDeferred();
+ const mock: MockNotifications = {
+ sendSessionAttentionNotification: vi.fn(async () => deferred.promise),
+ sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })),
+ };
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, mock, makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_resolve_race_error',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ const alarmPromise = runAlarm(instance);
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_resolve_race_error',
+ intent: { kind: 'resolve', reason: 'question' },
+ });
+
+ deferred.reject(new Error('sensitive upstream message'));
+ await alarmPromise;
+
+ const row = readOutboxRows(state)[0];
+ expect(row).toMatchObject({
+ request_id: 'req_resolve_race_error',
+ status: 'resolved',
+ last_error: null,
+ });
+ expect(mock.sendSessionAttentionNotification).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('does not emit metrics from immediate attention alarm when the deadline is in the future', async () => {
+ // Regression for Kilobot finding 3: the immediate-attention outbox alarm
+ // used to call `emitSessionMetrics` unconditionally, so a raise fired
+ // long before the actual metrics deadline would prematurely publish
+ // session metrics. The alarm must only emit when the persisted
+ // `metricsAlarmAt` is finite, positive, and <= Date.now().
+ const sessionId = trackSession('ses_attention_metrics_future_deadline');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ const o11y = makeMockO11Y();
+ installMocks(instance, makeMockNotifications(calls), o11y);
+
+ // Close sets `metricsAlarmAt` to now + POST_CLOSE_DRAIN_MS (5s).
+ await instance.ingest(
+ [{ type: 'session_close', data: { reason: 'completed' } }],
+ kiloUserId,
+ sessionId,
+ 1
+ );
+
+ const metricsAlarmBefore = Number(readMeta(state, 'metricsAlarmAt') ?? '0');
+ expect(metricsAlarmBefore).toBeGreaterThan(Date.now());
+ expect(Number.isFinite(metricsAlarmBefore)).toBe(true);
+
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_future_deadline',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ await runAlarm(instance);
+
+ // Notification dispatched, metrics NOT emitted.
+ expect(calls).toHaveLength(1);
+ expect(calls[0]).toMatchObject({ requestId: 'req_future_deadline' });
+ expect(o11y.ingestSessionMetrics).not.toHaveBeenCalled();
+ expect(readMeta(state, 'metricsEmitted')).not.toBe('true');
+ // The persisted deadline must be untouched by the immediate alarm.
+ expect(readMeta(state, 'metricsAlarmAt')).toBe(String(metricsAlarmBefore));
+ // The shared alarm is rescheduled to the metrics deadline (no more
+ // outbox work, future deadline still pending).
+ const nextAlarm = await state.storage.getAlarm();
+ expect(nextAlarm).toBe(metricsAlarmBefore);
+ await state.storage.deleteAlarm();
+ });
+ });
+
+ it('emits metrics from the attention alarm once the deadline arrives', async () => {
+ const sessionId = trackSession('ses_attention_metrics_at_deadline');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ const o11y = makeMockO11Y();
+ installMocks(instance, makeMockNotifications(calls), o11y);
+
+ await instance.ingest(
+ [{ type: 'session_close', data: { reason: 'completed' } }],
+ kiloUserId,
+ sessionId,
+ 1
+ );
+
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_at_deadline',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ // First alarm at the immediate tick: outbox dispatched, metrics not yet.
+ await runAlarm(instance);
+ expect(calls).toHaveLength(1);
+ expect(o11y.ingestSessionMetrics).not.toHaveBeenCalled();
+
+ // Advance past the metrics deadline (POST_CLOSE_DRAIN_MS = 5s).
+ vi.advanceTimersByTime(6_000);
+ await runAlarm(instance);
+
+ expect(o11y.ingestSessionMetrics).toHaveBeenCalledTimes(1);
+ expect(readMeta(state, 'metricsEmitted')).toBe('true');
+ expect(readMeta(state, 'metricsAlarmAt')).toBeNull();
+ await state.storage.deleteAlarm();
+ });
+ });
+
+ it('skips metrics when the alarm fires without a persisted deadline (legacy compatibility)', async () => {
+ // Legacy platform alarms may reach `alarm()` without `metricsAlarmAt`
+ // ever being written. The fix must not crash, must not emit, and must
+ // still reschedule the next outbox tick.
+ const sessionId = trackSession('ses_attention_metrics_legacy_no_deadline');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ const o11y = makeMockO11Y();
+ installMocks(instance, makeMockNotifications(calls), o11y);
+
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_legacy_no_deadline',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ // Defensive: ensure no deadline is persisted (none is set by the
+ // raise path on its own).
+ expect(readMeta(state, 'metricsAlarmAt')).toBeNull();
+
+ await runAlarm(instance);
+
+ expect(calls).toHaveLength(1);
+ expect(o11y.ingestSessionMetrics).not.toHaveBeenCalled();
+ expect(readMeta(state, 'metricsEmitted')).not.toBe('true');
+ // No more outbox work, so the next alarm is cleared.
+ const nextAlarm = await state.storage.getAlarm();
+ expect(nextAlarm).toBeNull();
+ });
+ });
+
+ it('re-reads the metrics deadline after dispatchOutboxBatch (reread on IO await)', async () => {
+ // Race regression: a prior code path read metricsEmitted/metricsAlarmAt
+ // once at the top of `alarm()` and then emitted metrics even if the
+ // deadline had been deleted or pushed during the awaited
+ // `sendSessionAttentionNotification` call. The fix re-reads after the
+ // dispatch loop returns so a concurrent update wins.
+ const sessionId = trackSession('ses_attention_metrics_reread');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const deferred = createDeferred();
+ const mock: MockNotifications = {
+ sendSessionAttentionNotification: vi.fn(async () => deferred.promise),
+ sendSessionReadyNotification: vi.fn(async () => ({ dispatched: true })),
+ };
+ const o11y = makeMockO11Y();
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, mock, o11y);
+
+ // Pre-set a far-future deadline so the OLD code would still try to
+ // emit (because it had read `metricsEmitted` once at the top of
+ // alarm() and then ignored the deadline entirely). With the fix, the
+ // alarm re-reads after the await and sees the future deadline.
+ await instance.ingest(
+ [{ type: 'session_close', data: { reason: 'completed' } }],
+ kiloUserId,
+ sessionId,
+ 1
+ );
+ const initialDeadline = Number(readMeta(state, 'metricsAlarmAt') ?? '0');
+ expect(initialDeadline).toBeGreaterThan(Date.now());
+
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_reread',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ const alarmPromise = runAlarm(instance);
+
+ // While the notification RPC is awaited, simulate a concurrent update
+ // that pushes the deadline further out (e.g. a new session_open
+ // resetting the inactivity timer).
+ const newDeadline = Date.now() + 10 * 60 * 1000;
+ state.storage.sql.exec(
+ "UPDATE ingest_meta SET value = ? WHERE key = 'metricsAlarmAt'",
+ String(newDeadline)
+ );
+
+ deferred.resolve({ dispatched: true });
+ await alarmPromise;
+
+ expect(mock.sendSessionAttentionNotification).toHaveBeenCalledTimes(1);
+ // The fix re-reads the deadline after the await; the push must not
+ // emit metrics, and the deadline must be the concurrent value.
+ expect(o11y.ingestSessionMetrics).not.toHaveBeenCalled();
+ expect(readMeta(state, 'metricsEmitted')).not.toBe('true');
+ expect(Number(readMeta(state, 'metricsAlarmAt'))).toBe(newDeadline);
+ await state.storage.deleteAlarm();
+ });
+ });
+
+ it('keeps the outbox retry alarm after metrics emission', async () => {
+ const sessionId = trackSession('ses_attention_metrics_outbox_alarm');
+ const stub = getStub(kiloUserId, sessionId);
+
+ await seedSession(stub, sessionId);
+
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications([]), makeMockO11Y());
+ await instance.ingest(
+ [
+ { type: 'session', data: { title: 'Outbox Alarm After Metrics' } },
+ { type: 'session_close', data: { reason: 'completed' } },
+ ],
+ kiloUserId,
+ sessionId,
+ 1
+ );
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_outbox_after_metrics',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ // First alarm: dispatches the outbox. With the fix, it does NOT emit
+ // metrics because the deadline (POST_CLOSE_DRAIN_MS in the future)
+ // hasn't arrived yet.
+ await runAlarm(instance);
+ expect(readMeta(state, 'metricsEmitted')).not.toBe('true');
+ // The deadline is preserved and the alarm is rescheduled to it.
+ const pendingDeadline = Number(readMeta(state, 'metricsAlarmAt') ?? '0');
+ expect(pendingDeadline).toBeGreaterThan(0);
+ const pendingAlarm = await state.storage.getAlarm();
+ expect(pendingAlarm).toBe(pendingDeadline);
+
+ // Advance past the metrics deadline and run again: metrics emit.
+ vi.advanceTimersByTime(6_000);
+ await runAlarm(instance);
+ expect(readMeta(state, 'metricsEmitted')).toBe('true');
+ expect(readMeta(state, 'metricsAlarmAt')).toBeNull();
+
+ // No outbox work remains, so the alarm is cleared.
+ let alarmTime = await state.storage.getAlarm();
+ expect(alarmTime).toBeNull();
+
+ // A new raise after metrics must reschedule the alarm.
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_after_metrics',
+ intent: { kind: 'raise', reason: 'permission' },
+ });
+
+ alarmTime = await state.storage.getAlarm();
+ expect(alarmTime).not.toBeNull();
+ expect(Number.isFinite(alarmTime)).toBe(true);
+ await state.storage.deleteAlarm();
+ });
+ });
+
+ // Kilobot finding 5: the absolute dispatch window is bounded at
+ // ATTENTION_MAX_DISPATCH_AGE_MS (55min) from `raisedAt` so the row can
+ // never cross the receiver's 60min idempotency TTL.
+ describe('absolute dispatch window (55min from raisedAt)', () => {
+ it('parks a pending row at/over the deadline before claim returns it', async () => {
+ const sessionId = trackSession('ses_attention_claim_park_expired');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_expired_pending',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ // Move the row's `raisedAt` and `next_attempt_at` well past the
+ // deadline so the next claim is over the window. The claim
+ // must park the row as terminal failed and never call the RPC.
+ const pastDeadline = Date.now() + 60 * 60 * 1000; // +60min
+ state.storage.sql.exec(
+ 'UPDATE attention_outbox SET raised_at = ?, next_attempt_at = ? WHERE request_id = ?',
+ Date.now() - ATTENTION_MAX_DISPATCH_AGE_MS, // 55min ago exactly → at deadline
+ pastDeadline,
+ 'req_expired_pending'
+ );
+
+ await runAlarm(instance);
+
+ const row = readOutboxRows(state)[0];
+ expect(calls).toHaveLength(0);
+ expect(row).toMatchObject({
+ request_id: 'req_expired_pending',
+ status: 'failed',
+ next_attempt_at: null,
+ last_error: 'retry_window_expired',
+ });
+ });
+ });
+
+ it('parks a pending row when `now` crosses the deadline even if its next_attempt_at is in the past', async () => {
+ // A row whose `next_attempt_at` is in the past but whose
+ // `raisedAt` is older than ATTENTION_MAX_DISPATCH_AGE_MS must
+ // still be parked, not dispatched.
+ const sessionId = trackSession('ses_attention_claim_now_over_deadline');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_now_past_deadline',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ // Force raisedAt deep in the past; keep next_attempt_at < now
+ // (so it looks "due") but the deadline has long since passed.
+ const longAgo = Date.now() - 24 * 60 * 60 * 1000; // 24h ago
+ state.storage.sql.exec(
+ 'UPDATE attention_outbox SET raised_at = ?, next_attempt_at = ? WHERE request_id = ?',
+ longAgo,
+ Date.now() - 1,
+ 'req_now_past_deadline'
+ );
+
+ await runAlarm(instance);
+
+ const row = readOutboxRows(state)[0];
+ expect(calls).toHaveLength(0);
+ expect(row).toMatchObject({
+ request_id: 'req_now_past_deadline',
+ status: 'failed',
+ next_attempt_at: null,
+ last_error: 'retry_window_expired',
+ });
+ });
+ });
+
+ it('recovers a stale in_flight row that has crossed the deadline as terminal failed', async () => {
+ // Stale recovery must not reschedule a row past the absolute
+ // deadline, even if the cap-by-attempt-count would have allowed
+ // more attempts.
+ const sessionId = trackSession('ses_attention_recover_expired_in_flight');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_stale_expired',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ // Simulate a crash mid-dispatch with a row well past the window.
+ state.storage.sql.exec(
+ "UPDATE attention_outbox SET status = 'in_flight', raised_at = ?, attempt_count = 1 WHERE request_id = 'req_stale_expired'",
+ Date.now() - 2 * 60 * 60 * 1000 // 2h ago
+ );
+
+ await runAlarm(instance);
+
+ const row = readOutboxRows(state)[0];
+ expect(calls).toHaveLength(0);
+ expect(row).toMatchObject({
+ request_id: 'req_stale_expired',
+ status: 'failed',
+ next_attempt_at: null,
+ last_error: 'stale_in_flight_recovered',
+ });
+ });
+ });
+
+ it('a retry sequence past the deadline never invokes the RPC and parks the row', async () => {
+ // The full integration: the alarm loop runs back-to-back retries
+ // until either the cap or the deadline trips. With raises that
+ // happened just inside the window, retries must still fall
+ // within the window until the cap or the absolute deadline
+ // parks the row — the RPC must never be invoked at/after the
+ // deadline.
+ const sessionId = trackSession('ses_attention_retry_window');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ const raisedAt = Date.now();
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(
+ instance,
+ makeMockNotifications(calls, async () => ({
+ dispatched: false,
+ reason: 'dispatch_failed',
+ })),
+ makeMockO11Y()
+ );
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_window',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+
+ // Run a few retries strictly inside the window. Each advance
+ // is small enough to keep us well under the 55min cap.
+ for (let i = 0; i < 4; i++) {
+ vi.advanceTimersByTime(20_000);
+ await runAlarm(instance);
+ }
+ const beforeDeadline = readOutboxRows(state)[0];
+ expect(beforeDeadline?.status).toBe('pending');
+ expect(calls.length).toBeGreaterThan(0);
+
+ // Jump past the deadline. The next alarm must NOT call the RPC
+ // and must park the row as failed.
+ vi.advanceTimersByTime(60 * 60 * 1000); // +60min
+ const callsBefore = calls.length;
+ await runAlarm(instance);
+ const callsAfter = calls.length;
+ expect(callsAfter).toBe(callsBefore);
+
+ const row = readOutboxRows(state)[0];
+ expect(row).toMatchObject({
+ request_id: 'req_window',
+ status: 'failed',
+ next_attempt_at: null,
+ });
+ expect(row?.last_error).toBe('retry_window_expired');
+ // The deadline is the original raisedAt + 55min, not
+ // 'dispatch_failed' (which is the per-retry reason).
+ expect(dispatchDeadline(raisedAt)).toBe(raisedAt + ATTENTION_MAX_DISPATCH_AGE_MS);
+ });
+ });
+
+ it('a delayed alarm past the deadline fails the row without dispatch', async () => {
+ // A row was raised, the alarm was lost, and the platform re-fires
+ // it well past the deadline. The first claim must park the row
+ // and never call the RPC.
+ const sessionId = trackSession('ses_attention_delayed_past_deadline');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_delayed',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ // Simulate a long outage: rewind `raised_at` so the row is past
+ // its window when the alarm next fires.
+ state.storage.sql.exec(
+ 'UPDATE attention_outbox SET raised_at = ? WHERE request_id = ?',
+ Date.now() - 90 * 60 * 1000, // 90min ago
+ 'req_delayed'
+ );
+
+ await runAlarm(instance);
+
+ const row = readOutboxRows(state)[0];
+ expect(calls).toHaveLength(0);
+ expect(row).toMatchObject({
+ request_id: 'req_delayed',
+ status: 'failed',
+ next_attempt_at: null,
+ last_error: 'retry_window_expired',
+ });
+ });
+ });
+
+ it('does not reschedule an alarm that only has expired pending work', async () => {
+ // After the alarm body has parked all expired pending rows, the
+ // shared scheduler must not keep an alarm alive pointing at a
+ // past-deadline time. The next alarm is cleared.
+ const sessionId = trackSession('ses_attention_reschedule_no_expired');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_expired_alone',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ state.storage.sql.exec(
+ 'UPDATE attention_outbox SET raised_at = ?, next_attempt_at = ? WHERE request_id = ?',
+ Date.now() - 60 * 60 * 1000,
+ Date.now() - 60 * 60 * 1000,
+ 'req_expired_alone'
+ );
+
+ await runAlarm(instance);
+
+ const nextAlarm = await state.storage.getAlarm();
+ expect(nextAlarm).toBeNull();
+ const row = readOutboxRows(state)[0];
+ expect(row?.status).toBe('failed');
+ });
+ });
+
+ it('initial raise immediate is unaffected: raisedAt === nextAttemptAt is well before the deadline', async () => {
+ // A fresh raise sets raisedAt = nextAttemptAt = now. The very
+ // first claim must dispatch immediately without hitting the
+ // boundary check.
+ const sessionId = trackSession('ses_attention_initial_immediate');
+ const stub = getStub(kiloUserId, sessionId);
+ await seedSession(stub, sessionId);
+
+ const calls: SendSessionAttentionNotificationParams[] = [];
+ await runInDurableObject(stub, async (instance, state) => {
+ installMocks(instance, makeMockNotifications(calls), makeMockO11Y());
+ await instance.recordAttentionEvent({
+ kiloUserId,
+ sessionId,
+ requestId: 'req_immediate',
+ intent: { kind: 'raise', reason: 'question' },
+ });
+ await runAlarm(instance);
+ expect(calls).toHaveLength(1);
+ const row = readOutboxRows(state)[0];
+ expect(row).toMatchObject({
+ request_id: 'req_immediate',
+ status: 'dispatched',
+ });
+ });
+ });
+ });
+});