diff --git a/README.md b/README.md index a035c52b..0efbde12 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,19 @@ # Alden +## 2026-10-11 — 비상 중단된 브라우저·AX 작업의 정확한 상태 + +로컬 브라우저 작업이 전역 중단 직후 예외로 끝나는 경우 +일반 작업 실패로 오인하지 않고 확정 중단 상태를 반환합니다. +승인된 정확한 AXPress 작업은 **누르기 전 대상 확인 단계**가 +중단된 경우에만 취소로 귀속하며, 누른 뒤 실제 효과가 +불명확하면 기존 `effect_unknown`을 유지해 중복 누름을 막습니다. +작업 문구나 예외의 민감한 내용은 상태 응답으로 전파되지 않습니다. + +[독립 브라우저·AX·음성 세션 중단 계약](docs/architecture/alden-tool-cancellation-20261011.md) · +[macOS CI #38073524156](https://github.com/twoimo/alden/actions/runs/38073524156): +관련 Python 회귀 94개 통과. 전역 중단과 명시적 재개 동안 이전 음성 입력·브라우저 결과가 새 턴으로 전달되지 않으며, 큐 등록 전 취소를 입력 성공으로 보고하지 않습니다. 사용자 Mac의 물리 접근성, 실제 +클릭 결과 및 운영 서비스 중단을 입증한 결과는 아닙니다. + ## 2026-10-11 — 로컬 MLX 응답 확정 후에만 TTS·기억 저장 기존 27B·Flash-Next 로컬 음성 LLM의 종료 사유가 누락됐거나 diff --git a/desktop/DESIGN.md b/desktop/DESIGN.md index f4927032..fc412997 100644 --- a/desktop/DESIGN.md +++ b/desktop/DESIGN.md @@ -1,5 +1,21 @@ # Alden desktop design contract +## 2026-10-11 — Tool stop state is not an effect receipt + +Independent owned-browser jobs that are interrupted during a runner +exception display aborted, not a fake provider failure. Exact-target +background AX only reports aborted when cancellation is confirmed +**before** the press. Once an AX action might have occurred, the +existing unknown-effect error is not converted into successful +cancellation or a retry prompt. Never show private task strings or +provider exceptions as UI status. + +One global stop epoch also fences concurrent local voice and browser turns. A stale voice session cannot allocate a new turn or advertise an input that was cancelled before queue publication; only explicit human resume plus a new token allows new work. + +[Behavior, redaction and macOS CI 94-test evidence](../docs/architecture/alden-tool-cancellation-20261011.md). +Physical macOS Accessibility focus, input and outcome need a separate +owner-device verification. + ## 2026-10-11 — Confirmed voice answer boundary A local MLX transport that emits partial text but does not confirm a diff --git a/docs/ALDEN_CONTINUATION.md b/docs/ALDEN_CONTINUATION.md index c22e340e..a8ca6074 100644 --- a/docs/ALDEN_CONTINUATION.md +++ b/docs/ALDEN_CONTINUATION.md @@ -1,3 +1,7 @@ +> 2026-10-11 추가: [로컬 브라우저·exact AX 비상 중단 귀속과 눌림 효과 미확인 유지](architecture/alden-tool-cancellation-20261011.md). +> `codex/alden-tool-cancel-attribution-20261011`: 기존 [macOS CI 38072772583](https://github.com/twoimo/alden/actions/runs/38072772583) Python 92/92, 통합 [CI 38073524156](https://github.com/twoimo/alden/actions/runs/38073524156) Python 94/94 통과(실제 Alden 음성·브라우저·DB 코드와 모의 하드웨어). 과거 세션 취소 후 신규 입력 성공 오보고와 pre-dispatch 등록 경합을 수정했고 explicit resume는 새 토큰만 허용. +> PR #31 다음 단계로 검토하되 원본 운영 사용자 Mac의 GUI, 중단 이벤트, 권한, STATUS는 직접 검증하지 못했으며 병합·설치를 수행하지 않았다. + > 2026-10-11 신규: [로컬 MLX 응답 완결성/오발화 방지와 음성 이력 실제 경로](architecture/alden-local-mlx-completion-20261011.md). > `codex/alden-voice-completion-fence-20261011`는 PR #30 다음의 별도 독립 소스 브랜치다. > [macOS CI 38072362981](https://github.com/twoimo/alden/actions/runs/38072362981): 175개 중 169개 통과·6개 NumPy skip. 메모리·TTS/SQLite는 고정 모델 전송의 실제 제품 코드 테스트. diff --git a/docs/architecture/alden-tool-cancellation-20261011.md b/docs/architecture/alden-tool-cancellation-20261011.md new file mode 100644 index 00000000..6fe68cbe --- /dev/null +++ b/docs/architecture/alden-tool-cancellation-20261011.md @@ -0,0 +1,97 @@ +# Alden 독립 브라우저·macOS AX 도구 비상 중단 귀속 — 2026-10-11 + +## 소스 기준과 변경 범위 + +기준 원격 코드: PR #31 `0b9ceac7b5e93d093043ea406b742d3b4e5274ff`. +분리 검토 브랜치: `codex/alden-tool-cancel-attribution-20261011`. +PR #27→#28→#29→#30→#31→이 변경의 스택을 유지하고 병합하지 않는다. +지정 Mac 복제본, 원본·사용자 파일, 활성 DB/전송 큐, 모델과 +운영 서비스는 원격 GitHub 소스 변경으로 손대지 않았다. + +## 실제 제품 변경 + +```mermaid +flowchart LR + A[브라우저 작업 시작] --> B{전역 비상 중단?} + B -->|no| C[전용 브라우저 실행] + B -->|yes| D[aborted / no result] + C --> E{실행 결과 또는 예외} + E -->|중단된 상태 + 예외| D + E -->|예외, 중단 없음| F[failed / redacted reason] + E -->|성공 + 중단 없음| G[completed] + H[명시 승인된 AXPress] --> I[정확한 대상 확인] + I -->|중단 후 예외| D + I -->|일치| J[AXPress] + J --> K{효과 확인됨?} + K -->|아니오| L[failed / effect_unknown] + K -->|예, 중단 없음| G +``` + +`scripts/alden_tool_runtime.py`의 `run_browser`에서 브라우저 +컨텍스트가 global abort에 의해 닫히며 예외가 나는 경우를 +`browser_job_failed`로 잘못 보고하지 않는다. 현재 토큰의 +중단 상태를 검사하여 `ToolStatus.ABORTED`와 +`AX_ABORT_GLOBAL`만 내보내고 비공개 exception 내용·작업 +문구·반환 문자열은 노출하지 않는다. 이 상태는 외부 웹사이트의 +선행 부작용이 롤백됐다는 뜻이 아니다. + +`run_exact_ax`는 입력된 타깃을 resolve할 때 **아직 +`perform_exact`를 호출하기 전** 전역 중단이 발생하고 +FocusStealRequired·대상 오류·기타 예외를 수신한 경우 +`ABORTED`로 통일한다. 기존 opt-in·정확한 PID/role/identifier, +foreground/pointer 전환 금지 계약은 보존한다. + +실제 AXPress 이후 토큰 중단이나 실행 오류는 버튼이 눌렸는지 +확정할 수 없으므로 이미 있는 `AX_ERROR_EFFECT_UNKNOWN`을 +계속 유지한다. 이를 취소 성공이나 재실행 가능 상태로 바꾸지 +않고 안전한 사후 증거 대사를 요구한다. + +## 테스트 범위와 한계 + +[macOS CI #38072772583](https://github.com/twoimo/alden/actions/runs/38072772583), +검증 SHA `ef94d51226314408fbcd8830f95d2728bb783cea`: +**Python 92/92 통과**. 대상별 비상 중단, browser context close, +브라우저 실패·비밀 정보 비노출, exact AXPress 미승인, +확정 누름 후 효과 미확인, 로컬 음성 턴 귀속을 검사한다. +전용 모의 Runner/AX adapter와 실제 AbortController를 사용했으나 +사용자 Mac의 Accessibility 승인·실제 창·물리 클릭 증거는 아니다. + +## 서로 다른 런타임의 전역 중단과 재개 + +추가 통합에서 음성 파이프라인과 독립 브라우저가 **같은** +`AbortController` epoch를 사용하는 동안 동시에 작업하도록 +재현했다. Global emergency stop이 발행되면 지연된 모델 응답, +TTS/assistant 이력과 브라우저 결과를 모두 차단한다. +이후 명시적 `resume_after_human_action`은 과거 epoch 토큰을 +되살리지 않고, 새 토큰으로 새롭게 시작한 세션만 성공한다. + +`scripts/alden_voice.py`의 `_begin_turn`은 유효하지 않은 +세션에서 새 사용자 입력·`turn_id`를 할당하지 않는다. +동기 `process_text/process_utterance`는 해당 입력을 +`ABORTED`, `cancelled=true`로 정확히 반환한다. +입력 수신과 작업 큐 등록 사이의 중단 경합도 +`_submit_turn`의 성공 boolean으로 연결해, 작업 큐에 +등록되지 않은 요청을 `submit_text`가 성공했다고 +보고하지 않도록 수정했다. + +[macOS CI #38073524156](https://github.com/twoimo/alden/actions/runs/38073524156), +정확한 검증 SHA `80c8bc002404e9c77328ac9e1147dd3cf6a94dd4`: +**Python 94/94 통과**. `tests/test_alden_cross_runtime_abort.py`는 +실제 `AldenVoicePipeline`, `AldenToolRuntime`, +`alden_history.database`, `AbortController`를 함께 실행한다. +네트워크 모델과 브라우저/스피커만 모의 객체이므로 이 결과는 +실물 macOS 장치의 모델·AX·마이크 효과를 증명하지 않는다. +선행 실패 실행 `38073211690`은 테스트의 SQL 정렬 가정 오류로 +분리해 보존했고, `38073277534`에서 교차 턴 93개 통과한 +결과도 당시 커밋의 독립 증거로 남긴다. + +## 실제 설치/릴리즈 게이트 + +Mac 네이티브 Core/Computer Use가 아직 사용 가능한 유효한 +`turn_token`을 제공하지 않아 사용자의 작업 복제본, +`goal-objective.md` 원문과 `outputs/STATUS.md`, 물리 UI· +프로세스 상태를 재조회하지 못했다. 기존 산출물과 음성 모델 +설정·데이터를 보존한 뒤 최신 스택의 정확한 소스를 +검토·설치·서명·공증·운영 배포해야 한다. +이 GitHub 원격 CI는 비상 중단 직후 다른 Mac 앱의 실제 UI +효과가 없었음을 증명하지 못한다. diff --git a/scripts/alden_tool_runtime.py b/scripts/alden_tool_runtime.py index f5615065..620f0cf7 100644 --- a/scripts/alden_tool_runtime.py +++ b/scripts/alden_tool_runtime.py @@ -329,6 +329,17 @@ async def run_browser(self, job: BrowserToolJob) -> ToolJobResult: ) raise except Exception: + # A local runner can throw while global stop closes its browser + # context. That turn is cancelled, not a new browser failure. + # The runner's own effect receipts remain authoritative; this + # status never asserts that an external page write was reverted. + if token.is_cancelled(): + return self._finish( + job_id=job_id, + kind=kind, + status=ToolStatus.ABORTED, + error_code=AX_ABORT_GLOBAL, + ) return self._finish( job_id=job_id, kind=kind, @@ -577,6 +588,11 @@ def run_exact_ax(self, job: ExactAxToolJob) -> ToolJobResult: adapter = self._ax_adapter_factory() resolved = adapter.resolve_exact(job.target, timeout_seconds=timeout_seconds) except FocusStealRequired: + if token.is_cancelled(): + return self._finish( + job_id=job_id, kind=kind, + status=ToolStatus.ABORTED, error_code=AX_ABORT_GLOBAL, + ) return self._finish( job_id=job_id, kind=kind, @@ -584,6 +600,11 @@ def run_exact_ax(self, job: ExactAxToolJob) -> ToolJobResult: error_code=AX_ABORT_FOCUS_REQUIRED, ) except BackgroundAxActionError as exc: + if token.is_cancelled(): + return self._finish( + job_id=job_id, kind=kind, + status=ToolStatus.ABORTED, error_code=AX_ABORT_GLOBAL, + ) status = ( ToolStatus.REJECTED if exc.error_code @@ -602,6 +623,11 @@ def run_exact_ax(self, job: ExactAxToolJob) -> ToolJobResult: error_code=exc.error_code, ) except Exception: + if token.is_cancelled(): + return self._finish( + job_id=job_id, kind=kind, + status=ToolStatus.ABORTED, error_code=AX_ABORT_GLOBAL, + ) return self._finish( job_id=job_id, kind=kind, diff --git a/scripts/alden_voice.py b/scripts/alden_voice.py index 0d26844f..20e3326a 100644 --- a/scripts/alden_voice.py +++ b/scripts/alden_voice.py @@ -1235,6 +1235,11 @@ def _begin_turn(self, source: str, event_id: str | None) -> VoiceTurn | None: with self._lock: if self._closed or source not in {"microphone", "text"}: return None + # An older, globally aborted session may not accept another input + # after human resume. The caller must create a fresh epoch token; + # otherwise a rejected ticket would falsely advance turn_id. + if self.token.is_cancelled(): + return None if event_id is not None: if not isinstance(event_id, str) or not event_id or len(event_id) > 256: return None @@ -1304,22 +1309,23 @@ def _close_adapters(self) -> None: if failure is not None: raise RuntimeError("voice_resource_cleanup_failed") from failure - def _submit_turn(self, turn: VoiceTurn, pcm16: bytes = b"", text: str | None = None) -> None: + def _submit_turn(self, turn: VoiceTurn, pcm16: bytes = b"", text: str | None = None) -> bool: future: Future[VoiceResult] = Future() self._latest_future = future if turn.token.is_cancelled(): future.set_result(self._turn_end(turn, VoiceState.ABORTED, "global_abort")) - return + return False try: self._turn_state(turn, VoiceState.TRANSCRIBING if text is None else VoiceState.GENERATING) except AldenCancelled: future.set_result(self._turn_end(turn, VoiceState.ABORTED, "turn_cancelled")) - return + return False self._pending_turn = (turn, pcm16, text, future) if self._worker is None: self._worker = threading.Thread(target=self._work_loop, daemon=True, name="alden-voice") self._worker.start() self._work_ready.notify() + return True def _work_loop(self) -> None: while True: @@ -1350,8 +1356,7 @@ def submit_text(self, text: str, *, source: str = "text", event_id: str | None = turn = self._begin_turn(source, event_id) if turn is None: return False - self._submit_turn(turn, text=text) - return True + return self._submit_turn(turn, text=text) def poll_result(self) -> VoiceResult | None: with self._lock: @@ -1389,14 +1394,24 @@ def _turn_end(self, turn: VoiceTurn, state: VoiceState, error: str) -> VoiceResu def process_utterance(self, pcm16: bytes, *, source: str = "microphone", event_id: str | None = None) -> VoiceResult: turn = self._begin_turn(source, event_id) if turn is None: - return VoiceResult(VoiceState.ENDED, "input_ignored", conversation_id=self.conversation_id, turn_id=self.turn_id, context_version=self.context_version) + with self._lock: + cancelled = self.token.is_cancelled() + return VoiceResult(VoiceState.ABORTED if cancelled else VoiceState.ENDED, + "global_abort" if cancelled else "input_ignored", + conversation_id=self.conversation_id, turn_id=self.turn_id, + context_version=self.context_version, cancelled=cancelled) with self._processing_lock: return self._process_turn(turn, pcm16=pcm16) def process_text(self, text: str, *, source: str = "text", event_id: str | None = None) -> VoiceResult: turn = self._begin_turn(source, event_id) if turn is None: - return VoiceResult(VoiceState.ENDED, "input_ignored", conversation_id=self.conversation_id, turn_id=self.turn_id, context_version=self.context_version) + with self._lock: + cancelled = self.token.is_cancelled() + return VoiceResult(VoiceState.ABORTED if cancelled else VoiceState.ENDED, + "global_abort" if cancelled else "input_ignored", + conversation_id=self.conversation_id, turn_id=self.turn_id, + context_version=self.context_version, cancelled=cancelled) with self._processing_lock: return self._process_turn(turn, text=text) diff --git a/tests/test_alden_cross_runtime_abort.py b/tests/test_alden_cross_runtime_abort.py new file mode 100644 index 00000000..23517bca --- /dev/null +++ b/tests/test_alden_cross_runtime_abort.py @@ -0,0 +1,197 @@ +"""One shared emergency latch across live Alden voice and local browser jobs. + +Runs real AldenVoicePipeline, SQLite user/assistant history, AbortController, +and AldenToolRuntime together. Only model/browser/hardware actions are faked. +""" +import asyncio +from pathlib import Path +import sys +import threading +from tempfile import TemporaryDirectory +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from alden_abort import AbortController +from alden_browser_use import BrowserJobResult +from alden_history import database +from alden_tool_runtime import AldenToolRuntime, BrowserToolJob, ToolStatus +from alden_voice import AldenVoicePipeline, VoiceResult, VoiceState, VoiceStatusStore + + +class BlockingAnswer: + def __init__(self): + self.started = threading.Event() + self.release = threading.Event() + self.completed = 0 + + def generate(self, text, token, *, history=()): + self.started.set() + if not self.release.wait(4): + raise RuntimeError("test blocking answer timed out") + token.raise_if_cancelled() + self.completed += 1 + return "old response must not be spoken" + + +class TextToSpeechSpy: + def __init__(self): + self.calls = [] + + def speak(self, text, token): + token.raise_if_cancelled() + self.calls.append(text) + + +class GatedBrowser: + def __init__(self, *, abort_error=False): + self.started = asyncio.Event() + self.release = asyncio.Event() + self.abort_error = abort_error + + async def run(self, task): + self.started.set() + await self.release.wait() + if self.abort_error: + raise RuntimeError("browser terminated after global emergency stop") + return BrowserJobResult(True, result="confirmed browser result") + + +class SharedEmergencyBoundary(unittest.IsolatedAsyncioTestCase): + async def test_abort_between_ticket_creation_and_dispatch_returns_rejected_submission(self): + with TemporaryDirectory() as directory: + root = Path(directory) + controller = AbortController(root) + class NeverInfer: + def generate(self, *args, **kwargs): + self.fail("pre-dispatch abort must never call the model") + speaker = TextToSpeechSpy() + voice = AldenVoicePipeline( + stt=object(), llm=NeverInfer(), tts=speaker, + token=AbortController(root).token(), status=VoiceStatusStore(root), + manual_listen=True, + ) + try: + begin = voice._begin_turn + def abort_after_allocating_ticket(source, event_id): + turn = begin(source, event_id) + controller.abort("stop between ticket and worker queue") + return turn + voice._begin_turn = abort_after_allocating_ticket + self.assertFalse(voice.submit_text("must not reach inference", event_id="ticket-race")) + completed = voice.poll_result() + self.assertIsNotNone(completed) + self.assertEqual(completed.state, VoiceState.ABORTED) + self.assertEqual(completed.reply, "") + self.assertEqual(voice._recent_conversation(), []) + self.assertEqual(speaker.calls, []) + self.assertIsNone(voice._worker) + finally: + voice.close() + + async def test_global_stop_fences_both_runtime_results_and_only_fresh_epoch_can_resume(self): + with TemporaryDirectory() as directory: + root = Path(directory) + controller = AbortController(root) + llm = BlockingAnswer() + voice_speaker = TextToSpeechSpy() + voice = AldenVoicePipeline( + stt=object(), llm=llm, tts=voice_speaker, + token=AbortController(root).token(), status=VoiceStatusStore(root), + manual_listen=True, + ) + browser_runner = GatedBrowser(abort_error=True) + browser_runtime = AldenToolRuntime( + root, _browser_runner_factory=lambda token: browser_runner, + ) + try: + self.assertTrue(voice.submit_text("현재 요청", source="text", event_id="voice-event-1")) + browser = asyncio.create_task(browser_runtime.run_browser( + BrowserToolJob("browser-job-1", "bounded local task") + )) + self.assertTrue(await asyncio.to_thread(llm.started.wait, 3)) + await asyncio.wait_for(browser_runner.started.wait(), 3) + + controller.abort("user-pressed-emergency-stop") + llm.release.set() + browser_runner.release.set() + browser_answer = await asyncio.wait_for(browser, 3) + + self.assertEqual(browser_answer.status, ToolStatus.ABORTED) + self.assertFalse(browser_answer.ok) + self.assertEqual(browser_answer.result, "") + self.assertEqual(browser_answer.error_code, "global_abort") + + voice_answer: VoiceResult | None = None + for _ in range(100): + voice_answer = voice.poll_result() + if voice_answer is not None: + break + await asyncio.sleep(.01) + self.assertIsNotNone(voice_answer) + self.assertEqual(voice_answer.state, VoiceState.ABORTED) + self.assertTrue(voice_answer.cancelled) + self.assertEqual(voice_answer.reply, "") + self.assertEqual(llm.completed, 0) + self.assertEqual(voice_speaker.calls, []) + with database(root) as db: + prior = db.execute( + "SELECT role,content FROM voice_messages WHERE session_id=?", + (voice.conversation_id,), + ).fetchall() + self.assertEqual([(row["role"], row["content"]) for row in prior], + [("user", "현재 요청")]) + + # Resume requires an explicitly renewed global epoch. The old + # voice token cannot be reused for a previously cancelled turn. + controller.resume_after_human_action() + self.assertTrue(voice.token.is_cancelled()) + prior_turn = voice.turn_id + self.assertFalse(voice.submit_text("stale epoch ignored", event_id="old-token")) + ignored = voice.process_text("stale direct input", event_id="old-direct") + self.assertEqual(ignored.state, VoiceState.ABORTED) + self.assertTrue(ignored.cancelled) + self.assertEqual(ignored.error_code, "global_abort") + self.assertEqual(voice.turn_id, prior_turn) + # Begin a separate, freshly owned voice session after resume. + new_speaker = TextToSpeechSpy() + class ReadyAnswer: + def generate(self, text, token, *, history=()): + token.raise_if_cancelled() + return "fresh confirmed answer" + renewed = AldenVoicePipeline( + stt=object(), llm=ReadyAnswer(), tts=new_speaker, + token=AbortController(root).token(), status=VoiceStatusStore(root), + manual_listen=True, + ) + try: + result = renewed.process_text("재개한 새 요청", event_id="fresh-voice-turn") + self.assertEqual(result.state, VoiceState.ENDED) + self.assertEqual(result.reply, "fresh confirmed answer") + self.assertEqual(new_speaker.calls, ["fresh confirmed answer"]) + with database(root) as db: + newer = db.execute( + "SELECT role FROM voice_messages WHERE session_id=? " + "ORDER BY turn_id, CASE role WHEN 'user' THEN 0 ELSE 1 END", + (renewed.conversation_id,), + ).fetchall() + self.assertEqual([row["role"] for row in newer], ["user", "assistant"]) + finally: + renewed.close() + + ready = GatedBrowser() + ready.release.set() + new_browser = AldenToolRuntime(root, _browser_runner_factory=lambda token: ready) + fresh_browser_result = await new_browser.run_browser( + BrowserToolJob("browser-job-2", "new explicitly issued job") + ) + self.assertEqual(fresh_browser_result.status, ToolStatus.COMPLETED) + self.assertEqual(fresh_browser_result.result, "confirmed browser result") + self.assertNotEqual(fresh_browser_result.job_id, browser_answer.job_id) + finally: + llm.release.set() + browser_runner.release.set() + voice.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_alden_tool_runtime.py b/tests/test_alden_tool_runtime.py index 3741085d..9c48bebf 100644 --- a/tests/test_alden_tool_runtime.py +++ b/tests/test_alden_tool_runtime.py @@ -272,6 +272,24 @@ def aborting_ax_action() -> bool: self.assertEqual(ax.status, ToolStatus.ABORTED) self.assertEqual(ax.error_code, AX_ABORT_GLOBAL) + async def test_global_abort_followed_by_browser_exception_reports_cancelled_turn(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + controller = AbortController(root) + class ExplodeAfterStop: + async def run(self, _task): + controller.abort("stop before result publication") + raise RuntimeError("arbitrary provider payload must stay private") + + runtime = AldenToolRuntime( + root, _browser_runner_factory=lambda _token: ExplodeAfterStop(), + ) + result = await runtime.run_browser(BrowserToolJob("browser-exception-after-stop", "task")) + self.assertEqual(result.status, ToolStatus.ABORTED) + self.assertEqual(result.error_code, AX_ABORT_GLOBAL) + self.assertEqual(result.result, "") + self.assertTrue(read_abort_state(controller.path).latched) + async def test_task_rect_and_result_bounds_fail_closed(self): with TemporaryDirectory() as temp_dir: root = Path(temp_dir) @@ -414,6 +432,29 @@ def test_exact_ax_global_abort_after_action_reports_uncertain_effect(self): self.assertEqual(len(adapter.resolve_calls), 1) self.assertEqual(len(adapter.perform_calls), 1) + def test_abort_while_resolving_exact_ax_does_not_press_or_misreport_failure(self): + errors = (FocusStealRequired(), BackgroundAxActionError(AX_ERROR_TARGET_AMBIGUOUS), + RuntimeError("accessibility error after emergency stop")) + for raised in errors: + with self.subTest(error=type(raised).__name__), TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + controller = AbortController(root) + class AbortingResolver(FakeExactAxAdapter): + def resolve_exact(self, target, *, timeout_seconds): + self.resolve_calls.append((target, timeout_seconds)) + controller.abort("global stop during read-only resolution") + raise raised + + adapter = AbortingResolver() + runtime = AldenToolRuntime(root, _ax_adapter_factory=lambda: adapter) + result = runtime.run_exact_ax( + ExactAxToolJob("ax-resolve-stopped", exact_target(), opt_in=True) + ) + self.assertEqual(result.status, ToolStatus.ABORTED) + self.assertEqual(result.error_code, AX_ABORT_GLOBAL) + self.assertEqual(len(adapter.resolve_calls), 1) + self.assertEqual(adapter.perform_calls, []) + def test_exact_ax_latched_abort_prevents_target_resolution(self): with TemporaryDirectory() as temp_dir: root = Path(temp_dir)