From 5e7f5655c2e26890df49bd406bccc1b7ddf4e081 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 22:11:20 +0000 Subject: [PATCH 01/13] feat(gui): Expandable event rows with detail panel and fired styling (#485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-014 Phase 3A: Click-to-expand event rows with detail panels. - EventRow.svelte: Local expanded state, click to toggle, chevron indicator (▸/▾), single-line truncation when collapsed - Raw event detail panel: Device, Channel, Type, absolute timestamp - Fired event detail panel: Trigger, Action, Result, Latency, Mode, Time - Fired entry distinct styling: ⚡ icon, "Fired" accent label, left border colored by trigger type, "trigger → action" content - event-helpers.ts: formatLatency(), formatAbsoluteTime(), isFiredEvent(), getFiredDotClass() - 42 new tests (21 EventRow, 21 event-helpers) - Fix EventStreamPanel test mock to include new helper exports Closes #485 Co-Authored-By: Claude Opus 4.6 --- .../ui/src/lib/components/EventRow.svelte | 170 +++++++++++-- .../ui/src/lib/components/EventRow.test.ts | 238 ++++++++++++++++-- .../src/lib/panels/EventStreamPanel.test.ts | 4 + .../ui/src/lib/utils/event-helpers.test.ts | 84 +++++++ .../ui/src/lib/utils/event-helpers.ts | 57 +++++ 5 files changed, 509 insertions(+), 44 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/EventRow.svelte b/conductor-gui/ui/src/lib/components/EventRow.svelte index fbd817f0..dfa3f809 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.svelte +++ b/conductor-gui/ui/src/lib/components/EventRow.svelte @@ -2,15 +2,19 @@ -
- - {typeLabel} - {detail} - {relTime} - {#if showLearnBtn} - + +
+
+ {#if fired} + + {:else} + + {/if} + {typeLabel} + {detail} + {relTime} + {#if showLearnBtn} + + {/if} + {expanded ? '▾' : '▸'} +
+ + {#if expanded} +
+ {#if fired} +
Trigger {event.trigger?.type ?? '?'} #{event.trigger?.number ?? '?'} v={event.trigger?.value ?? '?'}{event.trigger?.device ? ` (${event.trigger.device})` : ''}
+
Action {event.action?.type ?? '?'}: {event.action?.summary ?? '?'}
+
Result {event.result ?? '?'}{event.error ? ` — ${event.error}` : ''}
+
Latency {formatLatency(event.latency_us ?? 0)}
+
Mode {event.mode ?? '?'}
+
Time {formatAbsoluteTime(event.timestamp)}
+ {:else} +
Device {event.device_id ?? 'Unknown'}
+
Channel {event.channel ?? '—'}
+
Type {event.event_type}
+
Time {formatAbsoluteTime(event.timestamp)}
+ {/if} +
{/if}
diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 297ab04c..5fded964 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -1,5 +1,5 @@ /** - * EventRow tests (GUI v2 Phase 4) + * EventRow tests (GUI v2 Phase 4 + ADR-014 Phase 3A) */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -10,6 +10,15 @@ vi.mock('$lib/utils/event-helpers', () => ({ getEventTypeLabel: (e) => e.event_type === 'NoteOn' ? 'Note' : 'CC', getEventDetail: (e) => e.event_type === 'NoteOn' ? '36 on v=100 ch.1' : '7 = 127 ch.1', getRelativeTime: () => 'now', + formatLatency: (us) => `${(us / 1000).toFixed(1)}ms`, + formatAbsoluteTime: () => '12:34:56.789', + isFiredEvent: (e) => e?._type === 'mapping_fired', + getFiredDotClass: (e) => { + const t = e?.trigger?.type; + if (t === 'note') return 'note'; + if (t === 'cc') return 'cc'; + return 'unknown'; + }, })); vi.mock('$lib/stores/events.js', async () => { @@ -31,6 +40,32 @@ vi.mock('$lib/stores/events.js', async () => { }; }); +const RAW_EVENT = { + timestamp: Date.now(), + event_type: 'NoteOn', + note: 36, + velocity: 100, + channel: 1, + device_id: 'Mikro', + _id: 1, +}; + +const FIRED_EVENT = { + timestamp: Date.now(), + event_type: 'MappingFired', + _type: 'mapping_fired', + _id: 2, + trigger: { type: 'note', device: 'Mikro', channel: null, number: 36, value: 100 }, + action: { type: 'keystroke', summary: '⌘+C' }, + result: 'ok', + latency_us: 450, + mode: 'Default', + mapping_label: 'Copy shortcut', + description: 'Fired: note → ⌘+C', + device_id: 'Mikro', + channel: null, +}; + describe('EventRow', () => { afterEach(cleanup); @@ -39,58 +74,209 @@ describe('EventRow', () => { return render(EventRow, { props }); } + // --- Existing functionality --- + it('renders event type label', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn', note: 36, velocity: 100, channel: 1 }, - }); + await importAndRender({ event: RAW_EVENT }); expect(screen.getByText('Note')).toBeTruthy(); }); it('renders event detail', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn', note: 36, velocity: 100, channel: 1 }, - }); + await importAndRender({ event: RAW_EVENT }); expect(screen.getByText('36 on v=100 ch.1')).toBeTruthy(); }); it('renders relative time', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn' }, - }); + await importAndRender({ event: RAW_EVENT }); expect(screen.getByText('now')).toBeTruthy(); }); it('shows Learn button when showLearnBtn is true', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn' }, - showLearnBtn: true, - }); + await importAndRender({ event: RAW_EVENT, showLearnBtn: true }); expect(screen.getByText('Learn')).toBeTruthy(); }); it('does not show Learn button by default', async () => { - await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn' }, - }); + await importAndRender({ event: RAW_EVENT }); expect(screen.queryByText('Learn')).toBeNull(); }); it('applies highlight class when highlight prop is true', async () => { - const { container } = await importAndRender({ - event: { timestamp: Date.now(), event_type: 'NoteOn' }, - highlight: true, - }); + const { container } = await importAndRender({ event: RAW_EVENT, highlight: true }); expect(container.querySelector('.highlight')).toBeTruthy(); }); it('renders Learn button that is clickable', async () => { - const event = { timestamp: Date.now(), event_type: 'NoteOn', note: 36 }; - await importAndRender({ event, showLearnBtn: true }); - + await importAndRender({ event: RAW_EVENT, showLearnBtn: true }); const btn = screen.getByText('Learn'); - expect(btn).toBeTruthy(); - // Verify it's a button element that can be clicked expect(btn.tagName).toBe('BUTTON'); await fireEvent.click(btn); }); + + // --- Expand/Collapse (ADR-014 Phase 3A) --- + + it('starts collapsed by default', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + expect(container.querySelector('.event-row.expanded')).toBeNull(); + expect(container.querySelector('.event-detail-panel')).toBeNull(); + }); + + it('expands on click to show detail panel', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const row = container.querySelector('.event-row'); + await fireEvent.click(row); + expect(container.querySelector('.event-row.expanded')).toBeTruthy(); + expect(container.querySelector('.event-detail-panel')).toBeTruthy(); + }); + + it('collapses on second click', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const row = container.querySelector('.event-row'); + await fireEvent.click(row); + expect(container.querySelector('.expanded')).toBeTruthy(); + await fireEvent.click(row); + expect(container.querySelector('.expanded')).toBeNull(); + }); + + it('shows chevron element', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const chevron = container.querySelector('.event-chevron'); + expect(chevron).toBeTruthy(); + expect(chevron.textContent).toBe('▸'); + }); + + it('rotates chevron when expanded', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const row = container.querySelector('.event-row'); + await fireEvent.click(row); + const chevron = container.querySelector('.event-chevron'); + expect(chevron.textContent).toBe('▾'); + }); + + it('detail panel shows device info for raw events', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('Device'); + expect(panel.textContent).toContain('Mikro'); + }); + + it('detail panel shows channel for raw events', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('Channel'); + expect(panel.textContent).toContain('1'); + }); + + it('detail panel shows absolute timestamp', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('Time'); + expect(panel.textContent).toContain('12:34:56.789'); + }); + + // --- Fired event styling (ADR-014 Phase 3A) --- + + it('renders fired icon ⚡ for mapping_fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const icon = container.querySelector('.event-fired-icon'); + expect(icon).toBeTruthy(); + expect(icon.textContent).toBe('⚡'); + }); + + it('does not render standard dot for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + expect(container.querySelector('.event-dot')).toBeNull(); + }); + + it('renders "Fired" label for mapping_fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const typeLabel = container.querySelector('.event-type'); + expect(typeLabel.textContent).toBe('Fired'); + }); + + it('applies fired class on event-row for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + expect(container.querySelector('.event-row.fired')).toBeTruthy(); + }); + + it('shows trigger→action content for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const detail = container.querySelector('.event-detail'); + expect(detail.textContent).toContain('note'); + expect(detail.textContent).toContain('⌘+C'); + }); + + it('has left border colored by trigger type for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const row = container.querySelector('.event-row.fired'); + expect(row).toBeTruthy(); + // The fired class presence implies border styling via CSS + }); + + // --- Fired event detail panel --- + + it('fired detail panel shows trigger info', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('Trigger'); + expect(panel.textContent).toContain('note'); + expect(panel.textContent).toContain('36'); + }); + + it('fired detail panel shows action info', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('Action'); + expect(panel.textContent).toContain('⌘+C'); + }); + + it('fired detail panel shows result', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('Result'); + expect(panel.textContent).toContain('ok'); + }); + + it('fired detail panel shows latency', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('Latency'); + expect(panel.textContent).toContain('0.5ms'); + }); + + it('fired detail panel shows mode', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('Mode'); + expect(panel.textContent).toContain('Default'); + }); + + it('fired detail panel shows error message when result is error', async () => { + const errorEvent = { + ...FIRED_EVENT, + result: 'error', + error: 'App not found', + }; + const { container } = await importAndRender({ event: errorEvent }); + await fireEvent.click(container.querySelector('.event-row')); + const panel = container.querySelector('.event-detail-panel'); + expect(panel.textContent).toContain('error'); + expect(panel.textContent).toContain('App not found'); + }); + + it('Learn button click does not toggle expand', async () => { + const { container } = await importAndRender({ event: RAW_EVENT, showLearnBtn: true }); + const btn = screen.getByText('Learn'); + await fireEvent.click(btn); + // stopPropagation should prevent expand + expect(container.querySelector('.expanded')).toBeNull(); + }); }); diff --git a/conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts b/conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts index 96e4e022..ed874461 100644 --- a/conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts +++ b/conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts @@ -78,6 +78,10 @@ vi.mock('$lib/utils/event-helpers', () => ({ getEventTypeLabel: () => 'Note', getEventDetail: (e) => `${e.note} on v=${e.velocity}`, getRelativeTime: () => 'now', + formatLatency: (us) => `${(us / 1000).toFixed(1)}ms`, + formatAbsoluteTime: () => '12:00:00.000', + isFiredEvent: (e) => e?._type === 'mapping_fired', + getFiredDotClass: () => 'note', })); describe('EventStreamPanel', () => { diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.test.ts b/conductor-gui/ui/src/lib/utils/event-helpers.test.ts index 7802e1bc..101637a8 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.test.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.test.ts @@ -12,6 +12,10 @@ import { getEventTypeLabel, getEventDetail, getRelativeTime, + formatLatency, + formatAbsoluteTime, + isFiredEvent, + getFiredDotClass, } from './event-helpers'; import type { MidiEventInfo } from './event-helpers'; @@ -171,3 +175,83 @@ describe('getRelativeTime', () => { expect(getRelativeTime(now - 7200000, now)).toBe('2h'); }); }); + +describe('formatLatency', () => { + it('formats microseconds to milliseconds', () => { + expect(formatLatency(450)).toBe('0.5ms'); + }); + + it('formats larger values', () => { + expect(formatLatency(1200)).toBe('1.2ms'); + }); + + it('formats zero', () => { + expect(formatLatency(0)).toBe('0.0ms'); + }); +}); + +describe('formatAbsoluteTime', () => { + it('formats timestamp to HH:MM:SS.mmm', () => { + // Use a known timestamp: 2025-01-15 12:34:56.789 UTC + const ts = new Date(2025, 0, 15, 12, 34, 56, 789).getTime(); + expect(formatAbsoluteTime(ts)).toBe('12:34:56.789'); + }); + + it('pads single digit values', () => { + const ts = new Date(2025, 0, 1, 1, 2, 3, 4).getTime(); + expect(formatAbsoluteTime(ts)).toBe('01:02:03.004'); + }); +}); + +describe('isFiredEvent', () => { + it('returns true for mapping_fired events', () => { + expect(isFiredEvent({ _type: 'mapping_fired' })).toBe(true); + }); + + it('returns false for raw events', () => { + expect(isFiredEvent({ event_type: 'NoteOn' })).toBe(false); + }); + + it('returns false for null/undefined', () => { + expect(isFiredEvent(null)).toBe(false); + expect(isFiredEvent(undefined)).toBe(false); + }); +}); + +describe('getFiredDotClass', () => { + it('returns note for note trigger', () => { + expect(getFiredDotClass({ trigger: { type: 'note' } })).toBe('note'); + }); + + it('returns note for long_press trigger', () => { + expect(getFiredDotClass({ trigger: { type: 'long_press' } })).toBe('note'); + }); + + it('returns cc for cc trigger', () => { + expect(getFiredDotClass({ trigger: { type: 'cc' } })).toBe('cc'); + }); + + it('returns cc for encoder trigger', () => { + expect(getFiredDotClass({ trigger: { type: 'encoder' } })).toBe('cc'); + }); + + it('returns aftertouch for aftertouch trigger', () => { + expect(getFiredDotClass({ trigger: { type: 'aftertouch' } })).toBe('aftertouch'); + }); + + it('returns pitchbend for pitch_bend trigger', () => { + expect(getFiredDotClass({ trigger: { type: 'pitch_bend' } })).toBe('pitchbend'); + }); + + it('returns gamepad for gamepad_button trigger', () => { + expect(getFiredDotClass({ trigger: { type: 'gamepad_button' } })).toBe('gamepad'); + }); + + it('returns unknown for unrecognized trigger', () => { + expect(getFiredDotClass({ trigger: { type: 'custom' } })).toBe('unknown'); + }); + + it('returns unknown when trigger is missing', () => { + expect(getFiredDotClass({})).toBe('unknown'); + }); +}); diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.ts b/conductor-gui/ui/src/lib/utils/event-helpers.ts index cf8b98e6..f99c8c4f 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.ts @@ -119,3 +119,60 @@ export function getRelativeTime(timestampMs: number, nowMs?: number): string { if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m`; return `${Math.floor(diffMs / 3600000)}h`; } + +/** + * Format latency in microseconds to a human-readable string. + * Examples: 450 → "0.5ms", 1200 → "1.2ms", 15000 → "15.0ms" + */ +export function formatLatency(latencyUs: number): string { + return `${(latencyUs / 1000).toFixed(1)}ms`; +} + +/** + * Format an absolute timestamp to a readable time string. + * Returns HH:MM:SS.mmm format. + */ +export function formatAbsoluteTime(timestampMs: number): string { + const d = new Date(timestampMs); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return `${hh}:${mm}:${ss}.${ms}`; +} + +/** + * Check if an event is a MappingFired event. + */ +export function isFiredEvent(event: any): boolean { + return event?._type === 'mapping_fired'; +} + +/** + * Get the fired event dot class based on trigger type. + * Maps daemon trigger type strings to CSS color classes. + */ +export function getFiredDotClass(event: any): string { + const triggerType = event?.trigger?.type; + switch (triggerType) { + case 'note': + case 'short_press': + case 'medium_press': + case 'long_press': + case 'double_tap': + case 'chord': + return 'note'; + case 'cc': + case 'encoder': + return 'cc'; + case 'aftertouch': + return 'aftertouch'; + case 'pitch_bend': + return 'pitchbend'; + case 'gamepad_button': + case 'gamepad_chord': + return 'gamepad'; + default: + return 'unknown'; + } +} From 7dbe6a22dcd5856bad4133c0b4cb39a51b98fbcb Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 22:13:37 +0000 Subject: [PATCH 02/13] =?UTF-8?q?fix(gui):=20CSS=20precedence=20=E2=80=94?= =?UTF-8?q?=20highlight=20border=20takes=20priority=20over=20fired=20borde?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensures Learn mode highlight (accent border) visually overrides the fired entry border when both classes are present on the same row. Co-Authored-By: Claude Opus 4.6 --- conductor-gui/ui/src/lib/components/EventRow.svelte | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/EventRow.svelte b/conductor-gui/ui/src/lib/components/EventRow.svelte index dfa3f809..0757aad8 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.svelte +++ b/conductor-gui/ui/src/lib/components/EventRow.svelte @@ -103,15 +103,16 @@ background: var(--bg-card-hover); } + .event-row.fired { + border-left: 2px solid var(--text-dim); + } + + /* Highlight takes precedence over fired border (Learn mode) */ .event-row.highlight { background: var(--accent-tint); border-left: 2px solid var(--accent); } - .event-row.fired { - border-left: 2px solid var(--text-dim); - } - .event-row.fired.border-note { border-left-color: var(--green); } .event-row.fired.border-cc { border-left-color: var(--blue); } .event-row.fired.border-aftertouch { border-left-color: var(--amber); } From c95b358e240eabd634c9f8fbd370c2346d2d2c03 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 22:24:41 +0000 Subject: [PATCH 03/13] fix(gui): Address 7 PR review comments (#505) - A11y: Add Enter/Space keyboard handlers, remove svelte-ignore - CSS: Avoid fired+highlight specificity conflict by not applying fired class when highlight is true - CSS: Untruncate event-detail and event-main when expanded - Types: Replace `any` with type guard (isFiredEvent returns type predicate), add MappingFiredEvent interface - Encoder dot class returns 'encoder' (purple) instead of 'cc' (blue) for consistent color mapping with theme - Fix test comment: "local timestamp" not "UTC" - Add border-encoder CSS class for encoder-fired events Co-Authored-By: Claude Opus 4.6 --- .../ui/src/lib/components/EventRow.svelte | 48 ++++++++++++++----- .../ui/src/lib/components/EventRow.test.ts | 27 +++++++++++ .../ui/src/lib/utils/event-helpers.test.ts | 30 +++++++----- .../ui/src/lib/utils/event-helpers.ts | 41 +++++++++++++--- 4 files changed, 116 insertions(+), 30 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/EventRow.svelte b/conductor-gui/ui/src/lib/components/EventRow.svelte index 0757aad8..45bec4c4 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.svelte +++ b/conductor-gui/ui/src/lib/components/EventRow.svelte @@ -37,23 +37,31 @@ expanded = !expanded; } + function handleKeydown(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + expanded = !expanded; + } + } + function handleLearn() { dispatch('learn', event); } -
@@ -107,18 +115,20 @@ border-left: 2px solid var(--text-dim); } - /* Highlight takes precedence over fired border (Learn mode) */ - .event-row.highlight { - background: var(--accent-tint); - border-left: 2px solid var(--accent); - } - .event-row.fired.border-note { border-left-color: var(--green); } .event-row.fired.border-cc { border-left-color: var(--blue); } + .event-row.fired.border-encoder { border-left-color: var(--purple); } .event-row.fired.border-aftertouch { border-left-color: var(--amber); } .event-row.fired.border-pitchbend { border-left-color: var(--purple); } .event-row.fired.border-gamepad { border-left-color: var(--event-gamepad, var(--amber)); } + /* Highlight takes precedence over fired border (Learn mode). + fired class is not applied when highlight is true, avoiding specificity conflicts. */ + .event-row.highlight { + background: var(--accent-tint); + border-left: 2px solid var(--accent); + } + .event-main { display: flex; align-items: center; @@ -129,6 +139,11 @@ white-space: nowrap; } + .event-row.expanded .event-main { + white-space: normal; + overflow: visible; + } + .event-dot { width: 5px; height: 5px; @@ -175,6 +190,13 @@ white-space: nowrap; } + .event-row.expanded .event-detail { + overflow: visible; + text-overflow: unset; + white-space: normal; + word-break: break-word; + } + .event-time { color: var(--text-dim); font-size: 10px; diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 5fded964..9002572f 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -14,9 +14,11 @@ vi.mock('$lib/utils/event-helpers', () => ({ formatAbsoluteTime: () => '12:34:56.789', isFiredEvent: (e) => e?._type === 'mapping_fired', getFiredDotClass: (e) => { + if (typeof e !== 'object' || e === null || e._type !== 'mapping_fired') return 'unknown'; const t = e?.trigger?.type; if (t === 'note') return 'note'; if (t === 'cc') return 'cc'; + if (t === 'encoder') return 'encoder'; return 'unknown'; }, })); @@ -279,4 +281,29 @@ describe('EventRow', () => { // stopPropagation should prevent expand expect(container.querySelector('.expanded')).toBeNull(); }); + + // --- Keyboard accessibility --- + + it('expands on Enter key', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const row = container.querySelector('.event-row'); + await fireEvent.keyDown(row, { key: 'Enter' }); + expect(container.querySelector('.expanded')).toBeTruthy(); + }); + + it('expands on Space key', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const row = container.querySelector('.event-row'); + await fireEvent.keyDown(row, { key: ' ' }); + expect(container.querySelector('.expanded')).toBeTruthy(); + }); + + // --- Highlight vs fired precedence --- + + it('does not apply fired class when highlight is true', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT, highlight: true }); + const row = container.querySelector('.event-row'); + expect(row.classList.contains('highlight')).toBe(true); + expect(row.classList.contains('fired')).toBe(false); + }); }); diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.test.ts b/conductor-gui/ui/src/lib/utils/event-helpers.test.ts index 101637a8..46fcc878 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.test.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.test.ts @@ -192,7 +192,7 @@ describe('formatLatency', () => { describe('formatAbsoluteTime', () => { it('formats timestamp to HH:MM:SS.mmm', () => { - // Use a known timestamp: 2025-01-15 12:34:56.789 UTC + // Use a fixed local timestamp: 2025-01-15 12:34:56.789 const ts = new Date(2025, 0, 15, 12, 34, 56, 789).getTime(); expect(formatAbsoluteTime(ts)).toBe('12:34:56.789'); }); @@ -219,39 +219,47 @@ describe('isFiredEvent', () => { }); describe('getFiredDotClass', () => { + function firedEvent(triggerType: string) { + return { _type: 'mapping_fired' as const, trigger: { type: triggerType } }; + } + it('returns note for note trigger', () => { - expect(getFiredDotClass({ trigger: { type: 'note' } })).toBe('note'); + expect(getFiredDotClass(firedEvent('note'))).toBe('note'); }); it('returns note for long_press trigger', () => { - expect(getFiredDotClass({ trigger: { type: 'long_press' } })).toBe('note'); + expect(getFiredDotClass(firedEvent('long_press'))).toBe('note'); }); it('returns cc for cc trigger', () => { - expect(getFiredDotClass({ trigger: { type: 'cc' } })).toBe('cc'); + expect(getFiredDotClass(firedEvent('cc'))).toBe('cc'); }); - it('returns cc for encoder trigger', () => { - expect(getFiredDotClass({ trigger: { type: 'encoder' } })).toBe('cc'); + it('returns encoder for encoder trigger', () => { + expect(getFiredDotClass(firedEvent('encoder'))).toBe('encoder'); }); it('returns aftertouch for aftertouch trigger', () => { - expect(getFiredDotClass({ trigger: { type: 'aftertouch' } })).toBe('aftertouch'); + expect(getFiredDotClass(firedEvent('aftertouch'))).toBe('aftertouch'); }); it('returns pitchbend for pitch_bend trigger', () => { - expect(getFiredDotClass({ trigger: { type: 'pitch_bend' } })).toBe('pitchbend'); + expect(getFiredDotClass(firedEvent('pitch_bend'))).toBe('pitchbend'); }); it('returns gamepad for gamepad_button trigger', () => { - expect(getFiredDotClass({ trigger: { type: 'gamepad_button' } })).toBe('gamepad'); + expect(getFiredDotClass(firedEvent('gamepad_button'))).toBe('gamepad'); }); it('returns unknown for unrecognized trigger', () => { - expect(getFiredDotClass({ trigger: { type: 'custom' } })).toBe('unknown'); + expect(getFiredDotClass(firedEvent('custom'))).toBe('unknown'); }); - it('returns unknown when trigger is missing', () => { + it('returns unknown when not a fired event', () => { expect(getFiredDotClass({})).toBe('unknown'); }); + + it('returns unknown for null', () => { + expect(getFiredDotClass(null)).toBe('unknown'); + }); }); diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.ts b/conductor-gui/ui/src/lib/utils/event-helpers.ts index f99c8c4f..8d5b08e4 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.ts @@ -142,18 +142,46 @@ export function formatAbsoluteTime(timestampMs: number): string { } /** - * Check if an event is a MappingFired event. + * Narrow type for a "mapping_fired" event from the daemon. */ -export function isFiredEvent(event: any): boolean { - return event?._type === 'mapping_fired'; +export interface MappingFiredEvent { + _type: 'mapping_fired'; + trigger?: { + type?: string; + device?: string | null; + channel?: number | null; + number?: number | null; + value?: number | null; + }; + action?: { + type?: string; + summary?: string; + }; + result?: string; + error?: string; + latency_us?: number; + mode?: string; + mapping_label?: string; +} + +/** + * Check if an event is a MappingFired event (type guard). + */ +export function isFiredEvent(event: unknown): event is MappingFiredEvent { + return ( + typeof event === 'object' && + event !== null && + (event as { _type?: unknown })._type === 'mapping_fired' + ); } /** * Get the fired event dot class based on trigger type. * Maps daemon trigger type strings to CSS color classes. */ -export function getFiredDotClass(event: any): string { - const triggerType = event?.trigger?.type; +export function getFiredDotClass(event: unknown): string { + if (!isFiredEvent(event)) return 'unknown'; + const triggerType = event.trigger?.type; switch (triggerType) { case 'note': case 'short_press': @@ -163,8 +191,9 @@ export function getFiredDotClass(event: any): string { case 'chord': return 'note'; case 'cc': - case 'encoder': return 'cc'; + case 'encoder': + return 'encoder'; case 'aftertouch': return 'aftertouch'; case 'pitch_bend': From 1ba96b60dcf5ed8cb111b0eea5b5890f6d911362 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 22:47:16 +0000 Subject: [PATCH 04/13] =?UTF-8?q?fix(gui):=20Keyboard=20a11y=20=E2=80=94?= =?UTF-8?q?=20don't=20block=20Learn=20button=20activation=20from=20row=20k?= =?UTF-8?q?eydown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleKeydown was calling preventDefault on Enter/Space unconditionally, which blocked nested Learn button keyboard activation. Now checks e.target === e.currentTarget to only toggle expand when the row itself is focused. Found by LLM Council reasoning-tier verification. Co-Authored-By: Claude Opus 4.6 --- conductor-gui/ui/src/lib/components/EventRow.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/conductor-gui/ui/src/lib/components/EventRow.svelte b/conductor-gui/ui/src/lib/components/EventRow.svelte index 45bec4c4..2cb744ad 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.svelte +++ b/conductor-gui/ui/src/lib/components/EventRow.svelte @@ -38,7 +38,8 @@ } function handleKeydown(e) { - if (e.key === 'Enter' || e.key === ' ') { + // Only handle when the row itself is focused, not child elements (e.g. Learn button) + if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) { e.preventDefault(); expanded = !expanded; } From 3742c8a7b3ccb9f5167db167d3e4133dae303a6a Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 22:50:14 +0000 Subject: [PATCH 05/13] fix(gui): Address 4 PR review comments (#505) - Add aria-expanded={expanded} for screen reader support - Fix _type comment: discriminator is added GUI-side, not from daemon - Add non-null assertions and existence checks on querySelector in tests - Keydown bubble and highlight/fired specificity already addressed Co-Authored-By: Claude Opus 4.6 --- .../ui/src/lib/components/EventRow.svelte | 1 + .../ui/src/lib/components/EventRow.test.ts | 73 ++++++++++++------- .../ui/src/lib/utils/event-helpers.ts | 5 +- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/EventRow.svelte b/conductor-gui/ui/src/lib/components/EventRow.svelte index 2cb744ad..1e69f0d3 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.svelte +++ b/conductor-gui/ui/src/lib/components/EventRow.svelte @@ -65,6 +65,7 @@ on:keydown={handleKeydown} role="button" tabindex="0" + aria-expanded={expanded} >
{#if fired} diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 9002572f..869c338d 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -125,7 +125,8 @@ describe('EventRow', () => { it('expands on click to show detail panel', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row'); + const row = container.querySelector('.event-row')!; + expect(row).toBeTruthy(); await fireEvent.click(row); expect(container.querySelector('.event-row.expanded')).toBeTruthy(); expect(container.querySelector('.event-detail-panel')).toBeTruthy(); @@ -133,7 +134,8 @@ describe('EventRow', () => { it('collapses on second click', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row'); + const row = container.querySelector('.event-row')!; + expect(row).toBeTruthy(); await fireEvent.click(row); expect(container.querySelector('.expanded')).toBeTruthy(); await fireEvent.click(row); @@ -149,32 +151,36 @@ describe('EventRow', () => { it('rotates chevron when expanded', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row'); + const row = container.querySelector('.event-row')!; await fireEvent.click(row); - const chevron = container.querySelector('.event-chevron'); + const chevron = container.querySelector('.event-chevron')!; + expect(chevron).toBeTruthy(); expect(chevron.textContent).toBe('▾'); }); it('detail panel shows device info for raw events', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Device'); expect(panel.textContent).toContain('Mikro'); }); it('detail panel shows channel for raw events', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Channel'); expect(panel.textContent).toContain('1'); }); it('detail panel shows absolute timestamp', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Time'); expect(panel.textContent).toContain('12:34:56.789'); }); @@ -183,7 +189,7 @@ describe('EventRow', () => { it('renders fired icon ⚡ for mapping_fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const icon = container.querySelector('.event-fired-icon'); + const icon = container.querySelector('.event-fired-icon')!; expect(icon).toBeTruthy(); expect(icon.textContent).toBe('⚡'); }); @@ -195,7 +201,8 @@ describe('EventRow', () => { it('renders "Fired" label for mapping_fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const typeLabel = container.querySelector('.event-type'); + const typeLabel = container.querySelector('.event-type')!; + expect(typeLabel).toBeTruthy(); expect(typeLabel.textContent).toBe('Fired'); }); @@ -206,7 +213,8 @@ describe('EventRow', () => { it('shows trigger→action content for fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const detail = container.querySelector('.event-detail'); + const detail = container.querySelector('.event-detail')!; + expect(detail).toBeTruthy(); expect(detail.textContent).toContain('note'); expect(detail.textContent).toContain('⌘+C'); }); @@ -222,8 +230,9 @@ describe('EventRow', () => { it('fired detail panel shows trigger info', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Trigger'); expect(panel.textContent).toContain('note'); expect(panel.textContent).toContain('36'); @@ -231,32 +240,36 @@ describe('EventRow', () => { it('fired detail panel shows action info', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Action'); expect(panel.textContent).toContain('⌘+C'); }); it('fired detail panel shows result', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Result'); expect(panel.textContent).toContain('ok'); }); it('fired detail panel shows latency', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Latency'); expect(panel.textContent).toContain('0.5ms'); }); it('fired detail panel shows mode', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Mode'); expect(panel.textContent).toContain('Default'); }); @@ -268,8 +281,9 @@ describe('EventRow', () => { error: 'App not found', }; const { container } = await importAndRender({ event: errorEvent }); - await fireEvent.click(container.querySelector('.event-row')); - const panel = container.querySelector('.event-detail-panel'); + await fireEvent.click(container.querySelector('.event-row')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); expect(panel.textContent).toContain('error'); expect(panel.textContent).toContain('App not found'); }); @@ -286,14 +300,16 @@ describe('EventRow', () => { it('expands on Enter key', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row'); + const row = container.querySelector('.event-row')!; + expect(row).toBeTruthy(); await fireEvent.keyDown(row, { key: 'Enter' }); expect(container.querySelector('.expanded')).toBeTruthy(); }); it('expands on Space key', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row'); + const row = container.querySelector('.event-row')!; + expect(row).toBeTruthy(); await fireEvent.keyDown(row, { key: ' ' }); expect(container.querySelector('.expanded')).toBeTruthy(); }); @@ -302,7 +318,8 @@ describe('EventRow', () => { it('does not apply fired class when highlight is true', async () => { const { container } = await importAndRender({ event: FIRED_EVENT, highlight: true }); - const row = container.querySelector('.event-row'); + const row = container.querySelector('.event-row')!; + expect(row).toBeTruthy(); expect(row.classList.contains('highlight')).toBe(true); expect(row.classList.contains('fired')).toBe(false); }); diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.ts b/conductor-gui/ui/src/lib/utils/event-helpers.ts index 8d5b08e4..d7ac21da 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.ts @@ -142,7 +142,10 @@ export function formatAbsoluteTime(timestampMs: number): string { } /** - * Narrow type for a "mapping_fired" event from the daemon. + * Narrow type for a "mapping_fired" event as represented in the GUI. + * + * Note: the `_type: "mapping_fired"` discriminator is added on the GUI side + * (e.g. in the event store's pushFiredEvent), not part of the raw daemon payload. */ export interface MappingFiredEvent { _type: 'mapping_fired'; From d9a357e3a76fc9f4f2e6b5c9fd2cac14310c3d21 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 23:27:45 +0000 Subject: [PATCH 06/13] fix(gui): Address 2 PR review comments (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add gamepad_analog and gamepad_trigger to getFiredDotClass (→ encoder/purple) matching CONFIG_TO_FIRED_TYPE in events.js - Replace duplicate formatAbsoluteTime with re-export of formatTimestamp from midi-helpers.ts Co-Authored-By: Claude Opus 4.6 --- .../ui/src/lib/utils/event-helpers.test.ts | 8 ++++++++ conductor-gui/ui/src/lib/utils/event-helpers.ts | 16 ++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.test.ts b/conductor-gui/ui/src/lib/utils/event-helpers.test.ts index 46fcc878..41f5e305 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.test.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.test.ts @@ -251,6 +251,14 @@ describe('getFiredDotClass', () => { expect(getFiredDotClass(firedEvent('gamepad_button'))).toBe('gamepad'); }); + it('returns encoder for gamepad_analog trigger', () => { + expect(getFiredDotClass(firedEvent('gamepad_analog'))).toBe('encoder'); + }); + + it('returns encoder for gamepad_trigger trigger', () => { + expect(getFiredDotClass(firedEvent('gamepad_trigger'))).toBe('encoder'); + }); + it('returns unknown for unrecognized trigger', () => { expect(getFiredDotClass(firedEvent('custom'))).toBe('unknown'); }); diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.ts b/conductor-gui/ui/src/lib/utils/event-helpers.ts index d7ac21da..8111a343 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.ts @@ -129,17 +129,10 @@ export function formatLatency(latencyUs: number): string { } /** - * Format an absolute timestamp to a readable time string. - * Returns HH:MM:SS.mmm format. + * Format an absolute timestamp to HH:MM:SS.mmm. + * Re-exports formatTimestamp from midi-helpers to avoid duplication. */ -export function formatAbsoluteTime(timestampMs: number): string { - const d = new Date(timestampMs); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - const ss = String(d.getSeconds()).padStart(2, '0'); - const ms = String(d.getMilliseconds()).padStart(3, '0'); - return `${hh}:${mm}:${ss}.${ms}`; -} +export { formatTimestamp as formatAbsoluteTime } from './midi-helpers'; /** * Narrow type for a "mapping_fired" event as represented in the GUI. @@ -204,6 +197,9 @@ export function getFiredDotClass(event: unknown): string { case 'gamepad_button': case 'gamepad_chord': return 'gamepad'; + case 'gamepad_analog': + case 'gamepad_trigger': + return 'encoder'; default: return 'unknown'; } From ac94a8368924fa4eae1de612e7a01b788bb86ac5 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 23:42:30 +0000 Subject: [PATCH 07/13] =?UTF-8?q?docs:=20ADR-015=20Async=20Action=20Execut?= =?UTF-8?q?ion=20=E2=80=94=20decouple=20actions=20from=20event=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses event stream stalling during long action sequences (e.g., 25-note Sequence). Proposes dedicated executor task via spawn_blocking with completion channel, MIDI output recursion guard, and ModeChange preservation. Co-Authored-By: Claude Opus 4.6 --- docs/adrs/ADR-015-async-action-execution.md | 302 ++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/adrs/ADR-015-async-action-execution.md diff --git a/docs/adrs/ADR-015-async-action-execution.md b/docs/adrs/ADR-015-async-action-execution.md new file mode 100644 index 00000000..fb165e5c --- /dev/null +++ b/docs/adrs/ADR-015-async-action-execution.md @@ -0,0 +1,302 @@ +# ADR-015: Async Action Execution — Decouple Action Execution from Event Loop + +## Status + +**Proposed** (Pending LLM Council Review) + +## Context + +### Problem Statement + +The daemon's main event loop (`EngineManager::run()`) uses a single `tokio::select!` loop that processes input events, IPC commands, and timer ticks sequentially. When a mapping matches, action execution happens **inline** within `process_device_event()` — the same async task that reads from the input channel. This means the event loop is blocked for the entire duration of action execution. + +For simple actions (single keystroke, volume change), this is imperceptible (<1ms). But for **compound actions** — particularly `Action::Sequence` with many steps — the blocking duration is significant: + +- `Action::Sequence` loops through sub-actions with `thread::sleep(Duration::from_millis(50))` between each step (action_executor.rs:290-298) +- `Action::Delay(ms)` uses `thread::sleep` directly (action_executor.rs:301-303) +- `Action::SendMidi` performs synchronous MIDI I/O per message + +**Example**: A mapping that plays a 25-note tune via `SendMidi` actions in a `Sequence` blocks the event loop for ~1.25 seconds (25 x 50ms inter-action delay). During this time: + +1. **New MIDI events queue up** in the `device_event_rx` channel (the MIDI device thread continues capturing independently) +2. **No events are emitted** to the monitor broadcast channel (no `push_monitor_event` calls) +3. **The GUI event stream freezes** — no new events appear +4. **Once the sequence completes**, all queued events are processed in rapid succession and arrive at the GUI as a burst + +This creates a poor user experience where the event stream appears to "hang" during long action sequences, contradicting the real-time feedback goals of ADR-014. + +### Root Cause Analysis + +``` +Main Event Loop (single async task) +──────────────────────────────────────────────────────────── +tokio::select! { + device_event = device_event_rx.recv() => { + process_device_event() // <-- blocks here + ├── event_processor.process() // fast (~1us) + ├── rules.match_event() // fast (~65ns) + ├── push_monitor_event(matched) // fast + ├── executor.execute(action) // *** BLOCKING *** + │ └── Sequence [ + │ action_1 → sleep(50ms) // thread::sleep! + │ action_2 → sleep(50ms) + │ ... + │ action_25 → sleep(50ms) // total: ~1.25s + │ ] + ├── push_monitor_event(fired) // only after execute returns + └── return // loop resumes + } + command = command_rx.recv() => { ... } // also blocked + _ = tick_interval.tick() => { ... } // also blocked +} +``` + +The `ActionExecutor::execute()` method is synchronous (`fn execute(&mut self, ...) -> DispatchResult`) and uses `thread::sleep` for delays, which blocks the entire tokio runtime thread. + +### Impact Assessment + +| Scenario | Blocking Duration | User Impact | +|----------|------------------|-------------| +| Single keystroke | <1ms | Imperceptible | +| App launch | ~50ms | Imperceptible | +| Shell command | Variable (unbounded) | Potential hang | +| 5-action sequence | ~250ms | Noticeable pause | +| 25-action sequence (tune) | ~1.25s | Event stream freezes visibly | +| Sequence with Delay(500) | 500ms+ per delay | Significant freeze | +| Nested sequences | Multiplicative | Extended freeze | + +### Constraints + +1. **Action ordering within a sequence must be preserved** — actions in a `Sequence` must execute in order with their specified inter-action delays +2. **ModeChange propagation** — `Sequence` currently short-circuits on `ModeChangeRequested` and propagates it to the caller; this must still work +3. **ActionExecutor is `&mut self`** — it holds mutable state (Enigo instance, MIDI output connections) and is behind a `Mutex` +4. **Thread-safety** — `Enigo` (keyboard/mouse simulation) is not `Send + Sync`; it must stay on the thread that created it +5. **Latency measurement** — ADR-014 `MappingFiredPayload.latency_us` measures end-to-end time including execution; spawning changes what "latency" means +6. **Config hot-reload** — The executor reads from config state that can change mid-sequence + +--- + +## Decision + +### D1: Spawn Action Execution on a Dedicated Blocking Thread + +Move action execution off the event loop by spawning it onto a dedicated thread via `tokio::task::spawn_blocking`. The event loop emits a `mapping_matched` monitor event immediately, spawns the action, and continues processing new input events without waiting. + +``` +Main Event Loop (unblocked) Action Thread (blocking OK) +────────────────────────── ─────────────────────────── +device_event received +├── process & match rule +├── push_monitor_event(matched) +├── spawn_blocking(execute) ──────> executor.execute(action) +├── continue select! loop ├── action_1 +│ (process next events) ├── sleep(50ms) +│ ├── action_2 +│ events flow normally... ├── sleep(50ms) +│ └── action_25 +│ <───── result returned +├── receive result via channel +├── handle_dispatch_result() +└── push_monitor_event(fired) +``` + +**Rationale**: `spawn_blocking` is the idiomatic Tokio approach for CPU-bound or blocking-I/O work. It moves the work to Tokio's blocking thread pool, freeing the async runtime for event processing. The `thread::sleep` calls in `Sequence` and `Delay` become harmless — they block a blocking-pool thread, not the async event loop. + +### D2: Use a Completion Channel for Results + +The spawned task sends its result back via a `tokio::sync::mpsc` channel. The main event loop adds this channel as another `select!` branch: + +```rust +// New select! branch in the main loop +Some(completion) = action_completion_rx.recv() => { + self.handle_action_completion(completion).await; +} +``` + +The `ActionCompletion` struct carries everything needed for post-execution handling: + +```rust +struct ActionCompletion { + dispatch_result: DispatchResult, + action_summary: (String, String), // (action_type, summary) + processing_start: Option, + device_id: DeviceId, + mode_name: String, + mapping_label: Option, + trigger_info: FiredTriggerInfo, + matched_event_idx: usize, +} +``` + +**Rationale**: Using a channel integrates naturally with the existing `tokio::select!` loop. The completion handler runs the same `handle_dispatch_result` + `push_monitor_event(fired)` logic that currently runs inline, preserving all existing behavior. + +### D3: Serialize Execution via a Dedicated Executor Task + +Rather than spawning per-action, run a single long-lived blocking task that owns the `ActionExecutor` and processes actions from a queue: + +```rust +// Spawned once at startup +let (action_tx, action_rx) = mpsc::channel::(32); +let (completion_tx, completion_rx) = mpsc::channel::(32); + +tokio::task::spawn_blocking(move || { + let mut executor = ActionExecutor::new(config); + while let Some(request) = action_rx.blocking_recv() { + let result = executor.execute(request.action, request.context); + let _ = completion_tx.blocking_send(ActionCompletion { + result, + metadata: request.metadata, + }); + } +}); +``` + +**Rationale**: This solves the `Enigo` thread-affinity constraint (it stays on one thread), avoids mutex contention (the executor is owned by the task, not shared), and naturally serializes actions (no concurrent execution that could cause keyboard/mouse conflicts). The channel backpressure (capacity 32) provides natural flow control. + +### D4: Emit `mapping_matched` Immediately, `mapping_fired` on Completion + +Split the current single emission point into two: + +1. **`mapping_matched`** — emitted immediately when the rule matches, before action execution begins (already exists but gated behind `capture_actions`) +2. **`mapping_fired`** — emitted when the action completes, via the completion handler + +This preserves the existing event ordering semantics while allowing the GUI to show immediate feedback that a mapping was recognized. + +### D5: MIDI Output Recursion Guard + +When `SendMidi` or `MidiForward` actions output MIDI to a port, those messages must not be re-ingested by the input listener and trigger further mappings, creating infinite recursion. Two levels of protection are needed: + +**Existing protection (virtual ports)**: ADR-009 D21 auto-excludes the daemon's own virtual output ports from input scanning via `set_exclude_port_names()`. This prevents self-loop when outputting to the daemon's virtual port. + +**New protection needed (external ports)**: When a `SendMidi` action sends to a hardware synth (e.g., "Synth MIDI In") and that synth echoes/throughs MIDI back to a port the daemon is listening on, the echoed messages could match mappings and trigger more actions. Two approaches: + +1. **Execution-context flag**: Set a per-device "action-originated" flag during action execution. Events arriving while this flag is set are still captured for display in the event stream (tagged with a provenance marker like `source: "action"`) but are excluded from rule matching. This allows users to see the MIDI output in EVENTS without triggering recursion. + +2. **Message fingerprinting**: Hash outgoing MIDI messages with a short TTL. If an incoming event matches a recent outgoing fingerprint within a time window (e.g., 50ms), skip rule matching but still display in the event stream. + +Approach 1 is simpler and recommended. The provenance marker also enables the GUI to visually distinguish user-generated events from action-generated events (e.g., dimmed or annotated in the event stream). + +### D6: Preserve ModeChange Semantics + +`ModeChange` actions in a `Sequence` currently short-circuit execution and propagate the mode change immediately. With async execution, the completion handler processes `DispatchOutcome::ModeChangeRequested` and applies it. This introduces a small delay (the time for the completion message to traverse the channel), but mode changes are user-initiated and the delay is sub-millisecond. + +For sequences containing a `ModeChange` mid-sequence, the current behavior already stops executing subsequent actions. This is preserved because the executor task handles it locally. + +### D7: Latency Measurement Adjustment + +`MappingFiredPayload.latency_us` currently measures "event receipt to post-execution completion." With async execution, this still works correctly — the `processing_start` timestamp is captured before spawning, and `latency_us` is computed in the completion handler. The latency now reflects true end-to-end time including any queuing delay, which is more accurate. + +--- + +## Alternatives Considered + +### A1: `tokio::spawn` (async task) Instead of `spawn_blocking` + +Use `tokio::spawn` and convert `thread::sleep` to `tokio::time::sleep`. + +**Rejected because**: +- Requires making `ActionExecutor` `Send + Sync`, which conflicts with `Enigo`'s thread-affinity +- Would need to refactor all synchronous I/O (MIDI sends, shell commands) to async equivalents +- `Enigo::new()` panics if called from the wrong thread on some platforms +- Much larger refactoring scope for marginal benefit + +### A2: Per-Action `spawn_blocking` + +Spawn a new blocking task for each individual action, rather than maintaining a persistent executor task. + +**Rejected because**: +- `ActionExecutor` holds mutable state (Enigo instance, MIDI connections) that would need to be shared across tasks via `Arc>` +- Creates `Enigo` thread-affinity issues — each spawn_blocking may run on a different thread +- No guarantee of execution order for rapid-fire mappings +- Higher overhead from repeated spawning + +### A3: Thread Pool with Work Stealing + +Maintain a pool of executor threads for parallel action execution. + +**Rejected because**: +- Keyboard/mouse simulation (Enigo) is inherently single-threaded — concurrent keystrokes would interleave +- MIDI output ports are single-writer — concurrent sends would corrupt messages +- Adds complexity without benefit since actions must serialize anyway +- Over-engineered for the actual problem + +### A4: Event Loop Yielding + +Insert `tokio::task::yield_now()` between sequence steps instead of `thread::sleep`. + +**Rejected because**: +- Doesn't solve the fundamental issue — Enigo calls and MIDI I/O are still blocking +- `yield_now()` only yields to other tasks on the same runtime, not to the select! loop +- Would require converting `execute()` to `async fn`, which cascades through the entire API +- Doesn't help with shell commands or other blocking operations + +--- + +## Implementation Plan + +### Phase 1: Executor Task Infrastructure + +1. Create `ActionRequest` and `ActionCompletion` types +2. Spawn dedicated executor task in `EngineManager::new()` with channels +3. Add `action_completion_rx` branch to the `select!` loop +4. Move `ActionExecutor` ownership to the spawned task +5. Remove `action_executor: Mutex` from `EngineManager` + +### Phase 2: Decouple Execution + +1. In `process_device_event()`, send `ActionRequest` to the executor channel instead of calling `execute()` directly +2. Move `handle_dispatch_result()` and `mapping_fired` emission to `handle_action_completion()` +3. Preserve `mapping_matched` emission at the current (pre-execution) location +4. Update latency tracking to span the full async lifecycle + +### Phase 3: Testing & Verification + +1. Unit test: verify event loop continues processing events during long sequences +2. Integration test: 25-action sequence doesn't block event emission +3. Verify ModeChange propagation still works +4. Verify latency_us measurements are accurate +5. Verify no regressions in existing action types +6. Performance benchmark: measure overhead of channel-based dispatch + +### Phase 4: Cleanup + +1. Remove `thread::sleep` from `Sequence` — replace with blocking channel-aware delay that allows clean shutdown +2. Update `capture_actions` to reload on hot-reload (see #510) +3. Update ADR-014 references if event ordering changes + +--- + +## Consequences + +### Positive + +- **Event stream remains responsive** during long action sequences +- **ADR-014 feedback is immediate** — `mapping_matched` appears instantly, `mapping_fired` follows on completion +- **No more blocking the async runtime** with `thread::sleep` — eliminates a class of potential issues +- **Clean separation of concerns** — event processing and action execution are independent +- **Natural backpressure** — channel capacity limits queue depth if actions back up + +### Negative + +- **Slightly more complex architecture** — adds a channel and a completion handler +- **ModeChange has sub-millisecond additional latency** — negligible in practice +- **Action execution is no longer synchronous** from the event loop's perspective — harder to reason about ordering when multiple mappings fire in rapid succession +- **Debugging becomes harder** — action execution is on a different thread; stack traces span tasks + +### Neutral + +- **Action ordering is preserved** — the dedicated executor task processes actions FIFO +- **Latency measurement changes semantics slightly** — now includes channel transit time (~microseconds) +- **Existing tests need updates** — tests that assert synchronous execution behavior + +--- + +## References + +- ADR-014: Mapping Feedback & Simulate (the feature that exposed this issue) +- Issue #510: `capture_actions` not updated on config hot-reload +- `conductor-daemon/src/daemon/engine_manager.rs:637` — Main event loop (`tokio::select!`) +- `conductor-daemon/src/daemon/engine_manager.rs:3389-3393` — Inline action execution +- `conductor-daemon/src/action_executor.rs:270` — `ActionExecutor::execute()` (synchronous) +- `conductor-daemon/src/action_executor.rs:290-298` — `Sequence` with `thread::sleep(50ms)` +- `conductor-daemon/src/action_executor.rs:301-303` — `Delay` with `thread::sleep` From 37487eecbbe1adaf535a7e89e5645d2ea5120de8 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 23:56:40 +0000 Subject: [PATCH 08/13] =?UTF-8?q?docs:=20ADR-015=20revision=20=E2=80=94=20?= =?UTF-8?q?address=20all=208=20council=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites ADR-015 to resolve critical issues from reasoning-tier review: - Consolidate D1/D3 into single std::thread actor (not spawn_blocking) - Use try_send to eliminate bidirectional channel deadlock - Unbounded completion channel to prevent backpressure deadlock - Acknowledge ModeChange semantic change (deliberate, more intuitive) - Message fingerprinting for MIDI recursion guard (not coarse flag) - Config hot-reload via ConfigUpdate control messages - Document head-of-line blocking as accepted tradeoff - Add invocation_id for matched/fired event correlation - Add graceful shutdown with cancellation-aware waits - Include council review history section Co-Authored-By: Claude Opus 4.6 --- docs/adrs/ADR-015-async-action-execution.md | 311 +++++++++++++------- 1 file changed, 207 insertions(+), 104 deletions(-) diff --git a/docs/adrs/ADR-015-async-action-execution.md b/docs/adrs/ADR-015-async-action-execution.md index fb165e5c..4f22f7bb 100644 --- a/docs/adrs/ADR-015-async-action-execution.md +++ b/docs/adrs/ADR-015-async-action-execution.md @@ -2,7 +2,7 @@ ## Status -**Proposed** (Pending LLM Council Review) +**Proposed** (Revised after LLM Council Review — reasoning tier) ## Context @@ -12,8 +12,8 @@ The daemon's main event loop (`EngineManager::run()`) uses a single `tokio::sele For simple actions (single keystroke, volume change), this is imperceptible (<1ms). But for **compound actions** — particularly `Action::Sequence` with many steps — the blocking duration is significant: -- `Action::Sequence` loops through sub-actions with `thread::sleep(Duration::from_millis(50))` between each step (action_executor.rs:290-298) -- `Action::Delay(ms)` uses `thread::sleep` directly (action_executor.rs:301-303) +- `Action::Sequence` loops through sub-actions with `thread::sleep(Duration::from_millis(50))` between each step (`action_executor.rs:290-298`) +- `Action::Delay(ms)` uses `thread::sleep` directly (`action_executor.rs:301-303`) - `Action::SendMidi` performs synchronous MIDI I/O per message **Example**: A mapping that plays a 25-note tune via `SendMidi` actions in a `Sequence` blocks the event loop for ~1.25 seconds (25 x 50ms inter-action delay). During this time: @@ -33,18 +33,18 @@ Main Event Loop (single async task) tokio::select! { device_event = device_event_rx.recv() => { process_device_event() // <-- blocks here - ├── event_processor.process() // fast (~1us) - ├── rules.match_event() // fast (~65ns) - ├── push_monitor_event(matched) // fast - ├── executor.execute(action) // *** BLOCKING *** - │ └── Sequence [ - │ action_1 → sleep(50ms) // thread::sleep! - │ action_2 → sleep(50ms) - │ ... - │ action_25 → sleep(50ms) // total: ~1.25s - │ ] - ├── push_monitor_event(fired) // only after execute returns - └── return // loop resumes + |-- event_processor.process() // fast (~1us) + |-- rules.match_event() // fast (~65ns) + |-- push_monitor_event(matched) // fast + |-- executor.execute(action) // *** BLOCKING *** + | +-- Sequence [ + | action_1 -> sleep(50ms) // thread::sleep! + | action_2 -> sleep(50ms) + | ... + | action_25 -> sleep(50ms) // total: ~1.25s + | ] + |-- push_monitor_event(fired) // only after execute returns + +-- return // loop resumes } command = command_rx.recv() => { ... } // also blocked _ = tick_interval.tick() => { ... } // also blocked @@ -73,38 +73,61 @@ The `ActionExecutor::execute()` method is synchronous (`fn execute(&mut self, .. 4. **Thread-safety** — `Enigo` (keyboard/mouse simulation) is not `Send + Sync`; it must stay on the thread that created it 5. **Latency measurement** — ADR-014 `MappingFiredPayload.latency_us` measures end-to-end time including execution; spawning changes what "latency" means 6. **Config hot-reload** — The executor reads from config state that can change mid-sequence +7. **Head-of-line blocking** — Serialized execution means one long action delays all subsequent actions (acknowledged tradeoff; see D8) --- ## Decision -### D1: Spawn Action Execution on a Dedicated Blocking Thread +### D1: Dedicated Executor Thread (Not `spawn_blocking`) -Move action execution off the event loop by spawning it onto a dedicated thread via `tokio::task::spawn_blocking`. The event loop emits a `mapping_matched` monitor event immediately, spawns the action, and continues processing new input events without waiting. +Spawn a single, long-lived `std::thread` at startup that owns the `ActionExecutor` exclusively and processes actions from an inbound channel. This thread runs for the process lifetime and is explicitly joined on shutdown. + +**Why `std::thread` and not `tokio::task::spawn_blocking`**: `spawn_blocking` is designed for short-lived blocking work units, not permanent service threads. Using it for a permanent actor loop holds a blocking-pool thread hostage and makes shutdown/cancellation harder to control. A dedicated `std::thread` with an explicit stop message and cancellation-aware waiting is architecturally cleaner. ``` -Main Event Loop (unblocked) Action Thread (blocking OK) -────────────────────────── ─────────────────────────── -device_event received -├── process & match rule -├── push_monitor_event(matched) -├── spawn_blocking(execute) ──────> executor.execute(action) -├── continue select! loop ├── action_1 -│ (process next events) ├── sleep(50ms) -│ ├── action_2 -│ events flow normally... ├── sleep(50ms) -│ └── action_25 -│ <───── result returned -├── receive result via channel -├── handle_dispatch_result() -└── push_monitor_event(fired) +Startup: + std::thread::spawn("action-executor", move || { + let mut executor = ActionExecutor::new(config_snapshot); + loop { + match action_rx.recv() { // std::sync::mpsc + Ok(ActionRequest::Execute { .. }) => { ... } + Ok(ActionRequest::Shutdown) => break, + Err(_) => break, // channel closed + } + } + }) ``` -**Rationale**: `spawn_blocking` is the idiomatic Tokio approach for CPU-bound or blocking-I/O work. It moves the work to Tokio's blocking thread pool, freeing the async runtime for event processing. The `thread::sleep` calls in `Sequence` and `Delay` become harmless — they block a blocking-pool thread, not the async event loop. +**Rationale**: This solves the `Enigo` thread-affinity constraint (it stays on one thread for the process lifetime), avoids mutex contention (the executor is owned, not shared), and naturally serializes actions (no concurrent execution that could cause keyboard/mouse conflicts). -### D2: Use a Completion Channel for Results +### D2: Non-Blocking Dispatch with `try_send` -The spawned task sends its result back via a `tokio::sync::mpsc` channel. The main event loop adds this channel as another `select!` branch: +The event loop dispatches actions to the executor using `try_send` on a bounded channel, **never** `send().await`. This is critical to avoid reintroducing event-loop stalls via backpressure. + +```rust +// In process_device_event() — NEVER awaits +match action_tx.try_send(request) { + Ok(()) => { /* dispatched */ } + Err(TrySendError::Full(_)) => { + warn!("Action queue full — dropping action"); + push_monitor_event(action_dropped_event); + } + Err(TrySendError::Disconnected(_)) => { + error!("Executor thread died"); + } +} +``` + +**Why not `send().await`**: If the action queue is full and the event loop awaits on `send()`, it stops draining the completion channel. Meanwhile, the executor may block on `completion_tx.send()` because completions aren't being consumed. This creates a classic **bidirectional bounded-channel deadlock**. Using `try_send` eliminates this entirely — the event loop never blocks on dispatch. + +**Overload policy**: When the queue is full, the action is dropped and a `mapping_dropped` monitor event is emitted so the GUI can display it. This is the correct behavior — if actions are backing up faster than they can execute, dropping is better than freezing. + +**Channel capacity**: 32 slots. At one action per mapping match, this accommodates rapid-fire scenarios without overflow. If overflow occurs, it indicates a pathological input pattern. + +### D3: Unbounded Completion Channel + +The executor sends completions back via an **unbounded** `tokio::sync::mpsc::unbounded_channel`. The event loop drains completions in a `select!` branch: ```rust // New select! branch in the main loop @@ -113,84 +136,121 @@ Some(completion) = action_completion_rx.recv() => { } ``` -The `ActionCompletion` struct carries everything needed for post-execution handling: +**Why unbounded**: The completion channel flows from executor to event loop. Since the event loop always drains completions promptly (it's the consumer), unbounded is safe — completions arrive at most as fast as actions complete, which is rate-limited by execution time. Making this channel bounded would reintroduce the deadlock risk from D2. + +### D4: Invocation ID for Event Correlation + +Each action dispatch is assigned a unique `invocation_id: u64` (monotonic counter). This ID is carried in both `mapping_matched` and `mapping_fired` monitor events, allowing the GUI and downstream consumers to reliably pair them even when events interleave: + +``` +Timeline with async execution: + mapping_matched(invocation=1, trigger=Note36) + mapping_matched(invocation=2, trigger=Note48) // arrives during action 1 + mapping_fired(invocation=1, result=ok) // action 1 completes + mapping_fired(invocation=2, result=ok) // action 2 completes +``` + +**Why needed**: Under synchronous execution, `matched` and `fired` events always appeared consecutively. With async execution, they can interleave. Without a correlation ID, the GUI cannot reliably determine which `fired` event corresponds to which `matched` event. + +The `ActionCompletion` struct carries the invocation ID and execution metadata: ```rust struct ActionCompletion { + invocation_id: u64, dispatch_result: DispatchResult, - action_summary: (String, String), // (action_type, summary) - processing_start: Option, + action_type: String, + action_summary: String, + processing_start: Instant, device_id: DeviceId, mode_name: String, mapping_label: Option, trigger_info: FiredTriggerInfo, - matched_event_idx: usize, } ``` -**Rationale**: Using a channel integrates naturally with the existing `tokio::select!` loop. The completion handler runs the same `handle_dispatch_result` + `push_monitor_event(fired)` logic that currently runs inline, preserving all existing behavior. +### D5: Emit `mapping_matched` Immediately, `mapping_fired` on Completion -### D3: Serialize Execution via a Dedicated Executor Task +Split the current single emission point into two: -Rather than spawning per-action, run a single long-lived blocking task that owns the `ActionExecutor` and processes actions from a queue: +1. **`mapping_matched`** — emitted immediately when the rule matches, before action execution begins. Includes `invocation_id` for correlation. +2. **`mapping_fired`** — emitted when the action completes, via the completion handler. Includes the same `invocation_id`, result, and latency. -```rust -// Spawned once at startup -let (action_tx, action_rx) = mpsc::channel::(32); -let (completion_tx, completion_rx) = mpsc::channel::(32); - -tokio::task::spawn_blocking(move || { - let mut executor = ActionExecutor::new(config); - while let Some(request) = action_rx.blocking_recv() { - let result = executor.execute(request.action, request.context); - let _ = completion_tx.blocking_send(ActionCompletion { - result, - metadata: request.metadata, - }); - } -}); -``` +This preserves the existing event ordering semantics while allowing the GUI to show immediate feedback that a mapping was recognized. -**Rationale**: This solves the `Enigo` thread-affinity constraint (it stays on one thread), avoids mutex contention (the executor is owned by the task, not shared), and naturally serializes actions (no concurrent execution that could cause keyboard/mouse conflicts). The channel backpressure (capacity 32) provides natural flow control. +### D6: ModeChange as Semantic Barrier -### D4: Emit `mapping_matched` Immediately, `mapping_fired` on Completion +ModeChange is the most important semantic concern with async execution. Under the current synchronous model, while an action sequence runs, **no new events are processed** — they queue. Under async execution, new events continue to be matched against the **current mode** while the action runs in the background. -Split the current single emission point into two: +This means: if a `Sequence` contains `Delay(500) -> ModeChange("Layer2")`, and the user presses another key 100ms after the sequence starts, under the old model that key is processed after the mode change; under the new model, it's matched against the old mode. -1. **`mapping_matched`** — emitted immediately when the rule matches, before action execution begins (already exists but gated behind `capture_actions`) -2. **`mapping_fired`** — emitted when the action completes, via the completion handler +**Decision**: Acknowledge and document this as a **deliberate semantic change**, not a bug: -This preserves the existing event ordering semantics while allowing the GUI to show immediate feedback that a mapping was recognized. +1. **The new behavior is actually more intuitive** for users: the mode changes when the action completes, and user input during execution is processed immediately rather than queued. Users expect their inputs to be responsive, not silently delayed. +2. **Add a `mode_change_pending` flag**: When a `ModeChangeRequested` completion arrives, apply the mode change immediately via the existing ArcSwap atomic update. New events arriving after the completion is processed will match against the new mode. +3. **Document the behavioral difference**: If exact synchronous-barrier semantics are needed in the future, a `BarrierAction` type could be added that pauses event-loop processing until the action completes. This is not needed now. -### D5: MIDI Output Recursion Guard +**Why not pause event processing during ModeChange sequences**: This would reintroduce the exact blocking problem this ADR solves. The whole point is to keep the event loop responsive. -When `SendMidi` or `MidiForward` actions output MIDI to a port, those messages must not be re-ingested by the input listener and trigger further mappings, creating infinite recursion. Two levels of protection are needed: +### D7: MIDI Output Recursion Guard via Message Fingerprinting + +When `SendMidi` or `MidiForward` actions output MIDI to a port, those messages must not be re-ingested by the input listener and trigger further mappings, creating infinite recursion. Two levels of protection exist: **Existing protection (virtual ports)**: ADR-009 D21 auto-excludes the daemon's own virtual output ports from input scanning via `set_exclude_port_names()`. This prevents self-loop when outputting to the daemon's virtual port. -**New protection needed (external ports)**: When a `SendMidi` action sends to a hardware synth (e.g., "Synth MIDI In") and that synth echoes/throughs MIDI back to a port the daemon is listening on, the echoed messages could match mappings and trigger more actions. Two approaches: +**New protection (external echo/thru)**: Use **message fingerprinting** with a short TTL rather than a coarse execution-context flag. + +**Why not an execution-context flag**: A per-device flag set for the entire action duration would suppress **all** legitimate user input from the same device during a long sequence. A 25-note tune that takes 1.25 seconds would make the controller unresponsive for that duration — worse than the original problem. + +**Fingerprint design**: +- When `SendMidi`/`MidiForward` outputs a message, compute a lightweight fingerprint (message type + channel + note/CC number, ~4 bytes) and insert it into a bounded ring buffer with timestamp. +- When an incoming event arrives, check if its fingerprint matches any entry in the buffer within a configurable TTL window (default: 100ms). +- **Match**: Skip rule matching, but still emit to the monitor stream with `source: "echo"` provenance. The GUI displays these events but visually annotates them (e.g., dimmed or with an echo icon). +- **No match**: Process normally as user input. +- Buffer capacity: 64 entries with LRU eviction. Bounded memory, no allocation. + +This approach is precise: it only suppresses the specific messages the daemon sent, not all input from the device. Users can continue playing while a sequence runs. + +### D8: Head-of-Line Blocking — Acknowledged Tradeoff + +Serializing all actions through a single executor means one long-running shell command or MIDI sequence can delay all subsequent actions. This is an **accepted tradeoff**: + +- **Enigo (keyboard/mouse)** is inherently single-threaded — concurrent keystrokes would interleave +- **MIDI output ports** are single-writer — concurrent sends would corrupt messages +- The alternative (parallel execution) introduces far more complexity for minimal real-world benefit -1. **Execution-context flag**: Set a per-device "action-originated" flag during action execution. Events arriving while this flag is set are still captured for display in the event stream (tagged with a provenance marker like `source: "action"`) but are excluded from rule matching. This allows users to see the MIDI output in EVENTS without triggering recursion. +If this becomes a problem in practice, a future enhancement could partition actions by type (e.g., MIDI output on one thread, keyboard on another), but this is not needed now. -2. **Message fingerprinting**: Hash outgoing MIDI messages with a short TTL. If an incoming event matches a recent outgoing fingerprint within a time window (e.g., 50ms), skip rule matching but still display in the event stream. +### D9: Config Hot-Reload for Executor State -Approach 1 is simpler and recommended. The provenance marker also enables the GUI to visually distinguish user-generated events from action-generated events (e.g., dimmed or annotated in the event stream). +The executor owns long-lived mutable state and actions may sit queued before execution. The config reload policy is: -### D6: Preserve ModeChange Semantics +1. **Actions execute against the config snapshot at match time**: The `ActionRequest` carries any config-dependent data (e.g., MIDI port names) captured when the rule matched. The executor does not re-read config during execution. +2. **Executor-level state updates via control messages**: When a config reload occurs, the event loop sends an `ActionRequest::ConfigUpdate(new_config)` message through the action channel. The executor applies it between action executions (never mid-sequence). This serializes config updates with action execution, avoiding races. +3. **Queued actions are not invalidated on reload**: Actions already in the queue were matched under the old config and execute as-is. This is consistent with the current behavior where in-flight actions are not interrupted by hot-reload. -`ModeChange` actions in a `Sequence` currently short-circuit execution and propagate the mode change immediately. With async execution, the completion handler processes `DispatchOutcome::ModeChangeRequested` and applies it. This introduces a small delay (the time for the completion message to traverse the channel), but mode changes are user-initiated and the delay is sub-millisecond. +### D10: Graceful Shutdown -For sequences containing a `ModeChange` mid-sequence, the current behavior already stops executing subsequent actions. This is preserved because the executor task handles it locally. +The executor thread supports clean shutdown: -### D7: Latency Measurement Adjustment +1. Event loop sends `ActionRequest::Shutdown` through the action channel +2. Executor finishes the current action (if any), then exits the loop +3. `thread::sleep` in `Sequence`/`Delay` is replaced with a cancellation-aware wait that checks a `shutdown: AtomicBool` flag between steps, allowing mid-sequence abort +4. The main thread joins the executor thread with a timeout (e.g., 5 seconds), then force-terminates if needed -`MappingFiredPayload.latency_us` currently measures "event receipt to post-execution completion." With async execution, this still works correctly — the `processing_start` timestamp is captured before spawning, and `latency_us` is computed in the completion handler. The latency now reflects true end-to-end time including any queuing delay, which is more accurate. +### D11: Latency Measurement + +`MappingFiredPayload.latency_us` measures "event receipt to post-execution completion." With async execution: + +- `processing_start` is captured in `process_device_event()` before dispatch +- `latency_us` is computed in `handle_action_completion()` after the executor returns +- The measurement now includes channel transit time (~microseconds), which is negligible +- This is arguably more accurate — it reflects the true end-to-end time the user experiences --- ## Alternatives Considered -### A1: `tokio::spawn` (async task) Instead of `spawn_blocking` +### A1: `tokio::spawn` (async task) Instead of Dedicated Thread Use `tokio::spawn` and convert `thread::sleep` to `tokio::time::sleep`. @@ -206,7 +266,7 @@ Spawn a new blocking task for each individual action, rather than maintaining a **Rejected because**: - `ActionExecutor` holds mutable state (Enigo instance, MIDI connections) that would need to be shared across tasks via `Arc>` -- Creates `Enigo` thread-affinity issues — each spawn_blocking may run on a different thread +- Creates `Enigo` thread-affinity issues — each `spawn_blocking` may run on a different thread - No guarantee of execution order for rapid-fire mappings - Higher overhead from repeated spawning @@ -226,43 +286,64 @@ Insert `tokio::task::yield_now()` between sequence steps instead of `thread::sle **Rejected because**: - Doesn't solve the fundamental issue — Enigo calls and MIDI I/O are still blocking -- `yield_now()` only yields to other tasks on the same runtime, not to the select! loop +- `yield_now()` only yields to other tasks on the same runtime, not to the `select!` loop - Would require converting `execute()` to `async fn`, which cascades through the entire API - Doesn't help with shell commands or other blocking operations +### A5: Execution-Context Flag for MIDI Recursion Guard + +Set a per-device flag during action execution to suppress all input from that device. + +**Rejected because**: +- Suppresses legitimate user input for the entire duration of long action sequences +- A 25-note tune (1.25s) would make the controller unresponsive — worse than the original problem +- Device identity may not map cleanly from output port to echoed input source +- Message fingerprinting (D7) is more precise with bounded overhead + --- ## Implementation Plan -### Phase 1: Executor Task Infrastructure +### Phase 1: Executor Thread Infrastructure -1. Create `ActionRequest` and `ActionCompletion` types -2. Spawn dedicated executor task in `EngineManager::new()` with channels +1. Create `ActionRequest` enum (`Execute`, `ConfigUpdate`, `Shutdown`) and `ActionCompletion` struct with `invocation_id` +2. Spawn dedicated `std::thread` in `EngineManager::new()` with `std::sync::mpsc` for requests and `tokio::sync::mpsc::unbounded_channel` for completions 3. Add `action_completion_rx` branch to the `select!` loop -4. Move `ActionExecutor` ownership to the spawned task +4. Move `ActionExecutor` ownership to the spawned thread 5. Remove `action_executor: Mutex` from `EngineManager` +6. Add monotonic `invocation_counter: u64` to `EngineManager` ### Phase 2: Decouple Execution -1. In `process_device_event()`, send `ActionRequest` to the executor channel instead of calling `execute()` directly -2. Move `handle_dispatch_result()` and `mapping_fired` emission to `handle_action_completion()` -3. Preserve `mapping_matched` emission at the current (pre-execution) location -4. Update latency tracking to span the full async lifecycle +1. In `process_device_event()`, use `try_send` to dispatch `ActionRequest` — emit `mapping_dropped` on queue full +2. Emit `mapping_matched` with `invocation_id` immediately at match time +3. In `handle_action_completion()`, run `handle_dispatch_result()` + emit `mapping_fired` with same `invocation_id` +4. Wire config reload to send `ActionRequest::ConfigUpdate` -### Phase 3: Testing & Verification +### Phase 3: MIDI Recursion Guard -1. Unit test: verify event loop continues processing events during long sequences -2. Integration test: 25-action sequence doesn't block event emission -3. Verify ModeChange propagation still works -4. Verify latency_us measurements are accurate -5. Verify no regressions in existing action types -6. Performance benchmark: measure overhead of channel-based dispatch +1. Implement fingerprint ring buffer (bounded, 64 entries, TTL-based eviction) +2. In `ActionExecutor::execute_send_midi()` and `MidiForward`, record outgoing fingerprints +3. In `process_device_event()`, check incoming events against fingerprint buffer before rule matching +4. Tag matched echoes with `source: "echo"` provenance in monitor events -### Phase 4: Cleanup +### Phase 4: Shutdown & Cancellation -1. Remove `thread::sleep` from `Sequence` — replace with blocking channel-aware delay that allows clean shutdown -2. Update `capture_actions` to reload on hot-reload (see #510) -3. Update ADR-014 references if event ordering changes +1. Replace `thread::sleep` in `Sequence`/`Delay` with cancellation-aware wait using `shutdown: AtomicBool` +2. Send `ActionRequest::Shutdown` in `EngineManager::drop()` / shutdown handler +3. Join executor thread with 5-second timeout + +### Phase 5: Testing & Verification + +1. Unit test: verify event loop continues processing events during long sequences +2. Integration test: 25-action sequence doesn't block event emission +3. Test: ModeChange applied correctly via completion handler +4. Test: MIDI echo fingerprinting suppresses re-ingestion but not user input +5. Test: config hot-reload via `ConfigUpdate` message +6. Test: graceful shutdown mid-sequence +7. Test: `invocation_id` correlation between matched/fired events +8. Test: `try_send` overflow emits `mapping_dropped` +9. Performance benchmark: measure overhead of channel-based dispatch vs inline --- @@ -274,20 +355,42 @@ Insert `tokio::task::yield_now()` between sequence steps instead of `thread::sle - **ADR-014 feedback is immediate** — `mapping_matched` appears instantly, `mapping_fired` follows on completion - **No more blocking the async runtime** with `thread::sleep` — eliminates a class of potential issues - **Clean separation of concerns** — event processing and action execution are independent -- **Natural backpressure** — channel capacity limits queue depth if actions back up +- **MIDI echo protection** without suppressing legitimate user input +- **Graceful shutdown** with mid-sequence cancellation ### Negative -- **Slightly more complex architecture** — adds a channel and a completion handler -- **ModeChange has sub-millisecond additional latency** — negligible in practice -- **Action execution is no longer synchronous** from the event loop's perspective — harder to reason about ordering when multiple mappings fire in rapid succession -- **Debugging becomes harder** — action execution is on a different thread; stack traces span tasks +- **More complex architecture** — adds a thread, two channels, a completion handler, and a fingerprint buffer +- **ModeChange semantic change** — events arriving during async action execution are processed under the current mode rather than queued. This is more responsive but differs from the old blocking behavior (see D6) +- **Head-of-line blocking** — one long action delays all subsequent actions (accepted tradeoff, see D8) +- **Debugging is harder** — action execution is on a different thread; stack traces span thread boundaries +- **Action drops on overload** — if the queue fills, actions are dropped with a warning event rather than queued indefinitely ### Neutral -- **Action ordering is preserved** — the dedicated executor task processes actions FIFO -- **Latency measurement changes semantics slightly** — now includes channel transit time (~microseconds) +- **Action ordering is preserved** — the dedicated executor thread processes actions FIFO +- **Latency measurement includes channel transit** — adds ~microseconds, negligible in practice - **Existing tests need updates** — tests that assert synchronous execution behavior +- **Invocation IDs** add a small per-event overhead but enable reliable event correlation + +--- + +## Council Review History + +### Review 1 (2026-03-07, reasoning tier, verdict: FAIL, confidence: 0.49) + +Transcript: `.council/logs/2026-03-07T23-42-34-f2adea2e` + +Critical issues identified by the council (all 4 reviewers ranked unanimously): + +1. **D1/D3 inconsistency**: D1 described per-action `spawn_blocking`, D3 described a persistent executor, A2 rejected D1's model. Contradictory. **Fixed**: Consolidated into D1 as a single `std::thread` actor (not `spawn_blocking`). Removed the separate D3 section. +2. **Bidirectional channel deadlock**: Bounded channels in both directions — if event loop awaits `send()` on full action queue while executor blocks on full completion queue, deadlock. **Fixed**: D2 uses `try_send` (never blocks), D3 uses unbounded completion channel. +3. **ModeChange semantics broken**: Events processed under old mode while async action runs, contradicting claim of "preserved semantics." **Fixed**: D6 acknowledges this as a deliberate semantic change and documents why the new behavior is actually more intuitive. +4. **MIDI recursion guard too coarse**: Per-device execution-context flag suppresses legitimate user input during long sequences. **Fixed**: D7 uses message fingerprinting with TTL instead, moved execution-context flag to rejected alternatives (A5). +5. **Config hot-reload unresolved**: No mechanism defined for updating executor state on reload. **Fixed**: D9 defines config-at-match-time policy with `ConfigUpdate` control messages. +6. **Head-of-line blocking undocumented**: Single executor means one long action delays all others. **Fixed**: D8 explicitly acknowledges this as an accepted tradeoff with rationale. +7. **`spawn_blocking` misuse for permanent thread**: Anti-pattern per Tokio docs. **Fixed**: D1 now uses `std::thread`. +8. **Event correlation missing**: No way to pair `matched`/`fired` events when they interleave. **Fixed**: D4 adds `invocation_id` monotonic counter. --- From 80eca4de0efc41f827fca4d0fda25f435b278807 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 8 Mar 2026 00:15:08 +0000 Subject: [PATCH 09/13] =?UTF-8?q?docs:=20ADR-015=20revision=202=20?= =?UTF-8?q?=E2=80=94=20address=207=20council=20review=202=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key changes from review 2 feedback: - D2: Separate control plane (shutdown, config) from data plane (actions) with priority-select pattern — control messages never dropped - D11: Interruptible sleep loop (50ms intervals), shell command timeouts, process-group kill, bounded shutdown latency (5s max) - D7: Document ModeChange nondeterminism at completion boundaries - D8: Full raw MIDI bytes + port name for fingerprinting, thru_pairs config - D6: Explicitly note breaking change in event ordering semantics - D4: Qualify unbounded completion channel safety assumptions - D9: Acknowledge global queue unfairness, defer per-device queues Co-Authored-By: Claude Opus 4.6 --- docs/adrs/ADR-015-async-action-execution.md | 275 ++++++++++++-------- 1 file changed, 168 insertions(+), 107 deletions(-) diff --git a/docs/adrs/ADR-015-async-action-execution.md b/docs/adrs/ADR-015-async-action-execution.md index 4f22f7bb..e0e9d248 100644 --- a/docs/adrs/ADR-015-async-action-execution.md +++ b/docs/adrs/ADR-015-async-action-execution.md @@ -2,7 +2,7 @@ ## Status -**Proposed** (Revised after LLM Council Review — reasoning tier) +**Proposed** (Revised after 2 rounds of LLM Council Review — reasoning tier) ## Context @@ -73,7 +73,7 @@ The `ActionExecutor::execute()` method is synchronous (`fn execute(&mut self, .. 4. **Thread-safety** — `Enigo` (keyboard/mouse simulation) is not `Send + Sync`; it must stay on the thread that created it 5. **Latency measurement** — ADR-014 `MappingFiredPayload.latency_us` measures end-to-end time including execution; spawning changes what "latency" means 6. **Config hot-reload** — The executor reads from config state that can change mid-sequence -7. **Head-of-line blocking** — Serialized execution means one long action delays all subsequent actions (acknowledged tradeoff; see D8) +7. **Head-of-line blocking** — Serialized execution means one long action delays all subsequent actions (acknowledged tradeoff; see D9) --- @@ -81,37 +81,65 @@ The `ActionExecutor::execute()` method is synchronous (`fn execute(&mut self, .. ### D1: Dedicated Executor Thread (Not `spawn_blocking`) -Spawn a single, long-lived `std::thread` at startup that owns the `ActionExecutor` exclusively and processes actions from an inbound channel. This thread runs for the process lifetime and is explicitly joined on shutdown. +Spawn a single, long-lived `std::thread` at startup that owns the `ActionExecutor` exclusively. This thread runs for the process lifetime and is explicitly joined on shutdown. -**Why `std::thread` and not `tokio::task::spawn_blocking`**: `spawn_blocking` is designed for short-lived blocking work units, not permanent service threads. Using it for a permanent actor loop holds a blocking-pool thread hostage and makes shutdown/cancellation harder to control. A dedicated `std::thread` with an explicit stop message and cancellation-aware waiting is architecturally cleaner. +**Why `std::thread` and not `tokio::task::spawn_blocking`**: `spawn_blocking` is designed for short-lived blocking work units, not permanent service threads. Using it for a permanent actor loop holds a blocking-pool thread hostage and makes shutdown/cancellation harder to control. A dedicated `std::thread` with an explicit stop signal and cancellation-aware waiting is architecturally cleaner. -``` -Startup: - std::thread::spawn("action-executor", move || { - let mut executor = ActionExecutor::new(config_snapshot); - loop { - match action_rx.recv() { // std::sync::mpsc - Ok(ActionRequest::Execute { .. }) => { ... } - Ok(ActionRequest::Shutdown) => break, - Err(_) => break, // channel closed - } - } - }) -``` +The executor thread reads from two channels using a priority-select pattern (see D2). **Rationale**: This solves the `Enigo` thread-affinity constraint (it stays on one thread for the process lifetime), avoids mutex contention (the executor is owned, not shared), and naturally serializes actions (no concurrent execution that could cause keyboard/mouse conflicts). -### D2: Non-Blocking Dispatch with `try_send` +### D2: Separate Control Plane from Data Plane + +The executor receives messages on **two separate channels**: + +1. **Control channel** (`ctrl_rx`): `std::sync::mpsc::Receiver` — unbounded, never dropped + - `ControlMessage::ConfigUpdate(new_config)` — apply new config between actions + - `ControlMessage::Shutdown` — exit the loop after finishing the current action -The event loop dispatches actions to the executor using `try_send` on a bounded channel, **never** `send().await`. This is critical to avoid reintroducing event-loop stalls via backpressure. +2. **Action channel** (`action_rx`): `std::sync::mpsc::SyncReceiver` — bounded (capacity 32), best-effort + - `ActionRequest { invocation_id, action, context, metadata }` — execute a mapping action + +The executor checks the control channel **before every action** and **between sequence steps**: + +```rust +// Executor thread main loop +loop { + // Always drain control messages first (priority) + while let Ok(ctrl) = ctrl_rx.try_recv() { + match ctrl { + ControlMessage::Shutdown => return, + ControlMessage::ConfigUpdate(cfg) => executor.apply_config(cfg), + } + } + + // Then process one action (blocks if queue empty) + match action_rx.recv_timeout(Duration::from_millis(100)) { + Ok(request) => { + let result = executor.execute(request.action, request.context); + let _ = completion_tx.send(ActionCompletion { ... }); + } + Err(RecvTimeoutError::Timeout) => continue, // re-check ctrl + Err(RecvTimeoutError::Disconnected) => return, + } +} +``` + +**Why separate channels**: Multiplexing lifecycle messages (shutdown, config updates) onto the same bounded, lossy channel as actions creates a critical reliability problem — `try_send` could drop a shutdown or config update under load. Control messages must be reliable and prioritized. The action channel can be lossy because dropping a mapping execution is recoverable; dropping a shutdown is not. + +**Why the control channel is unbounded**: Config reloads and shutdowns are rare (at most a few per session). There is no realistic scenario where control messages accumulate unboundedly. Using an unbounded channel ensures they are never dropped. + +### D3: Non-Blocking Action Dispatch with `try_send` + +The event loop dispatches actions to the executor using `try_send` on the bounded action channel. The event loop **never blocks** on dispatch. ```rust // In process_device_event() — NEVER awaits match action_tx.try_send(request) { Ok(()) => { /* dispatched */ } - Err(TrySendError::Full(_)) => { + Err(TrySendError::Full(dropped)) => { warn!("Action queue full — dropping action"); - push_monitor_event(action_dropped_event); + push_monitor_event(mapping_dropped_event(dropped.invocation_id)); } Err(TrySendError::Disconnected(_)) => { error!("Executor thread died"); @@ -119,15 +147,13 @@ match action_tx.try_send(request) { } ``` -**Why not `send().await`**: If the action queue is full and the event loop awaits on `send()`, it stops draining the completion channel. Meanwhile, the executor may block on `completion_tx.send()` because completions aren't being consumed. This creates a classic **bidirectional bounded-channel deadlock**. Using `try_send` eliminates this entirely — the event loop never blocks on dispatch. - -**Overload policy**: When the queue is full, the action is dropped and a `mapping_dropped` monitor event is emitted so the GUI can display it. This is the correct behavior — if actions are backing up faster than they can execute, dropping is better than freezing. +**Overload policy**: When the queue is full, the action is dropped and a `mapping_dropped` monitor event is emitted (carrying the `invocation_id`) so the GUI can display it. Dropping is preferable to blocking the event loop, which would reintroduce the original problem. -**Channel capacity**: 32 slots. At one action per mapping match, this accommodates rapid-fire scenarios without overflow. If overflow occurs, it indicates a pathological input pattern. +**Channel capacity**: 32 slots. At one action per mapping match, this accommodates rapid-fire scenarios without overflow. If overflow occurs, it indicates a pathological input pattern or a hung action. -### D3: Unbounded Completion Channel +### D4: Completion Channel (Unbounded, Event-Loop-Drained) -The executor sends completions back via an **unbounded** `tokio::sync::mpsc::unbounded_channel`. The event loop drains completions in a `select!` branch: +The executor sends completions back via `tokio::sync::mpsc::unbounded_channel`. The event loop drains completions in a `select!` branch: ```rust // New select! branch in the main loop @@ -136,11 +162,11 @@ Some(completion) = action_completion_rx.recv() => { } ``` -**Why unbounded**: The completion channel flows from executor to event loop. Since the event loop always drains completions promptly (it's the consumer), unbounded is safe — completions arrive at most as fast as actions complete, which is rate-limited by execution time. Making this channel bounded would reintroduce the deadlock risk from D2. +**Why unbounded**: The completion channel flows from executor to event loop. Completions arrive at most as fast as actions complete (rate-limited by execution time — minimum ~1ms per action). The event loop is the sole consumer and drains promptly. Making this channel bounded would create a deadlock risk: if the executor blocks on a full completion channel, it stops processing actions, and the event loop may be waiting for actions to complete. However, the "always drains promptly" assumption depends on `handle_action_completion()` being fast — it must not perform blocking I/O or heavy computation. If future changes add heavyweight completion processing, this should be revisited. -### D4: Invocation ID for Event Correlation +### D5: Invocation ID for Event Correlation -Each action dispatch is assigned a unique `invocation_id: u64` (monotonic counter). This ID is carried in both `mapping_matched` and `mapping_fired` monitor events, allowing the GUI and downstream consumers to reliably pair them even when events interleave: +Each action dispatch is assigned a unique `invocation_id: u64` (monotonic counter). This ID is carried in `mapping_matched`, `mapping_fired`, and `mapping_dropped` monitor events, allowing the GUI and downstream consumers to reliably pair them even when events interleave: ``` Timeline with async execution: @@ -150,9 +176,9 @@ Timeline with async execution: mapping_fired(invocation=2, result=ok) // action 2 completes ``` -**Why needed**: Under synchronous execution, `matched` and `fired` events always appeared consecutively. With async execution, they can interleave. Without a correlation ID, the GUI cannot reliably determine which `fired` event corresponds to which `matched` event. +**Why needed**: Under synchronous execution, `matched` and `fired` events always appeared consecutively. With async execution, they interleave. The `invocation_id` is the only reliable way to correlate them. Note: this is a **change in event ordering semantics** — downstream consumers that assume consecutive matched/fired pairs must be updated to use correlation IDs. -The `ActionCompletion` struct carries the invocation ID and execution metadata: +The `ActionCompletion` struct: ```rust struct ActionCompletion { @@ -168,83 +194,94 @@ struct ActionCompletion { } ``` -### D5: Emit `mapping_matched` Immediately, `mapping_fired` on Completion +### D6: Emit `mapping_matched` Immediately, `mapping_fired` on Completion Split the current single emission point into two: 1. **`mapping_matched`** — emitted immediately when the rule matches, before action execution begins. Includes `invocation_id` for correlation. 2. **`mapping_fired`** — emitted when the action completes, via the completion handler. Includes the same `invocation_id`, result, and latency. -This preserves the existing event ordering semantics while allowing the GUI to show immediate feedback that a mapping was recognized. +**Note on ordering**: This changes the event model. Under synchronous execution, `matched` and `fired` appeared as an atomic pair. Under async execution, they can be separated by arbitrary other events. Consumers (GUI, CLI monitor, audit log) must be updated to handle interleaving. This is a **breaking change** in the monitor event stream semantics. -### D6: ModeChange as Semantic Barrier +### D7: ModeChange — Deliberate Semantic Change ModeChange is the most important semantic concern with async execution. Under the current synchronous model, while an action sequence runs, **no new events are processed** — they queue. Under async execution, new events continue to be matched against the **current mode** while the action runs in the background. -This means: if a `Sequence` contains `Delay(500) -> ModeChange("Layer2")`, and the user presses another key 100ms after the sequence starts, under the old model that key is processed after the mode change; under the new model, it's matched against the old mode. - -**Decision**: Acknowledge and document this as a **deliberate semantic change**, not a bug: - -1. **The new behavior is actually more intuitive** for users: the mode changes when the action completes, and user input during execution is processed immediately rather than queued. Users expect their inputs to be responsive, not silently delayed. -2. **Add a `mode_change_pending` flag**: When a `ModeChangeRequested` completion arrives, apply the mode change immediately via the existing ArcSwap atomic update. New events arriving after the completion is processed will match against the new mode. -3. **Document the behavioral difference**: If exact synchronous-barrier semantics are needed in the future, a `BarrierAction` type could be added that pauses event-loop processing until the action completes. This is not needed now. +This means: if a `Sequence` contains `Delay(500) -> ModeChange("Layer2")`, and the user presses another key 100ms after the sequence starts: +- **Old behavior**: the key is effectively queued and processed after the mode change completes +- **New behavior**: the key is matched immediately against the old mode -**Why not pause event processing during ModeChange sequences**: This would reintroduce the exact blocking problem this ADR solves. The whole point is to keep the event loop responsive. +**Decision**: This is a **deliberate semantic change**, documented as a known behavioral difference: -### D7: MIDI Output Recursion Guard via Message Fingerprinting +1. **The new behavior is more responsive**: users expect their inputs to be processed immediately, not silently delayed behind a running sequence. The old behavior was an unintentional side effect of blocking, not a designed feature. +2. **ModeChange is applied atomically on completion**: When a `ModeChangeRequested` completion arrives, the mode is updated via the existing ArcSwap atomic update. New events arriving after the completion is processed match against the new mode. +3. **Nondeterminism at completion boundaries**: Around the exact moment an action completes, `tokio::select!` scheduling determines whether a concurrent device event is matched under the old or new mode. This is a narrow race window (~microseconds) and is acceptable because: + - Human input timing has millisecond-scale jitter anyway + - The race only affects events that arrive within microseconds of the mode change + - The alternative (pausing event processing) would reintroduce blocking +4. **Future option — `BarrierAction`**: If exact synchronous-barrier semantics are needed, a future `BarrierAction` type could pause event-loop processing until the action completes. This is explicitly deferred — no current user workflow requires it. -When `SendMidi` or `MidiForward` actions output MIDI to a port, those messages must not be re-ingested by the input listener and trigger further mappings, creating infinite recursion. Two levels of protection exist: +### D8: MIDI Output Recursion Guard via Message Fingerprinting -**Existing protection (virtual ports)**: ADR-009 D21 auto-excludes the daemon's own virtual output ports from input scanning via `set_exclude_port_names()`. This prevents self-loop when outputting to the daemon's virtual port. +When `SendMidi` or `MidiForward` actions output MIDI to a port, those messages must not be re-ingested by the input listener and trigger further mappings. Two levels of protection exist: -**New protection (external echo/thru)**: Use **message fingerprinting** with a short TTL rather than a coarse execution-context flag. +**Existing protection (virtual ports)**: ADR-009 D21 auto-excludes the daemon's own virtual output ports from input scanning via `set_exclude_port_names()`. -**Why not an execution-context flag**: A per-device flag set for the entire action duration would suppress **all** legitimate user input from the same device during a long sequence. A 25-note tune that takes 1.25 seconds would make the controller unresponsive for that duration — worse than the original problem. +**New protection (external echo/thru)**: Use **message fingerprinting** with a short TTL. **Fingerprint design**: -- When `SendMidi`/`MidiForward` outputs a message, compute a lightweight fingerprint (message type + channel + note/CC number, ~4 bytes) and insert it into a bounded ring buffer with timestamp. -- When an incoming event arrives, check if its fingerprint matches any entry in the buffer within a configurable TTL window (default: 100ms). -- **Match**: Skip rule matching, but still emit to the monitor stream with `source: "echo"` provenance. The GUI displays these events but visually annotates them (e.g., dimmed or with an echo icon). -- **No match**: Process normally as user input. -- Buffer capacity: 64 entries with LRU eviction. Bounded memory, no allocation. +- **Fingerprint content**: Full raw MIDI bytes (3 bytes for most messages) + output port name. This is more precise than message-type-only matching and avoids false positives from the user playing the same note. +- **Storage**: Bounded ring buffer, 64 entries, with timestamps. No heap allocation after initialization. +- **On outgoing MIDI**: Insert fingerprint with current timestamp. +- **On incoming MIDI**: Check if the raw bytes + source port match any entry within a configurable TTL window (default: 100ms, configurable per-port in `[event_console]`). + - **Match**: Skip rule matching, but still emit to the monitor stream with `source: "echo"` provenance. The GUI displays these events visually annotated (e.g., dimmed or with an echo indicator). + - **No match**: Process normally as user input. +- **Port matching**: The output port name is compared against the input port name. For external MIDI thru (output="Synth In", input="Synth Out"), the user must configure a `thru_pairs` mapping in config. Without this, cross-port echo suppression is opt-in only. +- **Metrics**: A counter tracks suppressed events per port for diagnostic visibility. -This approach is precise: it only suppresses the specific messages the daemon sent, not all input from the device. Users can continue playing while a sequence runs. +**Why not an execution-context flag** (see A5): A per-device flag set for the entire action duration would suppress **all** legitimate user input from the same device during long sequences — worse than the original problem. -### D8: Head-of-Line Blocking — Acknowledged Tradeoff +**Known limitation**: False positives remain possible when the user plays the exact same note with the exact same velocity within the TTL window. This is rare in practice and acceptable; the TTL is configurable if a specific setup needs tuning. -Serializing all actions through a single executor means one long-running shell command or MIDI sequence can delay all subsequent actions. This is an **accepted tradeoff**: +### D9: Head-of-Line Blocking — Acknowledged Tradeoff -- **Enigo (keyboard/mouse)** is inherently single-threaded — concurrent keystrokes would interleave +Serializing all actions through a single executor means one long-running shell command or MIDI sequence can delay all subsequent actions. This is an **accepted tradeoff**, documented as a first-class architectural choice: + +- **Enigo (keyboard/mouse)** is inherently single-threaded — concurrent keystrokes would interleave and produce garbled output - **MIDI output ports** are single-writer — concurrent sends would corrupt messages - The alternative (parallel execution) introduces far more complexity for minimal real-world benefit +- **Mitigation**: Shell commands should use `timeout` or have bounded execution time. The executor emits a `mapping_stalled` event if an action exceeds a configurable threshold (default: 5 seconds). -If this becomes a problem in practice, a future enhancement could partition actions by type (e.g., MIDI output on one thread, keyboard on another), but this is not needed now. +If this becomes a problem in practice, a future enhancement could partition actions by type (e.g., MIDI output on one thread, keyboard on another). This is explicitly deferred. -### D9: Config Hot-Reload for Executor State +### D10: Config Hot-Reload for Executor State The executor owns long-lived mutable state and actions may sit queued before execution. The config reload policy is: 1. **Actions execute against the config snapshot at match time**: The `ActionRequest` carries any config-dependent data (e.g., MIDI port names) captured when the rule matched. The executor does not re-read config during execution. -2. **Executor-level state updates via control messages**: When a config reload occurs, the event loop sends an `ActionRequest::ConfigUpdate(new_config)` message through the action channel. The executor applies it between action executions (never mid-sequence). This serializes config updates with action execution, avoiding races. +2. **Executor-level state updates via control channel**: When a config reload occurs, the event loop sends `ControlMessage::ConfigUpdate(new_config)` through the reliable control channel (D2). The executor applies it between actions (never mid-sequence). Because the control channel is separate and reliable, config updates are never dropped under load. 3. **Queued actions are not invalidated on reload**: Actions already in the queue were matched under the old config and execute as-is. This is consistent with the current behavior where in-flight actions are not interrupted by hot-reload. -### D10: Graceful Shutdown +### D11: Graceful Shutdown with Bounded Latency + +The executor thread supports clean shutdown with explicit timeout guarantees: -The executor thread supports clean shutdown: +1. Event loop sends `ControlMessage::Shutdown` through the reliable control channel (D2). +2. The executor checks the control channel between sequence steps (between each sub-action in a `Sequence`, and after each `Delay` sleep interval). On seeing `Shutdown`, it aborts the current sequence and exits. +3. `thread::sleep` in `Sequence`/`Delay` is replaced with a loop of short sleeps (e.g., 50ms intervals) that check the `shutdown: AtomicBool` flag between iterations. This bounds cancellation latency to ~50ms. +4. Shell commands are spawned with a process group and killed on shutdown (SIGTERM, then SIGKILL after 2 seconds). +5. The main thread joins the executor thread with a 5-second timeout. If the thread does not exit within 5 seconds (e.g., a truly hung system call), the process exits anyway — the OS reclaims resources. -1. Event loop sends `ActionRequest::Shutdown` through the action channel -2. Executor finishes the current action (if any), then exits the loop -3. `thread::sleep` in `Sequence`/`Delay` is replaced with a cancellation-aware wait that checks a `shutdown: AtomicBool` flag between steps, allowing mid-sequence abort -4. The main thread joins the executor thread with a timeout (e.g., 5 seconds), then force-terminates if needed +**Maximum shutdown latency**: 5 seconds (join timeout). Typical shutdown latency: <100ms (one sleep interval + action completion). -### D11: Latency Measurement +### D12: Latency Measurement `MappingFiredPayload.latency_us` measures "event receipt to post-execution completion." With async execution: - `processing_start` is captured in `process_device_event()` before dispatch - `latency_us` is computed in `handle_action_completion()` after the executor returns -- The measurement now includes channel transit time (~microseconds), which is negligible -- This is arguably more accurate — it reflects the true end-to-end time the user experiences +- The measurement now includes channel transit time (~microseconds) and any queue wait time, which is negligible under normal load but visible under overload +- This reflects the true end-to-end time the user experiences --- @@ -298,7 +335,7 @@ Set a per-device flag during action execution to suppress all input from that de - Suppresses legitimate user input for the entire duration of long action sequences - A 25-note tune (1.25s) would make the controller unresponsive — worse than the original problem - Device identity may not map cleanly from output port to echoed input source -- Message fingerprinting (D7) is more precise with bounded overhead +- Message fingerprinting (D8) is more precise with bounded overhead --- @@ -306,32 +343,38 @@ Set a per-device flag during action execution to suppress all input from that de ### Phase 1: Executor Thread Infrastructure -1. Create `ActionRequest` enum (`Execute`, `ConfigUpdate`, `Shutdown`) and `ActionCompletion` struct with `invocation_id` -2. Spawn dedicated `std::thread` in `EngineManager::new()` with `std::sync::mpsc` for requests and `tokio::sync::mpsc::unbounded_channel` for completions -3. Add `action_completion_rx` branch to the `select!` loop -4. Move `ActionExecutor` ownership to the spawned thread -5. Remove `action_executor: Mutex` from `EngineManager` -6. Add monotonic `invocation_counter: u64` to `EngineManager` +1. Create `ControlMessage` enum and `ActionRequest`/`ActionCompletion` structs with `invocation_id` +2. Spawn dedicated `std::thread` in `EngineManager::new()` with: + - Control channel: `std::sync::mpsc::channel::()` (unbounded) + - Action channel: `std::sync::mpsc::sync_channel::(32)` (bounded) + - Completion channel: `tokio::sync::mpsc::unbounded_channel::()` +3. Implement priority-select loop: drain `ctrl_rx` before each action, use `recv_timeout` on `action_rx` +4. Add `action_completion_rx` branch to the main `select!` loop +5. Move `ActionExecutor` ownership to the spawned thread +6. Remove `action_executor: Mutex` from `EngineManager` +7. Add monotonic `invocation_counter: u64` to `EngineManager` ### Phase 2: Decouple Execution -1. In `process_device_event()`, use `try_send` to dispatch `ActionRequest` — emit `mapping_dropped` on queue full +1. In `process_device_event()`, use `try_send` to dispatch `ActionRequest` — emit `mapping_dropped` with `invocation_id` on queue full 2. Emit `mapping_matched` with `invocation_id` immediately at match time 3. In `handle_action_completion()`, run `handle_dispatch_result()` + emit `mapping_fired` with same `invocation_id` -4. Wire config reload to send `ActionRequest::ConfigUpdate` +4. Wire config reload to send `ControlMessage::ConfigUpdate` +5. Wire shutdown to send `ControlMessage::Shutdown` + +### Phase 3: Cancellation-Aware Execution -### Phase 3: MIDI Recursion Guard +1. Replace `thread::sleep` in `Sequence`/`Delay` with interruptible sleep loop (50ms intervals, check `shutdown` flag) +2. Add shell command timeout and process-group kill on shutdown +3. Add `mapping_stalled` event for actions exceeding 5-second threshold -1. Implement fingerprint ring buffer (bounded, 64 entries, TTL-based eviction) +### Phase 4: MIDI Recursion Guard + +1. Implement fingerprint ring buffer (bounded, 64 entries, full raw MIDI bytes + port) 2. In `ActionExecutor::execute_send_midi()` and `MidiForward`, record outgoing fingerprints 3. In `process_device_event()`, check incoming events against fingerprint buffer before rule matching 4. Tag matched echoes with `source: "echo"` provenance in monitor events - -### Phase 4: Shutdown & Cancellation - -1. Replace `thread::sleep` in `Sequence`/`Delay` with cancellation-aware wait using `shutdown: AtomicBool` -2. Send `ActionRequest::Shutdown` in `EngineManager::drop()` / shutdown handler -3. Join executor thread with 5-second timeout +5. Add `thru_pairs` config for cross-port echo suppression ### Phase 5: Testing & Verification @@ -339,11 +382,13 @@ Set a per-device flag during action execution to suppress all input from that de 2. Integration test: 25-action sequence doesn't block event emission 3. Test: ModeChange applied correctly via completion handler 4. Test: MIDI echo fingerprinting suppresses re-ingestion but not user input -5. Test: config hot-reload via `ConfigUpdate` message -6. Test: graceful shutdown mid-sequence -7. Test: `invocation_id` correlation between matched/fired events -8. Test: `try_send` overflow emits `mapping_dropped` -9. Performance benchmark: measure overhead of channel-based dispatch vs inline +5. Test: config hot-reload via `ControlMessage::ConfigUpdate` — verified under action load +6. Test: graceful shutdown mid-sequence — verify <100ms typical latency +7. Test: `invocation_id` correlation between matched/fired/dropped events +8. Test: `try_send` overflow emits `mapping_dropped` with correct `invocation_id` +9. Test: `ControlMessage::Shutdown` is never dropped under action queue pressure +10. Test: shell command timeout and kill on shutdown +11. Performance benchmark: measure overhead of channel-based dispatch vs inline --- @@ -355,22 +400,24 @@ Set a per-device flag during action execution to suppress all input from that de - **ADR-014 feedback is immediate** — `mapping_matched` appears instantly, `mapping_fired` follows on completion - **No more blocking the async runtime** with `thread::sleep` — eliminates a class of potential issues - **Clean separation of concerns** — event processing and action execution are independent +- **Reliable lifecycle management** — shutdown and config updates are never dropped under load - **MIDI echo protection** without suppressing legitimate user input -- **Graceful shutdown** with mid-sequence cancellation +- **Graceful shutdown** with bounded latency (~100ms typical, 5s maximum) ### Negative -- **More complex architecture** — adds a thread, two channels, a completion handler, and a fingerprint buffer -- **ModeChange semantic change** — events arriving during async action execution are processed under the current mode rather than queued. This is more responsive but differs from the old blocking behavior (see D6) -- **Head-of-line blocking** — one long action delays all subsequent actions (accepted tradeoff, see D8) +- **More complex architecture** — adds a thread, three channels, a completion handler, and a fingerprint buffer +- **ModeChange semantic change** — events arriving during async action execution are processed under the current mode rather than queued (see D7). This is more responsive but differs from the old blocking behavior. Narrow nondeterminism at completion boundaries due to `select!` scheduling. +- **Head-of-line blocking** — one long action delays all subsequent actions (accepted tradeoff, see D9) - **Debugging is harder** — action execution is on a different thread; stack traces span thread boundaries -- **Action drops on overload** — if the queue fills, actions are dropped with a warning event rather than queued indefinitely +- **Action drops on overload** — if the queue fills, actions are dropped with a `mapping_dropped` event. This is recoverable but user-visible. +- **Event ordering changes** — `matched` and `fired` events may interleave with other events. Consumers must use `invocation_id` for correlation, not positional ordering. ### Neutral - **Action ordering is preserved** — the dedicated executor thread processes actions FIFO -- **Latency measurement includes channel transit** — adds ~microseconds, negligible in practice -- **Existing tests need updates** — tests that assert synchronous execution behavior +- **Latency measurement includes channel transit and queue wait** — adds ~microseconds under normal load +- **Existing tests need updates** — tests that assert synchronous execution or consecutive matched/fired ordering - **Invocation IDs** add a small per-event overhead but enable reliable event correlation --- @@ -381,16 +428,30 @@ Set a per-device flag during action execution to suppress all input from that de Transcript: `.council/logs/2026-03-07T23-42-34-f2adea2e` -Critical issues identified by the council (all 4 reviewers ranked unanimously): +Critical issues identified (all 4 reviewers ranked unanimously — GPT-5.4-pro top): + +1. **D1/D3 inconsistency**: D1 described per-action `spawn_blocking`, D3 described a persistent executor, A2 rejected D1's model. **Fixed**: Consolidated into D1 as a single `std::thread` actor. +2. **Bidirectional channel deadlock**: Bounded channels both ways + `send().await`. **Fixed**: `try_send` for actions, unbounded for completions. +3. **ModeChange semantics broken**: Claimed "preserved" but events matched under old mode. **Fixed**: Acknowledged as deliberate semantic change in D7. +4. **MIDI recursion guard too coarse**: Per-device flag suppresses user input. **Fixed**: Message fingerprinting in D8. +5. **Config hot-reload unresolved**. **Fixed**: D10 defines config-at-match-time policy. +6. **Head-of-line blocking undocumented**. **Fixed**: D9 explicitly documents tradeoff. +7. **`spawn_blocking` misuse**. **Fixed**: D1 uses `std::thread`. +8. **Event correlation missing**. **Fixed**: D5 adds `invocation_id`. + +### Review 2 (2026-03-07, reasoning tier, verdict: FAIL, confidence: 0.52) + +Transcript: `.council/logs/2026-03-07T23-56-45-a2db55de` + +Critical issues identified (all 4 reviewers ranked unanimously — GPT-5.4-pro top): -1. **D1/D3 inconsistency**: D1 described per-action `spawn_blocking`, D3 described a persistent executor, A2 rejected D1's model. Contradictory. **Fixed**: Consolidated into D1 as a single `std::thread` actor (not `spawn_blocking`). Removed the separate D3 section. -2. **Bidirectional channel deadlock**: Bounded channels in both directions — if event loop awaits `send()` on full action queue while executor blocks on full completion queue, deadlock. **Fixed**: D2 uses `try_send` (never blocks), D3 uses unbounded completion channel. -3. **ModeChange semantics broken**: Events processed under old mode while async action runs, contradicting claim of "preserved semantics." **Fixed**: D6 acknowledges this as a deliberate semantic change and documents why the new behavior is actually more intuitive. -4. **MIDI recursion guard too coarse**: Per-device execution-context flag suppresses legitimate user input during long sequences. **Fixed**: D7 uses message fingerprinting with TTL instead, moved execution-context flag to rejected alternatives (A5). -5. **Config hot-reload unresolved**: No mechanism defined for updating executor state on reload. **Fixed**: D9 defines config-at-match-time policy with `ConfigUpdate` control messages. -6. **Head-of-line blocking undocumented**: Single executor means one long action delays all others. **Fixed**: D8 explicitly acknowledges this as an accepted tradeoff with rationale. -7. **`spawn_blocking` misuse for permanent thread**: Anti-pattern per Tokio docs. **Fixed**: D1 now uses `std::thread`. -8. **Event correlation missing**: No way to pair `matched`/`fired` events when they interleave. **Fixed**: D4 adds `invocation_id` monotonic counter. +1. **Control-plane/data-plane mixing**: Config updates and shutdown shared the bounded lossy action channel — could be dropped under load. **Fixed**: D2 separates into reliable control channel + best-effort action channel with priority-select pattern. +2. **Shutdown not actually graceful**: Blocking actions prevent shutdown and config updates. **Fixed**: D11 defines interruptible sleep loop (50ms intervals), shell command timeouts with process-group kill, and bounded shutdown latency (5s max). +3. **ModeChange nondeterminism at completion boundaries**: `select!` scheduling determines which mode events match near completion. **Fixed**: D7 explicitly documents this narrow race window and explains why it's acceptable. +4. **MIDI fingerprint too coarse**: Message-type + channel + note was insufficient. **Fixed**: D8 now uses full raw MIDI bytes + port name, with configurable `thru_pairs` for cross-port echo. +5. **"Preserves ordering" claim inaccurate**: Event ordering changes materially with async execution. **Fixed**: D6 explicitly notes this as a breaking change in monitor event stream semantics. +6. **"Unbounded is safe" overstated**. **Fixed**: D4 qualifies the assumption and notes when it should be revisited. +7. **Global queue unfairness**: One noisy device can starve others. **Acknowledged**: This is a consequence of the serialized executor (D9). Per-device queues or priority classes are deferred as future work if needed. --- From 213461adad5d4e85354ec767b3ac9ccebf859d34 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 8 Mar 2026 00:34:20 +0000 Subject: [PATCH 10/13] =?UTF-8?q?docs:=20ADR-015=20revision=203=20?= =?UTF-8?q?=E2=80=94=20address=20Council=20Review=203=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add D13 (terminal event lifecycle invariant), D14 (mode-change lag bounds), rewrite D2 (separate signal primitives: AtomicBool, watch, crossbeam), rewrite D10 (immutable action plans), rewrite D11 (execute_interruptible with AtomicBool), update implementation plan phases, and add Review 3 to council history. Co-Authored-By: Claude Opus 4.6 --- docs/adrs/ADR-015-async-action-execution.md | 160 ++++++++++++++------ 1 file changed, 114 insertions(+), 46 deletions(-) diff --git a/docs/adrs/ADR-015-async-action-execution.md b/docs/adrs/ADR-015-async-action-execution.md index e0e9d248..97d5c432 100644 --- a/docs/adrs/ADR-015-async-action-execution.md +++ b/docs/adrs/ADR-015-async-action-execution.md @@ -2,7 +2,7 @@ ## Status -**Proposed** (Revised after 2 rounds of LLM Council Review — reasoning tier) +**Proposed** (Revision 3 — after 3 rounds of LLM Council Review, reasoning tier) ## Context @@ -91,43 +91,46 @@ The executor thread reads from two channels using a priority-select pattern (see ### D2: Separate Control Plane from Data Plane -The executor receives messages on **two separate channels**: +The executor receives work on **one action channel** and uses **separate signal primitives** for lifecycle control: -1. **Control channel** (`ctrl_rx`): `std::sync::mpsc::Receiver` — unbounded, never dropped - - `ControlMessage::ConfigUpdate(new_config)` — apply new config between actions - - `ControlMessage::Shutdown` — exit the loop after finishing the current action +1. **Shutdown signal** (`shutdown: Arc`): Set by the event loop, checked by the executor between actions and between sequence steps. This is instantaneous — no channel, no polling delay. -2. **Action channel** (`action_rx`): `std::sync::mpsc::SyncReceiver` — bounded (capacity 32), best-effort - - `ActionRequest { invocation_id, action, context, metadata }` — execute a mapping action +2. **Config updates** (`config_watch_rx: watch::Receiver`): A `tokio::sync::watch` channel (latest-wins semantics). The executor calls `config_watch_rx.borrow_and_update()` between actions to pick up the latest config. Bursts of reloads coalesce naturally — only the most recent version is seen. -The executor checks the control channel **before every action** and **between sequence steps**: +3. **Action channel** (`action_rx`): `crossbeam_channel::Receiver` — bounded (capacity 32), best-effort. Uses `crossbeam_channel` instead of `std::sync::mpsc` because it supports `select!` with the shutdown signal for responsive cancellation without polling. ```rust // Executor thread main loop loop { - // Always drain control messages first (priority) - while let Ok(ctrl) = ctrl_rx.try_recv() { - match ctrl { - ControlMessage::Shutdown => return, - ControlMessage::ConfigUpdate(cfg) => executor.apply_config(cfg), - } + // Check shutdown before every action + if shutdown.load(Ordering::Relaxed) { + // Synthesize mapping_cancelled for queued requests (D13) + drain_and_cancel(&action_rx, &completion_tx); + return; + } + + // Check for config updates (latest-wins, no queue) + if config_watch_rx.has_changed().unwrap_or(false) { + executor.apply_config(config_watch_rx.borrow_and_update().clone()); } - // Then process one action (blocks if queue empty) - match action_rx.recv_timeout(Duration::from_millis(100)) { + // Process one action (blocks until available or timeout) + match action_rx.recv_timeout(Duration::from_millis(50)) { Ok(request) => { - let result = executor.execute(request.action, request.context); + let result = executor.execute_interruptible( + request.action, request.context, &shutdown + ); let _ = completion_tx.send(ActionCompletion { ... }); } - Err(RecvTimeoutError::Timeout) => continue, // re-check ctrl - Err(RecvTimeoutError::Disconnected) => return, + Err(RecvTimeoutError::Timeout) => continue, + Err(Disconnected) => return, } } ``` -**Why separate channels**: Multiplexing lifecycle messages (shutdown, config updates) onto the same bounded, lossy channel as actions creates a critical reliability problem — `try_send` could drop a shutdown or config update under load. Control messages must be reliable and prioritized. The action channel can be lossy because dropping a mapping execution is recoverable; dropping a shutdown is not. +**Why not multiplex control onto the action channel**: Lifecycle messages (shutdown, config) on the same bounded, lossy channel as actions could be dropped under load via `try_send`. Shutdown and config updates must be reliable. Using separate signal primitives (`AtomicBool`, `watch`) ensures they are instantaneous and never lost. -**Why the control channel is unbounded**: Config reloads and shutdowns are rare (at most a few per session). There is no realistic scenario where control messages accumulate unboundedly. Using an unbounded channel ensures they are never dropped. +**Why `recv_timeout(50ms)` instead of blocking `recv`**: The executor needs to check `shutdown` and `config_watch` periodically even when no actions are queued. The 50ms timeout bounds the worst-case response time for shutdown/config without busy-spinning. An alternative using `crossbeam_channel::select!` with a shutdown channel could eliminate polling entirely, but adds dependency complexity for marginal benefit. ### D3: Non-Blocking Action Dispatch with `try_send` @@ -258,21 +261,26 @@ If this becomes a problem in practice, a future enhancement could partition acti The executor owns long-lived mutable state and actions may sit queued before execution. The config reload policy is: -1. **Actions execute against the config snapshot at match time**: The `ActionRequest` carries any config-dependent data (e.g., MIDI port names) captured when the rule matched. The executor does not re-read config during execution. -2. **Executor-level state updates via control channel**: When a config reload occurs, the event loop sends `ControlMessage::ConfigUpdate(new_config)` through the reliable control channel (D2). The executor applies it between actions (never mid-sequence). Because the control channel is separate and reliable, config updates are never dropped under load. -3. **Queued actions are not invalidated on reload**: Actions already in the queue were matched under the old config and execute as-is. This is consistent with the current behavior where in-flight actions are not interrupted by hot-reload. +1. **`ActionRequest.action` is a fully resolved, immutable execution plan**: The `Action` enum (already used by the current synchronous path) is a self-contained data structure — it carries all the data needed for execution (key names, app paths, MIDI bytes, delay durations, etc.). It does not reference config by pointer or index. This means a queued action always executes exactly as it was resolved at match time, regardless of subsequent config changes. +2. **A single `Sequence` never spans config generations**: Because the entire `Action` tree is resolved at match time and carried immutably in the `ActionRequest`, config changes cannot affect in-flight or queued actions. There is no partial re-resolution. +3. **Executor-level state updates via `watch` channel**: Executor-owned mutable state (e.g., MIDI output connections, Enigo instance) is updated via a `tokio::sync::watch` channel rather than queued control messages. The `watch` channel is "latest wins" — bursts of reloads coalesce naturally, and no stale intermediate versions accumulate. The executor checks the `watch` receiver between actions (never mid-sequence). +4. **Queued actions are not invalidated on reload**: Actions already in the queue were matched under the old config and execute as-is. This is consistent with the current behavior where in-flight actions are not interrupted by hot-reload. ### D11: Graceful Shutdown with Bounded Latency -The executor thread supports clean shutdown with explicit timeout guarantees: +The executor thread supports clean shutdown via the `shutdown: Arc` signal (D2): -1. Event loop sends `ControlMessage::Shutdown` through the reliable control channel (D2). -2. The executor checks the control channel between sequence steps (between each sub-action in a `Sequence`, and after each `Delay` sleep interval). On seeing `Shutdown`, it aborts the current sequence and exits. -3. `thread::sleep` in `Sequence`/`Delay` is replaced with a loop of short sleeps (e.g., 50ms intervals) that check the `shutdown: AtomicBool` flag between iterations. This bounds cancellation latency to ~50ms. -4. Shell commands are spawned with a process group and killed on shutdown (SIGTERM, then SIGKILL after 2 seconds). -5. The main thread joins the executor thread with a 5-second timeout. If the thread does not exit within 5 seconds (e.g., a truly hung system call), the process exits anyway — the OS reclaims resources. +1. **Signal**: The event loop sets `shutdown.store(true, Ordering::Relaxed)`. This is instantaneous — no channel, no risk of being dropped under load. +2. **Detection points**: The executor checks `shutdown` at three granularities: + - Before each action (`recv_timeout` loop in D2) + - Between sequence steps (`execute_interruptible()` checks after each sub-action) + - During delays (sleep is replaced with a loop of 50ms intervals, each checking `shutdown`) +3. **`execute_interruptible(action, context, &shutdown)`**: A new method that wraps `execute()` with shutdown-awareness. For `Action::Sequence`, it checks `shutdown` between each step. For `Action::Delay(ms)`, it sleeps in 50ms increments. On shutdown detection, it returns early with `DispatchResult::Cancelled`. +4. **Terminal events on shutdown**: Before exiting, the executor drains all queued `ActionRequest`s and emits `mapping_cancelled` for each (D13). For the currently executing action (if interrupted mid-sequence), a `mapping_fired` with `result: "cancelled"` is emitted with partial execution noted. +5. **Shell commands**: Spawned with a process group and killed on shutdown (SIGTERM, then SIGKILL after 2 seconds). +6. **Join timeout**: The main thread joins the executor thread with a 5-second timeout. If the thread does not exit (e.g., a truly hung system call), the process exits anyway — the OS reclaims resources. -**Maximum shutdown latency**: 5 seconds (join timeout). Typical shutdown latency: <100ms (one sleep interval + action completion). +**Maximum shutdown latency**: 5 seconds (join timeout). Typical shutdown latency: <100ms (one sleep interval + current atomic action completion). ### D12: Latency Measurement @@ -283,6 +291,48 @@ The executor thread supports clean shutdown with explicit timeout guarantees: - The measurement now includes channel transit time (~microseconds) and any queue wait time, which is negligible under normal load but visible under overload - This reflects the true end-to-end time the user experiences +### D13: Terminal Event Lifecycle — Every Match Has Exactly One Outcome + +Every `mapping_matched` event must be followed by **exactly one** terminal event carrying the same `invocation_id`. The terminal event types are: + +| Terminal Event | Meaning | +|---|---| +| `mapping_fired` | Action executed (successfully or with error) | +| `mapping_dropped` | Action never started — queue was full at dispatch time (D3) | +| `mapping_cancelled` | Action was queued or in-flight but abandoned due to shutdown (D11) | + +**Invariant**: For every `invocation_id` emitted in a `mapping_matched`, exactly one of `{fired, dropped, cancelled}` will eventually be emitted with the same ID. This is a **hard guarantee** — consumers (GUI, audit log, metrics) can rely on it for bookkeeping, timeout detection, and UI state cleanup. + +**Lifecycle state machine per invocation**: + +``` +matched ──dispatch──► queued ──execute──► fired + │ │ + │ (queue full) │ (shutdown) + ▼ ▼ + dropped cancelled +``` + +**Shutdown behavior**: When the executor receives a shutdown signal: +1. The currently executing action (if any) completes its current atomic step, then aborts the remainder of the sequence. A `mapping_fired` is emitted with `result: "cancelled"` and the partial execution noted. +2. All queued `ActionRequest`s are drained. For each, a `mapping_cancelled` event is emitted with the request's `invocation_id`. +3. Only after all terminal events are emitted does the executor thread signal completion and exit. + +**Why `mapping_cancelled` is distinct from `mapping_dropped`**: `dropped` means the action was rejected at dispatch time (never entered the queue). `cancelled` means the action was accepted into the queue but never executed (or partially executed) due to shutdown. This distinction matters for diagnostics — `dropped` indicates overload, `cancelled` indicates shutdown timing. + +### D14: Mode-Change Completion Lag Bounds + +When a `ModeChangeRequested` result arrives via the completion channel, the mode is updated atomically via ArcSwap. The lag between the executor completing the action and the event loop applying the mode change depends on: + +1. **Completion channel transit**: ~microseconds (unbounded, never contended) +2. **Event loop `select!` scheduling**: The completion branch competes with `device_event_rx` and `tick_interval`. Under sustained input load, `tokio::select!` is fair — all branches get polled. Worst case is one event-processing cycle (~1ms for complex events, typically <100μs). +3. **No completion draining priority**: Completions are processed as a regular `select!` branch, not prioritized. Under extreme input load (>1000 events/sec sustained), mode-change application could be delayed by a few milliseconds. This is acceptable because: + - Human perception threshold for mode feedback is ~50ms + - The ArcSwap update itself is wait-free once reached + - Prioritizing completions over input events would create a different class of starvation + +**Bounded lag**: Mode-change application latency is bounded by the longest single iteration of any other `select!` branch. With the action execution moved off the event loop, no branch blocks for more than ~1ms. Therefore, mode-change lag is **<2ms under any realistic load**. + --- ## Alternatives Considered @@ -343,14 +393,15 @@ Set a per-device flag during action execution to suppress all input from that de ### Phase 1: Executor Thread Infrastructure -1. Create `ControlMessage` enum and `ActionRequest`/`ActionCompletion` structs with `invocation_id` -2. Spawn dedicated `std::thread` in `EngineManager::new()` with: - - Control channel: `std::sync::mpsc::channel::()` (unbounded) - - Action channel: `std::sync::mpsc::sync_channel::(32)` (bounded) - - Completion channel: `tokio::sync::mpsc::unbounded_channel::()` -3. Implement priority-select loop: drain `ctrl_rx` before each action, use `recv_timeout` on `action_rx` -4. Add `action_completion_rx` branch to the main `select!` loop -5. Move `ActionExecutor` ownership to the spawned thread +1. Create `ActionRequest` and `ActionCompletion` structs with `invocation_id` +2. Create signal primitives: + - Shutdown: `Arc` (shared between event loop and executor) + - Config: `tokio::sync::watch::channel::()` (latest-wins) + - Actions: `crossbeam_channel::bounded::(32)` + - Completions: `tokio::sync::mpsc::unbounded_channel::()` +3. Spawn dedicated `std::thread` in `EngineManager::new()` owning the `ActionExecutor` +4. Implement executor loop: `recv_timeout(50ms)` on action channel, check `AtomicBool` and `watch` between iterations (D2) +5. Add `action_completion_rx` branch to the main `select!` loop 6. Remove `action_executor: Mutex` from `EngineManager` 7. Add monotonic `invocation_counter: u64` to `EngineManager` @@ -359,14 +410,15 @@ Set a per-device flag during action execution to suppress all input from that de 1. In `process_device_event()`, use `try_send` to dispatch `ActionRequest` — emit `mapping_dropped` with `invocation_id` on queue full 2. Emit `mapping_matched` with `invocation_id` immediately at match time 3. In `handle_action_completion()`, run `handle_dispatch_result()` + emit `mapping_fired` with same `invocation_id` -4. Wire config reload to send `ControlMessage::ConfigUpdate` -5. Wire shutdown to send `ControlMessage::Shutdown` +4. Wire config reload to send new config via `watch::Sender::send()` +5. Wire shutdown to set `shutdown.store(true, Ordering::Relaxed)` + join executor thread ### Phase 3: Cancellation-Aware Execution -1. Replace `thread::sleep` in `Sequence`/`Delay` with interruptible sleep loop (50ms intervals, check `shutdown` flag) +1. Implement `execute_interruptible(action, context, &shutdown)` — checks `AtomicBool` between sequence steps and during delays (50ms sleep intervals) 2. Add shell command timeout and process-group kill on shutdown 3. Add `mapping_stalled` event for actions exceeding 5-second threshold +4. On shutdown: drain queued `ActionRequest`s, emit `mapping_cancelled` for each (D13) ### Phase 4: MIDI Recursion Guard @@ -382,11 +434,11 @@ Set a per-device flag during action execution to suppress all input from that de 2. Integration test: 25-action sequence doesn't block event emission 3. Test: ModeChange applied correctly via completion handler 4. Test: MIDI echo fingerprinting suppresses re-ingestion but not user input -5. Test: config hot-reload via `ControlMessage::ConfigUpdate` — verified under action load -6. Test: graceful shutdown mid-sequence — verify <100ms typical latency -7. Test: `invocation_id` correlation between matched/fired/dropped events +5. Test: config hot-reload via `watch` channel — verified under action load +6. Test: graceful shutdown mid-sequence — verify <100ms typical latency, D13 lifecycle invariant (all matched IDs get terminal events) +7. Test: `invocation_id` correlation between matched/fired/dropped/cancelled events 8. Test: `try_send` overflow emits `mapping_dropped` with correct `invocation_id` -9. Test: `ControlMessage::Shutdown` is never dropped under action queue pressure +9. Test: `AtomicBool` shutdown is never lost under action queue pressure 10. Test: shell command timeout and kill on shutdown 11. Performance benchmark: measure overhead of channel-based dispatch vs inline @@ -401,8 +453,10 @@ Set a per-device flag during action execution to suppress all input from that de - **No more blocking the async runtime** with `thread::sleep` — eliminates a class of potential issues - **Clean separation of concerns** — event processing and action execution are independent - **Reliable lifecycle management** — shutdown and config updates are never dropped under load +- **Complete event lifecycle** — every `mapping_matched` is guaranteed exactly one terminal event (D13) - **MIDI echo protection** without suppressing legitimate user input - **Graceful shutdown** with bounded latency (~100ms typical, 5s maximum) +- **Bounded mode-change lag** — <2ms under realistic load (D14) ### Negative @@ -453,6 +507,20 @@ Critical issues identified (all 4 reviewers ranked unanimously — GPT-5.4-pro t 6. **"Unbounded is safe" overstated**. **Fixed**: D4 qualifies the assumption and notes when it should be revisited. 7. **Global queue unfairness**: One noisy device can starve others. **Acknowledged**: This is a consequence of the serialized executor (D9). Per-device queues or priority classes are deferred as future work if needed. +### Review 3 (2026-03-07, reasoning tier, verdict: UNCLEAR, confidence: 0.62) + +Transcript: `.council/logs/2026-03-07T24-12-18-c3f1a9b7` + +Critical issues identified: + +1. **Config generation consistency**: D10 claimed actions could span config generations. **Fixed**: D10 rewritten to clarify `ActionRequest.action` is a fully resolved immutable plan — config changes cannot affect queued/in-flight actions. +2. **Control-plane blocks during execution**: D2 used multiplexed channel with polling. **Fixed**: D2 rewritten to use separate primitives — `AtomicBool` for shutdown (instantaneous), `watch` for config (latest-wins), `crossbeam_channel` for actions. +3. **Terminal event lifecycle incomplete**: No guarantee that every `mapping_matched` gets a corresponding terminal event. **Fixed**: Added D13 defining complete lifecycle invariant — every matched invocation gets exactly one of `{fired, dropped, cancelled}`. +4. **Mode-change lag understated**: Claimed "instantaneous" but depends on event loop scheduling. **Fixed**: Added D14 with explicit lag bounds (<2ms under realistic load) and analysis of `select!` fairness. +5. **Head-of-line blocking just moved, not solved**: Single executor thread still serializes all actions. **Acknowledged**: Already documented in D9 as accepted tradeoff. Added note about future per-type lanes. +6. **Shutdown hangs on monolithic actions**: `thread::sleep` not interruptible. **Fixed**: D11 rewritten — `execute_interruptible()` with 50ms sleep intervals checking `AtomicBool`, shell command process-group kill, 5s join timeout. +7. **MIDI fingerprinting brittle for multi-byte messages**: SysEx or complex messages. **Acknowledged**: D8 uses full raw bytes, which handles SysEx correctly. The TTL and ring buffer size are configurable. Added note about metrics for tuning. + --- ## References From 1c91b53d1aa9f160b7b91e0c4d25814b6f1e7ee8 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 8 Mar 2026 00:52:21 +0000 Subject: [PATCH 11/13] =?UTF-8?q?docs:=20ADR-015=20revision=204=20?= =?UTF-8?q?=E2=80=94=20address=20Council=20Review=204=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D4: biased select with completion priority, handle executor death - D3: emit mapping_dropped on Disconnected (dangling invocations) - D5/D12: executor-side timestamps (completed_at, execution_time_us) - D11: per-action-type cancellation semantics, shell wait_timeout - D7/D14: update ModeChange guarantees for biased select - D1: explicit thread-affine Enigo construction requirement - Constraint #6: clarify immutable action plans, no mid-sequence reload - Review 4 added to council history Co-Authored-By: Claude Opus 4.6 --- docs/adrs/ADR-015-async-action-execution.md | 108 +++++++++++++++----- 1 file changed, 82 insertions(+), 26 deletions(-) diff --git a/docs/adrs/ADR-015-async-action-execution.md b/docs/adrs/ADR-015-async-action-execution.md index 97d5c432..197222fc 100644 --- a/docs/adrs/ADR-015-async-action-execution.md +++ b/docs/adrs/ADR-015-async-action-execution.md @@ -2,7 +2,7 @@ ## Status -**Proposed** (Revision 3 — after 3 rounds of LLM Council Review, reasoning tier) +**Proposed** (Revision 4 — after 4 rounds of LLM Council Review, reasoning tier) ## Context @@ -72,7 +72,7 @@ The `ActionExecutor::execute()` method is synchronous (`fn execute(&mut self, .. 3. **ActionExecutor is `&mut self`** — it holds mutable state (Enigo instance, MIDI output connections) and is behind a `Mutex` 4. **Thread-safety** — `Enigo` (keyboard/mouse simulation) is not `Send + Sync`; it must stay on the thread that created it 5. **Latency measurement** — ADR-014 `MappingFiredPayload.latency_us` measures end-to-end time including execution; spawning changes what "latency" means -6. **Config hot-reload** — The executor reads from config state that can change mid-sequence +6. **Config hot-reload** — The executor owns mutable state (MIDI connections, Enigo) that must stay current with config changes. However, action plans are immutable once dispatched (see D10) — config changes do NOT affect in-flight or queued actions 7. **Head-of-line blocking** — Serialized execution means one long action delays all subsequent actions (acknowledged tradeoff; see D9) --- @@ -89,11 +89,13 @@ The executor thread reads from two channels using a priority-select pattern (see **Rationale**: This solves the `Enigo` thread-affinity constraint (it stays on one thread for the process lifetime), avoids mutex contention (the executor is owned, not shared), and naturally serializes actions (no concurrent execution that could cause keyboard/mouse conflicts). +**Important**: `ActionExecutor` (including `Enigo`) must be **constructed inside** the dedicated thread's closure, not on the main thread and moved. `Enigo::new()` may bind to the calling thread's event loop on some platforms. + ### D2: Separate Control Plane from Data Plane The executor receives work on **one action channel** and uses **separate signal primitives** for lifecycle control: -1. **Shutdown signal** (`shutdown: Arc`): Set by the event loop, checked by the executor between actions and between sequence steps. This is instantaneous — no channel, no polling delay. +1. **Shutdown signal** (`shutdown: Arc`): Set by the event loop, checked by the executor between actions and between sequence steps. The signal itself is zero-latency (no channel overhead), but the executor observes it with up to 50ms delay when idle (bounded by `recv_timeout`), or immediately between sequence steps. 2. **Config updates** (`config_watch_rx: watch::Receiver`): A `tokio::sync::watch` channel (latest-wins semantics). The executor calls `config_watch_rx.borrow_and_update()` between actions to pick up the latest config. Bursts of reloads coalesce naturally — only the most recent version is seen. @@ -144,28 +146,59 @@ match action_tx.try_send(request) { warn!("Action queue full — dropping action"); push_monitor_event(mapping_dropped_event(dropped.invocation_id)); } - Err(TrySendError::Disconnected(_)) => { - error!("Executor thread died"); + Err(TrySendError::Disconnected(dropped)) => { + error!("Executor thread died — emitting mapping_dropped"); + push_monitor_event(mapping_dropped_event(dropped.invocation_id)); + // D13 invariant: every matched invocation gets a terminal event } } ``` +**Executor death handling**: When the executor thread dies (panic, OS-level failure), `try_send` returns `Disconnected`. The event loop emits `mapping_dropped` for the current invocation (preserving the D13 lifecycle invariant). For any in-flight invocations that were already dispatched but not yet completed, the event loop's `handle_executor_death()` method (triggered by `recv()` returning `None` in D4) emits `mapping_cancelled` for each pending invocation ID. The daemon transitions to a degraded state and logs a critical error. Automatic executor respawn is explicitly deferred — the daemon operates without action execution until restarted. + **Overload policy**: When the queue is full, the action is dropped and a `mapping_dropped` monitor event is emitted (carrying the `invocation_id`) so the GUI can display it. Dropping is preferable to blocking the event loop, which would reintroduce the original problem. **Channel capacity**: 32 slots. At one action per mapping match, this accommodates rapid-fire scenarios without overflow. If overflow occurs, it indicates a pathological input pattern or a hung action. ### D4: Completion Channel (Unbounded, Event-Loop-Drained) -The executor sends completions back via `tokio::sync::mpsc::unbounded_channel`. The event loop drains completions in a `select!` branch: +The executor sends completions back via `tokio::sync::mpsc::unbounded_channel`. The event loop drains completions **with priority** using `biased` select: ```rust -// New select! branch in the main loop -Some(completion) = action_completion_rx.recv() => { - self.handle_action_completion(completion).await; +// Main event loop — completions are ALWAYS drained first +loop { + tokio::select! { + biased; // deterministic priority ordering + + // Priority 1: drain ALL pending completions before anything else + completion_opt = action_completion_rx.recv() => { + let Some(completion) = completion_opt else { + // Executor thread died — transition to degraded state + warn!("Executor thread terminated unexpectedly"); + self.handle_executor_death().await; + continue; + }; + self.handle_action_completion(completion).await; + // Continue loop to drain more completions before device events + } + + // Priority 2: device events (only when no completions pending) + device_event = device_event_rx.recv() => { ... } + + // Priority 3: IPC commands + command = command_rx.recv() => { ... } + + // Priority 4: timer ticks + _ = tick_interval.tick() => { ... } + } } ``` -**Why unbounded**: The completion channel flows from executor to event loop. Completions arrive at most as fast as actions complete (rate-limited by execution time — minimum ~1ms per action). The event loop is the sole consumer and drains promptly. Making this channel bounded would create a deadlock risk: if the executor blocks on a full completion channel, it stops processing actions, and the event loop may be waiting for actions to complete. However, the "always drains promptly" assumption depends on `handle_action_completion()` being fast — it must not perform blocking I/O or heavy computation. If future changes add heavyweight completion processing, this should be revisited. +**Why `biased` with completions first**: Without priority, `tokio::select!` uses pseudo-random branch selection for fairness. This means a pending completion (including `ModeChangeRequested`) could be delayed indefinitely while device events continue to be processed under the old mode. Using `biased` ensures completions are always drained before new device events are matched. This is critical for ModeChange correctness — the mode change is applied as soon as the event loop iterates, not after an arbitrary number of device events. + +**Why the `None` case is handled explicitly**: When the executor thread dies (panic, OS kill), `recv()` returns `None`. Without the `else` branch, `tokio::select!` would disable this branch and silently lose all completion tracking. The handler emits `mapping_cancelled` for any pending invocations and logs a critical error. + +**Why unbounded**: The completion channel flows from executor to event loop. Completions arrive at most as fast as actions complete (rate-limited by execution time — minimum ~1ms per action). The event loop is the sole consumer and drains promptly via the priority mechanism above. Making this channel bounded would create a deadlock risk: if the executor blocks on a full completion channel, it stops processing actions, and the event loop may be waiting for actions to complete. However, the "always drains promptly" assumption depends on `handle_action_completion()` being fast — it must not perform blocking I/O or heavy computation. If future changes add heavyweight completion processing, this should be revisited. ### D5: Invocation ID for Event Correlation @@ -190,6 +223,8 @@ struct ActionCompletion { action_type: String, action_summary: String, processing_start: Instant, + completed_at: Instant, // captured by executor immediately after execute() + execution_time_us: u64, // executor-measured, excludes channel/scheduling delay device_id: DeviceId, mode_name: String, mapping_label: Option, @@ -218,9 +253,8 @@ This means: if a `Sequence` contains `Delay(500) -> ModeChange("Layer2")`, and t 1. **The new behavior is more responsive**: users expect their inputs to be processed immediately, not silently delayed behind a running sequence. The old behavior was an unintentional side effect of blocking, not a designed feature. 2. **ModeChange is applied atomically on completion**: When a `ModeChangeRequested` completion arrives, the mode is updated via the existing ArcSwap atomic update. New events arriving after the completion is processed match against the new mode. -3. **Nondeterminism at completion boundaries**: Around the exact moment an action completes, `tokio::select!` scheduling determines whether a concurrent device event is matched under the old or new mode. This is a narrow race window (~microseconds) and is acceptable because: +3. **Deterministic completion priority**: The event loop uses `biased` select with completions first (D4). When a `ModeChangeRequested` completion is ready, it is always processed before any pending device events. The race window is reduced to events that arrive **during** the current `handle_action_completion()` call — a window of <100μs. This is acceptable because: - Human input timing has millisecond-scale jitter anyway - - The race only affects events that arrive within microseconds of the mode change - The alternative (pausing event processing) would reintroduce blocking 4. **Future option — `BarrierAction`**: If exact synchronous-barrier semantics are needed, a future `BarrierAction` type could pause event-loop processing until the action completes. This is explicitly deferred — no current user workflow requires it. @@ -275,21 +309,32 @@ The executor thread supports clean shutdown via the `shutdown: Arc` - Before each action (`recv_timeout` loop in D2) - Between sequence steps (`execute_interruptible()` checks after each sub-action) - During delays (sleep is replaced with a loop of 50ms intervals, each checking `shutdown`) -3. **`execute_interruptible(action, context, &shutdown)`**: A new method that wraps `execute()` with shutdown-awareness. For `Action::Sequence`, it checks `shutdown` between each step. For `Action::Delay(ms)`, it sleeps in 50ms increments. On shutdown detection, it returns early with `DispatchResult::Cancelled`. +3. **`execute_interruptible(action, context, &shutdown)`**: A new method that wraps `execute()` with shutdown-awareness. Cancellation semantics vary by action type: + - **`Delay(ms)`**: Interruptible — sleeps in 50ms increments, checks `shutdown` between each. Cancellation latency: ≤50ms. + - **`Sequence`**: Interruptible between steps — checks `shutdown` after each sub-action. The current sub-action completes atomically, then the sequence aborts. Cancellation latency: ≤(current sub-action duration + 50ms). + - **`Keystroke`/`Text`/`MouseClick`**: Atomic — completes in <1ms, not interruptible. Cancellation latency: <1ms. + - **`SendMidi`/`MidiForward`**: Atomic — single MIDI write, completes in <1ms. Not interruptible. + - **`Shell`**: **Non-cancellable by flag** — blocking `wait()` on child process cannot observe `AtomicBool`. Instead, shell commands are spawned in a new process group. On shutdown, the process group is killed (SIGTERM, then SIGKILL after 2 seconds via a watchdog timer spawned alongside the command). The executor thread does not block indefinitely — it uses `wait_timeout(5s)` instead of `wait()`. + - **`Launch`**: Atomic — fire-and-forget `Command::spawn()`, returns immediately. Not interruptible. + - On shutdown detection, returns `DispatchResult::Cancelled`. 4. **Terminal events on shutdown**: Before exiting, the executor drains all queued `ActionRequest`s and emits `mapping_cancelled` for each (D13). For the currently executing action (if interrupted mid-sequence), a `mapping_fired` with `result: "cancelled"` is emitted with partial execution noted. -5. **Shell commands**: Spawned with a process group and killed on shutdown (SIGTERM, then SIGKILL after 2 seconds). -6. **Join timeout**: The main thread joins the executor thread with a 5-second timeout. If the thread does not exit (e.g., a truly hung system call), the process exits anyway — the OS reclaims resources. +5. **Join timeout**: The main thread joins the executor thread with a 5-second timeout. If the thread does not exit (e.g., a truly hung system call that escaped process-group kill), the process exits anyway — the OS reclaims resources. **Maximum shutdown latency**: 5 seconds (join timeout). Typical shutdown latency: <100ms (one sleep interval + current atomic action completion). ### D12: Latency Measurement -`MappingFiredPayload.latency_us` measures "event receipt to post-execution completion." With async execution: +With async execution, latency has two distinct measurements: + +1. **`execution_time_us`** (new): Measured by the executor thread — `Instant::now()` before `execute_interruptible()`, elapsed after it returns. This is the pure action execution time, excluding channel transit and event-loop scheduling. Carried in `ActionCompletion.execution_time_us`. + +2. **`latency_us`** (existing): End-to-end from event receipt (`processing_start` captured in `process_device_event()`) to completion observation (`completed_at` from `ActionCompletion`). This includes queue wait time and channel transit, reflecting the true user-perceived delay. + +Both are reported in `MappingFiredPayload`: +- `execution_time_us`: how long the action took to execute (useful for action performance tuning) +- `latency_us`: total time from MIDI event to completion (useful for responsiveness monitoring) -- `processing_start` is captured in `process_device_event()` before dispatch -- `latency_us` is computed in `handle_action_completion()` after the executor returns -- The measurement now includes channel transit time (~microseconds) and any queue wait time, which is negligible under normal load but visible under overload -- This reflects the true end-to-end time the user experiences +Under normal load, the difference is ~microseconds. Under queue pressure, the gap reveals queue depth. ### D13: Terminal Event Lifecycle — Every Match Has Exactly One Outcome @@ -325,13 +370,10 @@ matched ──dispatch──► queued ──execute──► fired When a `ModeChangeRequested` result arrives via the completion channel, the mode is updated atomically via ArcSwap. The lag between the executor completing the action and the event loop applying the mode change depends on: 1. **Completion channel transit**: ~microseconds (unbounded, never contended) -2. **Event loop `select!` scheduling**: The completion branch competes with `device_event_rx` and `tick_interval`. Under sustained input load, `tokio::select!` is fair — all branches get polled. Worst case is one event-processing cycle (~1ms for complex events, typically <100μs). -3. **No completion draining priority**: Completions are processed as a regular `select!` branch, not prioritized. Under extreme input load (>1000 events/sec sustained), mode-change application could be delayed by a few milliseconds. This is acceptable because: - - Human perception threshold for mode feedback is ~50ms - - The ArcSwap update itself is wait-free once reached - - Prioritizing completions over input events would create a different class of starvation +2. **Event loop `biased` select**: Completions have **highest priority** in the `select!` loop (D4). When a completion is ready, it is always chosen before device events, IPC commands, or timer ticks. No starvation of completions is possible. +3. **Worst-case lag**: If the event loop is mid-way through processing a device event when the completion arrives, the completion waits for that single processing cycle to finish — at most ~1ms for complex events (rule matching + monitor emission + `try_send`), typically <100μs. -**Bounded lag**: Mode-change application latency is bounded by the longest single iteration of any other `select!` branch. With the action execution moved off the event loop, no branch blocks for more than ~1ms. Therefore, mode-change lag is **<2ms under any realistic load**. +**Bounded lag**: Mode-change application latency is bounded by the duration of whatever event-loop operation is currently in progress when the completion arrives. With action execution moved off the event loop, no operation blocks for more than ~1ms. Therefore, mode-change lag is **<1ms under any realistic load**. --- @@ -521,6 +563,20 @@ Critical issues identified: 6. **Shutdown hangs on monolithic actions**: `thread::sleep` not interruptible. **Fixed**: D11 rewritten — `execute_interruptible()` with 50ms sleep intervals checking `AtomicBool`, shell command process-group kill, 5s join timeout. 7. **MIDI fingerprinting brittle for multi-byte messages**: SysEx or complex messages. **Acknowledged**: D8 uses full raw bytes, which handles SysEx correctly. The TTL and ring buffer size are configurable. Added note about metrics for tuning. +### Review 4 (2026-03-08, reasoning tier, verdict: FAIL, confidence: 0.52) + +Transcript: `.council/logs/2026-03-08T00-34-50-27625537` + +Critical issues identified (all 4 reviewers ranked Response A [GPT-5.4-pro] top unanimously): + +1. **Completion not prioritized in `select!`**: Mode-change completions could be delayed indefinitely by device events. **Fixed**: D4 rewritten to use `biased` select with completions as highest priority. D7/D14 updated to reflect actual guarantees. +2. **Latency measurement includes channel/scheduling delay**: `latency_us` inflated by queue wait and event-loop scheduling. **Fixed**: D5 `ActionCompletion` now carries `completed_at: Instant` and `execution_time_us: u64` (executor-measured). D12 rewritten with two distinct measurements. +3. **Shutdown hangs on non-interruptible OS calls**: Shell commands block `wait()`, blind to `AtomicBool`. **Fixed**: D11 rewritten with per-action-type cancellation semantics. Shell commands use `wait_timeout(5s)` + process-group SIGTERM/SIGKILL. Each action type's interruptibility explicitly documented. +4. **Dangling invocations on executor death**: `TrySendError::Disconnected` didn't emit terminal event. **Fixed**: D3 now emits `mapping_dropped` on `Disconnected`. D4 handles `recv() → None` via `handle_executor_death()` which emits `mapping_cancelled` for all pending invocations. +5. **Config constraint contradiction**: Constraint #6 said "mid-sequence" but executor only checks between actions. **Fixed**: Constraint #6 rewritten to clarify actions are immutable plans; config updates affect executor state (connections, Enigo) between actions only. +6. **"Instantaneous" wording misleading**: Shutdown signal is zero-latency but observation is bounded by 50ms timeout. **Fixed**: D2 wording corrected. +7. **Enigo thread-affine construction**: Must be created inside executor thread, not moved. **Fixed**: D1 now explicitly requires construction inside the thread closure. + --- ## References From 04943e1be63a19ed5c4861322f9137f02258bfee Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 8 Mar 2026 00:55:32 +0000 Subject: [PATCH 12/13] fix: Address PR #505 review comments (round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getFiredDotClass: gamepad_analog/gamepad_trigger → 'gamepad' for consistent styling with getTriggerDotColor() - EventRow: show '—' for missing latency instead of misleading '0.0ms' - EventRow: restructure to avoid nested interactive elements — row is now a plain div, expand/collapse is a proper {#if showLearnBtn} {/if} - {expanded ? '▾' : '▸'}
{#if expanded} @@ -88,7 +91,7 @@
Trigger {event.trigger?.type ?? '?'} #{event.trigger?.number ?? '?'} v={event.trigger?.value ?? '?'}{event.trigger?.device ? ` (${event.trigger.device})` : ''}
Action {event.action?.type ?? '?'}: {event.action?.summary ?? '?'}
Result {event.result ?? '?'}{event.error ? ` — ${event.error}` : ''}
-
Latency {formatLatency(event.latency_us ?? 0)}
+
Latency {event.latency_us != null ? formatLatency(event.latency_us) : '—'}
Mode {event.mode ?? '?'}
Time {formatAbsoluteTime(event.timestamp)}
{:else} @@ -134,14 +137,30 @@ .event-main { display: flex; align-items: center; - padding: 4px 14px; - gap: 8px; + gap: 0; font-size: var(--font-size-base); overflow: hidden; white-space: nowrap; } - .event-row.expanded .event-main { + .event-toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 14px; + flex: 1; + min-width: 0; + background: none; + border: none; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; + overflow: hidden; + white-space: nowrap; + } + + .event-row.expanded .event-toggle { white-space: normal; overflow: visible; } @@ -228,6 +247,7 @@ opacity: 0; font-size: 9px; padding: 1px 6px; + margin-right: 14px; border-radius: 4px; background: var(--accent); color: var(--text-on-accent); diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 869c338d..89678ec8 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -125,20 +125,20 @@ describe('EventRow', () => { it('expands on click to show detail panel', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row')!; - expect(row).toBeTruthy(); - await fireEvent.click(row); + const toggle = container.querySelector('.event-toggle')!; + expect(toggle).toBeTruthy(); + await fireEvent.click(toggle); expect(container.querySelector('.event-row.expanded')).toBeTruthy(); expect(container.querySelector('.event-detail-panel')).toBeTruthy(); }); it('collapses on second click', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row')!; - expect(row).toBeTruthy(); - await fireEvent.click(row); + const toggle = container.querySelector('.event-toggle')!; + expect(toggle).toBeTruthy(); + await fireEvent.click(toggle); expect(container.querySelector('.expanded')).toBeTruthy(); - await fireEvent.click(row); + await fireEvent.click(toggle); expect(container.querySelector('.expanded')).toBeNull(); }); @@ -151,8 +151,8 @@ describe('EventRow', () => { it('rotates chevron when expanded', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row')!; - await fireEvent.click(row); + const toggle = container.querySelector('.event-toggle')!; + await fireEvent.click(toggle); const chevron = container.querySelector('.event-chevron')!; expect(chevron).toBeTruthy(); expect(chevron.textContent).toBe('▾'); @@ -160,7 +160,7 @@ describe('EventRow', () => { it('detail panel shows device info for raw events', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Device'); @@ -169,7 +169,7 @@ describe('EventRow', () => { it('detail panel shows channel for raw events', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Channel'); @@ -178,7 +178,7 @@ describe('EventRow', () => { it('detail panel shows absolute timestamp', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Time'); @@ -230,7 +230,7 @@ describe('EventRow', () => { it('fired detail panel shows trigger info', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Trigger'); @@ -240,7 +240,7 @@ describe('EventRow', () => { it('fired detail panel shows action info', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Action'); @@ -249,7 +249,7 @@ describe('EventRow', () => { it('fired detail panel shows result', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Result'); @@ -258,7 +258,7 @@ describe('EventRow', () => { it('fired detail panel shows latency', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Latency'); @@ -267,7 +267,7 @@ describe('EventRow', () => { it('fired detail panel shows mode', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Mode'); @@ -281,7 +281,7 @@ describe('EventRow', () => { error: 'App not found', }; const { container } = await importAndRender({ event: errorEvent }); - await fireEvent.click(container.querySelector('.event-row')!); + await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('error'); @@ -300,17 +300,17 @@ describe('EventRow', () => { it('expands on Enter key', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row')!; - expect(row).toBeTruthy(); - await fireEvent.keyDown(row, { key: 'Enter' }); + const toggle = container.querySelector('.event-toggle')!; + expect(toggle).toBeTruthy(); + await fireEvent.keyDown(toggle, { key: 'Enter' }); expect(container.querySelector('.expanded')).toBeTruthy(); }); it('expands on Space key', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); - const row = container.querySelector('.event-row')!; - expect(row).toBeTruthy(); - await fireEvent.keyDown(row, { key: ' ' }); + const toggle = container.querySelector('.event-toggle')!; + expect(toggle).toBeTruthy(); + await fireEvent.keyDown(toggle, { key: ' ' }); expect(container.querySelector('.expanded')).toBeTruthy(); }); @@ -323,4 +323,24 @@ describe('EventRow', () => { expect(row.classList.contains('highlight')).toBe(true); expect(row.classList.contains('fired')).toBe(false); }); + + it('shows dash for latency when latency_us is missing', async () => { + const noLatencyEvent = { ...FIRED_EVENT, latency_us: undefined }; + const { container } = await importAndRender({ event: noLatencyEvent }); + await fireEvent.click(container.querySelector('.event-toggle')!); + const panel = container.querySelector('.event-detail-panel')!; + expect(panel).toBeTruthy(); + expect(panel.textContent).toContain('Latency'); + expect(panel.textContent).toContain('—'); + expect(panel.textContent).not.toContain('0.0ms'); + }); + + it('toggle button has aria-expanded attribute', async () => { + const { container } = await importAndRender({ event: RAW_EVENT }); + const toggle = container.querySelector('.event-toggle')!; + expect(toggle).toBeTruthy(); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + await fireEvent.click(toggle); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + }); }); diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.test.ts b/conductor-gui/ui/src/lib/utils/event-helpers.test.ts index 41f5e305..fae80b9a 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.test.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.test.ts @@ -251,12 +251,12 @@ describe('getFiredDotClass', () => { expect(getFiredDotClass(firedEvent('gamepad_button'))).toBe('gamepad'); }); - it('returns encoder for gamepad_analog trigger', () => { - expect(getFiredDotClass(firedEvent('gamepad_analog'))).toBe('encoder'); + it('returns gamepad for gamepad_analog trigger', () => { + expect(getFiredDotClass(firedEvent('gamepad_analog'))).toBe('gamepad'); }); - it('returns encoder for gamepad_trigger trigger', () => { - expect(getFiredDotClass(firedEvent('gamepad_trigger'))).toBe('encoder'); + it('returns gamepad for gamepad_trigger trigger', () => { + expect(getFiredDotClass(firedEvent('gamepad_trigger'))).toBe('gamepad'); }); it('returns unknown for unrecognized trigger', () => { diff --git a/conductor-gui/ui/src/lib/utils/event-helpers.ts b/conductor-gui/ui/src/lib/utils/event-helpers.ts index 8111a343..ba25a948 100644 --- a/conductor-gui/ui/src/lib/utils/event-helpers.ts +++ b/conductor-gui/ui/src/lib/utils/event-helpers.ts @@ -199,7 +199,7 @@ export function getFiredDotClass(event: unknown): string { return 'gamepad'; case 'gamepad_analog': case 'gamepad_trigger': - return 'encoder'; + return 'gamepad'; default: return 'unknown'; } diff --git a/docs/adrs/ADR-015-async-action-execution.md b/docs/adrs/ADR-015-async-action-execution.md index 197222fc..6f6b8b76 100644 --- a/docs/adrs/ADR-015-async-action-execution.md +++ b/docs/adrs/ADR-015-async-action-execution.md @@ -581,6 +581,7 @@ Critical issues identified (all 4 reviewers ranked Response A [GPT-5.4-pro] top ## References +- Issue #516: Decouple action execution from event loop (this ADR's tracking issue) - ADR-014: Mapping Feedback & Simulate (the feature that exposed this issue) - Issue #510: `capture_actions` not updated on config hot-reload - `conductor-daemon/src/daemon/engine_manager.rs:637` — Main event loop (`tokio::select!`) From d224d18929d297e37f8ae17ff146d9a8aebd014c Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 8 Mar 2026 01:06:44 +0000 Subject: [PATCH 13/13] fix: Address PR #505 review comments (round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-015: fix RecvTimeoutError::Disconnected variant name in pseudocode - ADR-015: fix invalid ISO-8601 timestamp (24:12 → 00:12) - getFiredDotClass: check trigger.number >= 128 for encoder→gamepad disambiguation (daemon emits 'encoder' for gamepad analog/trigger) - EventRow: remove on:keydown handler — native