-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(safety): Emergency Stop for desktop automation (#4255) #4600
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
M3gA-Mind
merged 43 commits into
tinyhumansai:main
from
CodeGhost21:feat/desktop-safety-4255
Jul 16, 2026
Merged
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
d0ae213
docs(safety): design spec for emergency stop (#4255 slice 1)
CodeGhost21 0507956
docs(safety): implementation plan for emergency stop (#4255 slice 1)
CodeGhost21 ed6f297
docs(safety): correct callCoreRpc call-shape + i18n paths in plan (#4…
CodeGhost21 46f2b06
feat(emergency): safetySlice tracks automation-halt state (#4255)
CodeGhost21 ecb6129
docs(safety): fix engine.disable(Option) signature in plan Task 5 (#4…
CodeGhost21 afe57c3
feat(emergency): emergencyApi RPC client (#4255)
CodeGhost21 eb2cc95
feat(events): add AutomationHalted/AutomationResumed domain events (#…
CodeGhost21 d03e5c2
chore(sdd): add task-1 report for AutomationHalted/AutomationResumed
CodeGhost21 6d20b1d
i18n(emergency): add safety.* keys across locales (#4255)
CodeGhost21 e30afea
docs(safety): fix Task 10 broadcast client_id=system in plan (#4255)
CodeGhost21 991dc6f
feat(emergency): stop button + halted banner + socket/boot wiring (#4…
CodeGhost21 1710194
feat(emergency): halt-state types + global switch singleton (#4255)
CodeGhost21 c7a78bf
feat(emergency): RPC controllers (stop/resume/status) + boot install …
CodeGhost21 9d1b970
feat(emergency): approval middleware refuses external-effect tools wh…
CodeGhost21 a375da8
feat(emergency): accessibility_input_action refuses input while halte…
CodeGhost21 995b837
feat(emergency): bridge halt/resume events to automation_halt socket …
CodeGhost21 e3f8ac1
test(emergency): json-rpc e2e for stop/status/resume (#4255)
CodeGhost21 66ae765
fix(emergency): friendly halt reason + socket/hydration tests + clean…
CodeGhost21 eba9402
fix(emergency): move stop button to top-right + hide while halted (#4…
CodeGhost21 1da757c
chore: drop leaked SDD scratch report from feature branch (#4255)
CodeGhost21 a5cbd50
style(emergency): apply prettier formatting (#4255)
CodeGhost21 cdecdee
style(emergency): prettier-format all changed frontend files (#4255)
CodeGhost21 18e2800
fix(emergency): address review — fail-closed socket, no false halt, a…
CodeGhost21 44ab153
fix(emergency): unblock CI + register bridge at boot + visible stop-f…
CodeGhost21 10011c7
Merge remote-tracking branch 'origin/main' into feat/desktop-safety-4255
CodeGhost21 4f38787
style: prettier-format ChatRuntimeProvider.test.tsx (main-introduced,…
CodeGhost21 6525e34
fix(emergency): use typed store/hooks + defensive selectors so App-sh…
CodeGhost21 27ad55c
Merge remote-tracking branch 'origin/main' into feat/desktop-safety-4255
CodeGhost21 a69f2ca
fix: repair 2 pre-existing main test breakages exposed by full-suite …
CodeGhost21 690d2c5
Merge remote-tracking branch 'origin/main' into feat/desktop-safety-4255
CodeGhost21 bef5d7c
Merge remote-tracking branch 'upstream/main' into pr/4600
senamakel 96e398e
Merge remote-tracking branch 'origin/main' into feat/desktop-safety-4…
CodeGhost21 59169cf
fix(emergency): address open review comments (blocker + majors)
CodeGhost21 8720fee
Merge upstream/main into feat/desktop-safety-4255
M3gA-Mind 0398e2a
fix(safety): reset stale resumeFailed across halt cycles + reason fal…
M3gA-Mind e62240f
Merge branch 'main' into pr/4600
M3gA-Mind 621b3cd
fix(safety): give the resume-failed status region an accessible name
M3gA-Mind e5ddf7c
Merge upstream/main into feat/desktop-safety-4255
M3gA-Mind bfd0c59
test(emergency-stop): panic-safe ClearEmergencyOnDrop in ops tests
M3gA-Mind cc56bf5
Merge remote-tracking branch 'upstream/main' into pr/4600
senamakel 51da322
Merge branch 'main' into pr/4600
M3gA-Mind e5bcf46
fix(i18n): remove em dashes from safety.* translations
M3gA-Mind e340a8b
fix(safety): re-check kill switch on cron retries + keep halt banner …
M3gA-Mind File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
app/src/components/safety/AutomationHaltedBanner.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { act, fireEvent, screen, waitFor } from '@testing-library/react'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { clearHalt, setHalt } from '../../store/safetySlice'; | ||
| import { renderWithProviders } from '../../test/test-utils'; | ||
| import { AutomationHaltedBanner } from './AutomationHaltedBanner'; | ||
|
|
||
| const resume = vi.fn().mockResolvedValue({ engaged: false }); | ||
| vi.mock('../../services/api/emergencyApi', () => ({ | ||
| emergencyResume: (...a: unknown[]) => resume(...a), | ||
| })); | ||
|
|
||
| beforeEach(() => resume.mockClear()); | ||
|
|
||
| describe('AutomationHaltedBanner', () => { | ||
| it('renders nothing when not halted', () => { | ||
| const { container } = renderWithProviders(<AutomationHaltedBanner />); | ||
| expect(container.firstChild).toBeNull(); | ||
| }); | ||
|
|
||
| it('renders the banner when halted', () => { | ||
| const { store } = renderWithProviders(<AutomationHaltedBanner />, { | ||
| preloadedState: { safety: { halted: true } }, | ||
| }); | ||
| expect(screen.getByRole('alert')).toBeDefined(); | ||
| expect(screen.getByRole('alert').getAttribute('data-analytics-id')).toBe( | ||
| 'automation-halted-banner' | ||
| ); | ||
| // safety state is engaged | ||
| expect((store.getState() as { safety: { halted: boolean } }).safety.halted).toBe(true); | ||
| }); | ||
|
|
||
| it('shows reason when available', () => { | ||
| renderWithProviders(<AutomationHaltedBanner />, { | ||
| preloadedState: { safety: { halted: true, reason: 'custom reason' } }, | ||
| }); | ||
| expect(screen.getByText('custom reason')).toBeDefined(); | ||
| }); | ||
|
|
||
| it('falls back to haltedBody when reason is absent', () => { | ||
| renderWithProviders(<AutomationHaltedBanner />, { | ||
| preloadedState: { safety: { halted: true } }, | ||
| }); | ||
| expect(screen.getByText(/desktop automation is stopped/i)).toBeDefined(); | ||
| }); | ||
|
|
||
| it('calls emergencyResume and clears halt when Resume is clicked', async () => { | ||
| const { store } = renderWithProviders(<AutomationHaltedBanner />, { | ||
| preloadedState: { safety: { halted: true, reason: 'test' } }, | ||
| }); | ||
| fireEvent.click(screen.getByRole('button', { name: /resume/i })); | ||
| await waitFor(() => expect(resume).toHaveBeenCalled()); | ||
| const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; | ||
| expect(safetyState.halted).toBe(false); | ||
| }); | ||
|
|
||
| it('preserves halt and surfaces a retry message when emergencyResume fails', async () => { | ||
| // Fail-closed: on RPC failure the core is still halted, so the UI must | ||
| // NOT silently clear the halt. Clearing locally would re-expose the Stop | ||
| // button while every external-effect action remained blocked, giving a | ||
| // false "resumed" signal (#4255 codex P2). | ||
| resume.mockRejectedValueOnce(new Error('core error')); | ||
| const { store } = renderWithProviders(<AutomationHaltedBanner />, { | ||
| preloadedState: { safety: { halted: true } }, | ||
| }); | ||
| fireEvent.click(screen.getByRole('button', { name: /resume/i })); | ||
| await waitFor(() => expect(resume).toHaveBeenCalled()); | ||
| // Halt state must remain engaged after the failed RPC. | ||
| const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; | ||
| expect(safetyState.halted).toBe(true); | ||
| // Visible retry indicator appears. | ||
| await waitFor(() => | ||
| expect(screen.getByRole('status', { name: /could not resume/i })).toBeDefined() | ||
| ); | ||
| // Banner is still there so the user retains a Resume button to try again. | ||
| expect(screen.getByRole('alert')).toBeDefined(); | ||
| }); | ||
|
|
||
| it('clears the stale retry indicator on a new halt cycle', async () => { | ||
| // Guards the cross-cycle leak: the banner is mounted permanently, so a failed | ||
| // resume in one cycle must not surface a stale "could not resume" indicator on | ||
| // a later, unrelated halt. Drive: fail a resume → clear the halt via the | ||
| // external socket path (not the successful-RPC branch) → start a fresh halt. | ||
| resume.mockRejectedValueOnce(new Error('core error')); | ||
| const { store } = renderWithProviders(<AutomationHaltedBanner />, { | ||
| preloadedState: { safety: { halted: true } }, | ||
| }); | ||
| fireEvent.click(screen.getByRole('button', { name: /resume/i })); | ||
| await waitFor(() => | ||
| expect(screen.getByRole('status', { name: /could not resume/i })).toBeDefined() | ||
| ); | ||
| // Halt lifts via the socket-driven clear (bypasses the successful resume path). | ||
| act(() => { | ||
| store.dispatch(clearHalt()); | ||
| }); | ||
| // A brand-new halt cycle begins. | ||
| act(() => { | ||
| store.dispatch(setHalt({ reason: 'second cycle', source: 'test' })); | ||
| }); | ||
| await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); | ||
| // The retry indicator from the previous cycle must not carry over. | ||
| expect(screen.queryByRole('status', { name: /could not resume/i })).toBeNull(); | ||
| }); | ||
|
|
||
| it('dispatches halt and then renders banner after setHalt dispatch', async () => { | ||
| const { store } = renderWithProviders(<AutomationHaltedBanner />); | ||
| // Initially not halted | ||
| expect((store.getState() as { safety: { halted: boolean } }).safety.halted).toBe(false); | ||
| // Dispatch halt and let React re-render | ||
| act(() => { | ||
| store.dispatch(setHalt({ reason: 'dispatched', source: 'test' })); | ||
| }); | ||
| // Banner should appear | ||
| await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { useCallback, useEffect, useState } from 'react'; | ||
|
|
||
| import { useT } from '../../lib/i18n/I18nContext'; | ||
| import { emergencyResume } from '../../services/api/emergencyApi'; | ||
| import { useAppDispatch, useAppSelector } from '../../store/hooks'; | ||
| import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySlice'; | ||
|
|
||
| /** | ||
| * AutomationHaltedBanner — renders at the top of main content when automation | ||
| * is halted via the emergency stop. Provides a Resume button to lift the halt. | ||
| * | ||
| * The Redux `clearHalt` only fires on a confirmed resume from the core. If the | ||
| * `emergency_resume` RPC fails (timeout, auth, core unavailable), the halt is | ||
| * preserved locally and a visible retry message is shown — because the core is | ||
| * still halted and clearing the banner would silently re-enable the Stop button | ||
| * while every external-effect action remained blocked. The authoritative source | ||
| * of truth is the core; the `automation_halt` socket broadcast will also clear | ||
| * the state if the resume succeeds server-side after an in-flight RPC failure. | ||
| */ | ||
| export function AutomationHaltedBanner() { | ||
| const { t } = useT(); | ||
| const dispatch = useAppDispatch(); | ||
| const halted = useAppSelector(selectHalted); | ||
| const reason = useAppSelector(selectHaltReason); | ||
| const [resumeFailed, setResumeFailed] = useState(false); | ||
|
|
||
| const onResume = useCallback(async () => { | ||
| setResumeFailed(false); | ||
| console.debug('[emergency] resume requested (source=user)'); | ||
| try { | ||
| await emergencyResume(); | ||
| console.debug('[emergency] resume confirmed by core'); | ||
| // Only clear locally on a CONFIRMED resume. On failure the core is still | ||
| // halted, so clearing here would give a false "safe to run" signal. | ||
| dispatch(clearHalt()); | ||
| } catch (err) { | ||
| console.error('[emergency] resume FAILED — halt preserved locally, retry required', err); | ||
| setResumeFailed(true); | ||
| } | ||
| }, [dispatch]); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // The banner is mounted permanently (it only returns null when not halted), so | ||
| // `resumeFailed` would otherwise leak across halt cycles: a failed resume, then | ||
| // an external socket-driven clear, then a fresh halt would show a stale "could | ||
| // not resume" retry indicator the user never triggered. Reset it whenever the | ||
| // halt lifts so each new cycle starts clean. | ||
| useEffect(() => { | ||
| if (!halted) setResumeFailed(false); | ||
| }, [halted]); | ||
|
|
||
| if (!halted) return null; | ||
|
|
||
| // `sticky top-0 z-40` keeps the halt banner (and its Resume button) visible | ||
| // and reachable ABOVE the provider WebviewHost overlay (absolute inset-0 z-30, | ||
| // rendered as a sibling below); otherwise an active provider account fully | ||
| // covers the safety banner. Stays below the settings modal portal (z-50), | ||
| // matching the app's documented stacking convention. | ||
| return ( | ||
| <div | ||
| role="alert" | ||
| data-analytics-id="automation-halted-banner" | ||
| className="sticky top-0 z-40 flex items-center justify-between gap-3 px-4 py-2.5 bg-[var(--color-coral-50,#fdf2f2)] border-b border-[var(--color-coral-200,#f5c6c6)] text-[var(--color-coral-900,#7c2d2d)]"> | ||
| <div className="flex items-center gap-2 min-w-0"> | ||
| <strong className="shrink-0 font-semibold">{t('safety.haltedTitle')}</strong> | ||
| <span className="truncate text-sm text-[var(--color-coral-700,#b94040)]"> | ||
| {reason || t('safety.haltedBody')} | ||
| </span> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| </div> | ||
| <div className="flex shrink-0 items-center gap-2"> | ||
| {resumeFailed && ( | ||
| <span | ||
| role="status" | ||
| aria-label={t('safety.resumeFailed')} | ||
| data-analytics-id="emergency-resume-failed" | ||
| className="rounded-md bg-[var(--color-coral-100,#fce8e8)] px-2 py-1 text-xs font-medium text-[var(--color-coral-800,#8f3a3a)]"> | ||
| {t('safety.resumeFailed')} | ||
| </span> | ||
| )} | ||
| <button | ||
| type="button" | ||
| data-analytics-id="emergency-resume" | ||
| onClick={() => void onResume()} | ||
| className="rounded-md px-3 py-1 text-sm font-medium border border-[var(--color-coral-400,#d97373)] hover:bg-[var(--color-coral-100,#fce8e8)] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-coral-500,#e05c5c)]"> | ||
| {t('safety.resume')} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { fireEvent, screen, waitFor } from '@testing-library/react'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { setHalt } from '../../store/safetySlice'; | ||
| import { renderWithProviders } from '../../test/test-utils'; | ||
| import { EmergencyStopButton } from './EmergencyStopButton'; | ||
|
|
||
| const stop = vi | ||
| .fn() | ||
| .mockResolvedValue({ | ||
| engaged: true, | ||
| reason: undefined, | ||
| source: undefined, | ||
| engaged_at_ms: undefined, | ||
| }); | ||
| vi.mock('../../services/api/emergencyApi', () => ({ | ||
| emergencyStop: (...a: unknown[]) => stop(...a), | ||
| })); | ||
|
|
||
| beforeEach(() => stop.mockClear()); | ||
|
|
||
| describe('EmergencyStopButton', () => { | ||
| it('renders a button with the emergency stop label', () => { | ||
| renderWithProviders(<EmergencyStopButton />); | ||
| expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined(); | ||
| }); | ||
|
|
||
| it('calls emergencyStop with no argument and dispatches halt on click', async () => { | ||
| const { store } = renderWithProviders(<EmergencyStopButton />); | ||
| fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); | ||
| await waitFor(() => expect(stop).toHaveBeenCalledWith()); | ||
| const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; | ||
| expect(safetyState.halted).toBe(true); | ||
| }); | ||
|
|
||
| it('does NOT mark halted when emergencyStop throws, and shows a visible error', async () => { | ||
| stop.mockRejectedValueOnce(new Error('core unavailable')); | ||
| const { store } = renderWithProviders(<EmergencyStopButton />); | ||
| fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); | ||
| await waitFor(() => expect(stop).toHaveBeenCalled()); | ||
| // The core did not confirm the halt, so the UI must not claim halted. | ||
| const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; | ||
| expect(safetyState.halted).toBe(false); | ||
| // Button stays visible so the user can retry. | ||
| expect(screen.queryByRole('button', { name: /emergency stop/i })).not.toBeNull(); | ||
| // A visible, retryable error is surfaced so the operator knows it failed. | ||
| await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); | ||
| }); | ||
|
|
||
| it('renders nothing while already halted (banner Resume takes over)', () => { | ||
| renderWithProviders(<EmergencyStopButton />, { | ||
| preloadedState: { safety: { halted: true, source: 'user' } }, | ||
| }); | ||
| expect(screen.queryByRole('button', { name: /emergency stop/i })).toBeNull(); | ||
| }); | ||
|
|
||
| it('hides itself when the store transitions to halted', async () => { | ||
| const { store } = renderWithProviders(<EmergencyStopButton />); | ||
| expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined(); | ||
| store.dispatch(setHalt({ source: 'user' })); | ||
| await waitFor(() => | ||
| expect(screen.queryByRole('button', { name: /emergency stop/i })).toBeNull() | ||
| ); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.