From 5230b815464cb39eabfbc04d5bcf41f968ab55e6 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:40:57 +0900 Subject: [PATCH 01/16] fix(Alden): distinguish emergency stop from browser and pre-press AX resolution failures --- scripts/alden_tool_runtime.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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, From 4069eae76b52dea9aada261a4a9c823306451c73 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:41:18 +0900 Subject: [PATCH 02/16] test(Alden): verify interrupted browser exception and pre-press AX resolution do not publish success --- tests/test_alden_tool_runtime.py | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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) From 78d2f5e8176c71a7ab17747afb70d22af25b832f Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:42:57 +0900 Subject: [PATCH 03/16] docs(Alden): document safe tool cancellation and post-press uncertain-effect contract --- .../alden-tool-cancellation-20261011.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/architecture/alden-tool-cancellation-20261011.md diff --git a/docs/architecture/alden-tool-cancellation-20261011.md b/docs/architecture/alden-tool-cancellation-20261011.md new file mode 100644 index 00000000..37d4be21 --- /dev/null +++ b/docs/architecture/alden-tool-cancellation-20261011.md @@ -0,0 +1,68 @@ +# 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 승인·실제 창·물리 클릭 증거는 아니다. + +## 실제 설치/릴리즈 게이트 + +Mac 네이티브 Core/Computer Use가 아직 사용 가능한 유효한 +`turn_token`을 제공하지 않아 사용자의 작업 복제본, +`goal-objective.md` 원문과 `outputs/STATUS.md`, 물리 UI· +프로세스 상태를 재조회하지 못했다. 기존 산출물과 음성 모델 +설정·데이터를 보존한 뒤 최신 스택의 정확한 소스를 +검토·설치·서명·공증·운영 배포해야 한다. +이 GitHub 원격 CI는 비상 중단 직후 다른 Mac 앱의 실제 UI +효과가 없었음을 증명하지 못한다. From fb4012609cdad4363f9b5851af1f26a9fc206808 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:43:16 +0900 Subject: [PATCH 04/16] docs(Alden): carry emergency tool attribution through README and design handoff --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index a035c52b..44f0100e 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 #38072772583](https://github.com/twoimo/alden/actions/runs/38072772583): +관련 Python 회귀 92개 통과. 사용자 Mac의 물리 접근성, 실제 +클릭 결과 및 운영 서비스 중단을 입증한 결과는 아닙니다. + ## 2026-10-11 — 로컬 MLX 응답 확정 후에만 TTS·기억 저장 기존 27B·Flash-Next 로컬 음성 LLM의 종료 사유가 누락됐거나 From 66c10d6874a03b10ef88ca93fb9d1c445810b64a Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:43:20 +0900 Subject: [PATCH 05/16] docs(Alden): carry emergency tool attribution through README and design handoff --- desktop/DESIGN.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/desktop/DESIGN.md b/desktop/DESIGN.md index f4927032..642053fd 100644 --- a/desktop/DESIGN.md +++ b/desktop/DESIGN.md @@ -1,5 +1,19 @@ # 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. + +[Behavior, redaction and CI 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 From cad0120b1508345d3a92006e6fe9f0d4bf7e8555 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:43:24 +0900 Subject: [PATCH 06/16] docs(Alden): carry emergency tool attribution through README and design handoff --- docs/ALDEN_CONTINUATION.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ALDEN_CONTINUATION.md b/docs/ALDEN_CONTINUATION.md index c22e340e..50374c9b 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 통과(분리 fixture와 실제 AbortController). +> 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는 고정 모델 전송의 실제 제품 코드 테스트. From d15aed8761c6a08047e652efffa3697d94dc732a Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:45:51 +0900 Subject: [PATCH 07/16] test(Alden): prove one global abort fences concurrent voice and browser jobs until explicit resume --- tests/test_alden_cross_runtime_abort.py | 157 ++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/test_alden_cross_runtime_abort.py diff --git a/tests/test_alden_cross_runtime_abort.py b/tests/test_alden_cross_runtime_abort.py new file mode 100644 index 00000000..b8217a0d --- /dev/null +++ b/tests/test_alden_cross_runtime_abort.py @@ -0,0 +1,157 @@ +"""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_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, "abort_global") + + 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()) + self.assertFalse(voice.submit_text("stale epoch ignored", event_id="old-token")) + # 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=?", + (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() From b7f8907a2c1ab5333b2827498411936c042f95f5 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:47:03 +0900 Subject: [PATCH 08/16] fix(Alden): deny input into a cancelled voice session without allocating false turn IDs --- scripts/alden_voice.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/alden_voice.py b/scripts/alden_voice.py index 0d26844f..8be121aa 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 @@ -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) From 1da2de3c970d1081bd28046c3b48b398c26eaa14 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:47:31 +0900 Subject: [PATCH 09/16] test(Alden): verify stale voice session refuses new async/direct input after resume --- tests/test_alden_cross_runtime_abort.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_alden_cross_runtime_abort.py b/tests/test_alden_cross_runtime_abort.py index b8217a0d..73cd60f3 100644 --- a/tests/test_alden_cross_runtime_abort.py +++ b/tests/test_alden_cross_runtime_abort.py @@ -88,7 +88,7 @@ async def test_global_stop_fences_both_runtime_results_and_only_fresh_epoch_can_ self.assertEqual(browser_answer.status, ToolStatus.ABORTED) self.assertFalse(browser_answer.ok) self.assertEqual(browser_answer.result, "") - self.assertEqual(browser_answer.error_code, "abort_global") + self.assertEqual(browser_answer.error_code, "global_abort") voice_answer: VoiceResult | None = None for _ in range(100): @@ -114,7 +114,13 @@ async def test_global_stop_fences_both_runtime_results_and_only_fresh_epoch_can_ # 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: From b76a33e2f541ff7367a66a69c5d52dcbea0ac835 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:48:58 +0900 Subject: [PATCH 10/16] test(Alden): sort persistent voice messages by turn and role before checking resume --- tests/test_alden_cross_runtime_abort.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_alden_cross_runtime_abort.py b/tests/test_alden_cross_runtime_abort.py index 73cd60f3..7860d2e8 100644 --- a/tests/test_alden_cross_runtime_abort.py +++ b/tests/test_alden_cross_runtime_abort.py @@ -138,8 +138,11 @@ def generate(self, text, token, *, history=()): 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=?", - (renewed.conversation_id,)).fetchall() + 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() From 9ebf2edcabce4b6b6134489667fa3ef1c454f8cb Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:50:29 +0900 Subject: [PATCH 11/16] fix(Alden): reject newly aborted voice submissions before worker scheduling --- scripts/alden_voice.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/alden_voice.py b/scripts/alden_voice.py index 8be121aa..20e3326a 100644 --- a/scripts/alden_voice.py +++ b/scripts/alden_voice.py @@ -1309,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: @@ -1355,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: From 79d72d68dfd11ffcc3d8896ec9bbab9ca3d5f261 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:50:48 +0900 Subject: [PATCH 12/16] test(Alden): rejection status when emergency stop wins before voice work queue dispatch --- tests/test_alden_cross_runtime_abort.py | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_alden_cross_runtime_abort.py b/tests/test_alden_cross_runtime_abort.py index 7860d2e8..23517bca 100644 --- a/tests/test_alden_cross_runtime_abort.py +++ b/tests/test_alden_cross_runtime_abort.py @@ -57,6 +57,37 @@ async def run(self, task): 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) From c57d2dc13506c67c323cc31a60526e7388c01bb8 Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:54:56 +0900 Subject: [PATCH 13/16] docs(Alden): record cross-runtime abort/resume, stale token and pre-dispatch receipt --- .../alden-tool-cancellation-20261011.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/architecture/alden-tool-cancellation-20261011.md b/docs/architecture/alden-tool-cancellation-20261011.md index 37d4be21..6fe68cbe 100644 --- a/docs/architecture/alden-tool-cancellation-20261011.md +++ b/docs/architecture/alden-tool-cancellation-20261011.md @@ -56,6 +56,35 @@ foreground/pointer 전환 금지 계약은 보존한다. 전용 모의 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가 아직 사용 가능한 유효한 From 49ff146874b0e0d651b661eb5df8ee6c2579ee6b Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:55:13 +0900 Subject: [PATCH 14/16] docs(Alden): add runtime-coordinated emergency stop and new-session publication evidence --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 44f0100e..0efbde12 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ 불명확하면 기존 `effect_unknown`을 유지해 중복 누름을 막습니다. 작업 문구나 예외의 민감한 내용은 상태 응답으로 전파되지 않습니다. -[독립 브라우저·AX 중단 계약](docs/architecture/alden-tool-cancellation-20261011.md) · -[macOS CI #38072772583](https://github.com/twoimo/alden/actions/runs/38072772583): -관련 Python 회귀 92개 통과. 사용자 Mac의 물리 접근성, 실제 +[독립 브라우저·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·기억 저장 From 2ee702d3e6c7b941e8ea63cbc514fa4bddd349bf Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:55:17 +0900 Subject: [PATCH 15/16] docs(Alden): add runtime-coordinated emergency stop and new-session publication evidence --- desktop/DESIGN.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/DESIGN.md b/desktop/DESIGN.md index 642053fd..fc412997 100644 --- a/desktop/DESIGN.md +++ b/desktop/DESIGN.md @@ -10,7 +10,9 @@ 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. -[Behavior, redaction and CI evidence](../docs/architecture/alden-tool-cancellation-20261011.md). +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. From 4a4882ce8ac43e9d10e63924730f6c296399b54c Mon Sep 17 00:00:00 2001 From: Yeonwoo Choi <32544727+twoimo@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:55:21 +0900 Subject: [PATCH 16/16] docs(Alden): add runtime-coordinated emergency stop and new-session publication evidence --- docs/ALDEN_CONTINUATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ALDEN_CONTINUATION.md b/docs/ALDEN_CONTINUATION.md index 50374c9b..a8ca6074 100644 --- a/docs/ALDEN_CONTINUATION.md +++ b/docs/ALDEN_CONTINUATION.md @@ -1,5 +1,5 @@ > 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 통과(분리 fixture와 실제 AbortController). +> `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).