Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5230b81
fix(Alden): distinguish emergency stop from browser and pre-press AX …
twoimo Oct 10, 2026
4069eae
test(Alden): verify interrupted browser exception and pre-press AX re…
twoimo Oct 10, 2026
78d2f5e
docs(Alden): document safe tool cancellation and post-press uncertain…
twoimo Oct 10, 2026
fb40126
docs(Alden): carry emergency tool attribution through README and desi…
twoimo Oct 10, 2026
66c10d6
docs(Alden): carry emergency tool attribution through README and desi…
twoimo Oct 10, 2026
cad0120
docs(Alden): carry emergency tool attribution through README and desi…
twoimo Oct 10, 2026
d15aed8
test(Alden): prove one global abort fences concurrent voice and brows…
twoimo Oct 10, 2026
b7f8907
fix(Alden): deny input into a cancelled voice session without allocat…
twoimo Oct 10, 2026
1da2de3
test(Alden): verify stale voice session refuses new async/direct inpu…
twoimo Oct 10, 2026
b76a33e
test(Alden): sort persistent voice messages by turn and role before c…
twoimo Oct 10, 2026
9ebf2ed
fix(Alden): reject newly aborted voice submissions before worker sche…
twoimo Oct 10, 2026
79d72d6
test(Alden): rejection status when emergency stop wins before voice w…
twoimo Oct 10, 2026
c57d2dc
docs(Alden): record cross-runtime abort/resume, stale token and pre-d…
twoimo Oct 10, 2026
49ff146
docs(Alden): add runtime-coordinated emergency stop and new-session p…
twoimo Oct 10, 2026
2ee702d
docs(Alden): add runtime-coordinated emergency stop and new-session p…
twoimo Oct 10, 2026
4a4882c
docs(Alden): add runtime-coordinated emergency stop and new-session p…
twoimo Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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의 종료 사유가 누락됐거나
Expand Down
16 changes: 16 additions & 0 deletions desktop/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/ALDEN_CONTINUATION.md
Original file line number Diff line number Diff line change
@@ -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는 고정 모델 전송의 실제 제품 코드 테스트.
Expand Down
97 changes: 97 additions & 0 deletions docs/architecture/alden-tool-cancellation-20261011.md
Original file line number Diff line number Diff line change
@@ -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
효과가 없었음을 증명하지 못한다.
26 changes: 26 additions & 0 deletions scripts/alden_tool_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -577,13 +588,23 @@ 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,
status=ToolStatus.REFUSED,
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
Expand All @@ -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,
Expand Down
29 changes: 22 additions & 7 deletions scripts/alden_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
Loading