Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 36 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,14 +246,14 @@ only be noticed by polling — that is what `session watch` does. A connection c
instead send one `"type": "subscribe"` request and become an event stream:

```json
{"type":"subscribe","name":"events","args":{"events":["tempo","is_playing"]},"meta":{},"request_id":"...","protocol_version":2}
{"type":"subscribe","name":"events","args":{"events":["tempo","is_playing"]},"meta":{},"request_id":"...","protocol_version":3}
```

The remote answers with the usual response envelope (`result.subscribed` lists the
accepted names, `result.stream` is `true`) and then pushes one line per event:

```json
{"type":"event","protocol_version":2,"event":"tempo","ts":1730000000.0,"data":{"tempo":174.0},"dropped":0}
{"type":"event","protocol_version":3,"event":"tempo","ts":1730000000.0,"data":{"tempo":174.0},"dropped":0}
```

- A subscribing connection stops accepting commands, so pushed events can never
Expand Down Expand Up @@ -315,20 +315,38 @@ accepted names, `result.stream` is `true`) and then pushes one line per event:
"name": "song_info",
"args": {},
"meta": {
"request_timeout_ms": 15000
"request_timeout_ms": 15000,
"idempotency_key": "0f1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e"
},
"request_id": "8c9f9b0c1a9d4dc2abdf2d53f3a19be9",
"protocol_version": 2
"protocol_version": 3
}
```

`request_id` is fresh on every send and never identifies a retry.

`meta.idempotency_key` is optional (max 128 characters) and is what makes a
resend safe. The Remote Script keeps the response of the last 256 completed
keys; a request repeating a key it has already completed is **not executed
again** — the stored response is returned with `idempotent_replay: true` added
to `result` (or to `error.details` for a stored failure). `batch run` and
`batch stream` generate one key per step and reuse it for every retry attempt,
so a step that times out and is retried cannot be applied twice.

Protocol versions must match exactly. After upgrading the CLI, reinstall the
Remote Script and re-select the Control Surface in Live:

```bash
uv run ableton-cli install-remote-script --yes
```

### Response (success)

```json
{
"ok": true,
"request_id": "8c9f9b0c1a9d4dc2abdf2d53f3a19be9",
"protocol_version": 2,
"protocol_version": 3,
"result": {
"tempo": 120.0
},
Expand All @@ -342,7 +360,7 @@ accepted names, `result.stream` is `true`) and then pushes one line per event:
{
"ok": false,
"request_id": "8c9f9b0c1a9d4dc2abdf2d53f3a19be9",
"protocol_version": 2,
"protocol_version": 3,
"result": null,
"error": {
"code": "INVALID_ARGUMENT",
Expand Down Expand Up @@ -383,20 +401,19 @@ Batch steps run once unless the step opts in with a `retry` object:
`retry.on` defaults to `["REMOTE_BUSY"]`. `REMOTE_BUSY` is rejected by the
Remote Script before the command is queued, so a retry cannot double-apply it.

`TIMEOUT` is not retried by default, and listing it in `retry.on` fails with
`INVALID_ARGUMENT` unless the step's remote command is idempotent (read
commands). A timed-out command may already have been applied by Live, so
retrying it would apply it twice.
`TIMEOUT` is not retried by default, but adding it to `retry.on` is safe for
any command: a retrying step carries one `meta.idempotency_key` across all of
its attempts, so the Remote Script replays its stored response rather than
applying the command a second time. A replayed response carries
`idempotent_replay: true`.

A step that fails with `TIMEOUT` reports `error.details.may_have_executed`:
A step that fails with `TIMEOUT` and is not retried reports
`error.details.may_have_executed`:

- `true` — Live had already started the command; inspect the set before retrying
- `false` — the command was cancelled before it ran and can be resent safely
- `null` — no structured answer reached the CLI; assume it may have been applied

A `TIMEOUT` with `may_have_executed: true` is never retried, even when
`retry.on` lists `TIMEOUT`.

## Exit Codes

- `0` success
Expand Down Expand Up @@ -514,7 +531,11 @@ Development workflows (local checks, quality gates, and merge criteria) are docu
2. Confirm Ableton is running and `AbletonCliRemote` is selected as Control Surface.
3. Confirm host/port (`127.0.0.1:8765` by default).
4. Try a longer timeout: `--timeout-ms 30000`.
5. If protocol mismatches, use `--protocol-version <n>` or `uv run ableton-cli config set protocol_version <n>`.
5. If protocol mismatches, the installed Remote Script is from a different release:
run `uv run ableton-cli install-remote-script --yes`, restart Ableton Live, and re-select
`AbletonCliRemote` as Control Surface. Use `--protocol-version <n>` or
`uv run ableton-cli config set protocol_version <n>` only to talk to a deliberately pinned
Remote Script version.

### `AbletonCliRemote` not shown in Control Surface list

Expand Down
2 changes: 1 addition & 1 deletion remote_script/AbletonCliRemote/command_backend_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from .note_fields import NOTE_FIELD_SPECS

PROTOCOL_VERSION = 2
PROTOCOL_VERSION = 3
REMOTE_SCRIPT_VERSION = "0.5.0"
MIN_BPM = 20.0
MAX_BPM = 999.0
Expand Down
85 changes: 85 additions & 0 deletions remote_script/AbletonCliRemote/control_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import queue
import threading
import time
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
Expand Down Expand Up @@ -45,19 +46,56 @@ def schedule_message(self, _delay: int, callback: Callable[[], None]) -> None:
DRAIN_BUDGET_S = 0.005


#: How many completed responses are kept for idempotency-key replay.
#: A retry follows within one request timeout, so only recent keys can ever be
#: hit; 256 covers a deep `batch stream` burst while keeping the cache small
#: enough to be irrelevant to Live's memory. Eviction is FIFO by insertion.
IDEMPOTENCY_CACHE_SIZE = 256

#: Reserved result/details field marking a response that was replayed from the
#: idempotency cache instead of being executed again. No command handler may
#: return a result key by this name.
IDEMPOTENT_REPLAY_FIELD = "idempotent_replay"


@dataclass(slots=True)
class _CommandRequest:
name: str
args: dict[str, Any]
timeout_ms: int
event: threading.Event
idempotency_key: str | None = None
result: dict[str, Any] | None = None
error: Exception | None = None
lock: threading.Lock = field(default_factory=threading.Lock)
cancelled: bool = False
executing: bool = False


@dataclass(slots=True)
class _CachedResponse:
"""A completed dispatch, kept so a retry can replay it instead of rerunning."""

result: dict[str, Any] | None = None
error: CommandError | None = None


def _as_command_error(error: Exception) -> CommandError:
"""Normalize a dispatch failure for caching.

Mirrors how ``_execute_command_from_server_thread`` renders a
non-``CommandError`` exception, so a replayed failure is byte-identical to
the original apart from the replay marker.
"""
if isinstance(error, CommandError):
return error
return CommandError(
code=RemoteErrorCode.INTERNAL_ERROR.value,
message=str(error),
hint="Check Ableton Log.txt for details.",
)


def _mark_request_timed_out(request: _CommandRequest) -> bool:
"""Cancel a request whose client-side wait timed out.

Expand All @@ -82,6 +120,10 @@ def __init__(self, c_instance): # noqa: ANN001
self._queue: queue.Queue[_CommandRequest] = queue.Queue()
self._drain_lock = threading.Lock()
self._drain_scheduled = False
# Read and written only from `_drain_requests`, i.e. only on Live's
# main thread, which serializes dispatch — so no lock is needed and no
# request can observe another one mid-flight under the same key.
self._response_cache: OrderedDict[str, _CachedResponse] = OrderedDict()
remote_config = load_remote_config()
self._auth_token = remote_config.auth_token
self._event_broker = EventBroker()
Expand Down Expand Up @@ -184,6 +226,7 @@ def _execute_command_from_server_thread(
args=args,
timeout_ms=timeout_ms,
event=threading.Event(),
idempotency_key=meta.get("idempotency_key"),
)
self._queue.put(request)
self._schedule_drain()
Expand Down Expand Up @@ -232,6 +275,38 @@ def _scheduled_drain(self) -> None:
if needs_reschedule:
self._schedule_drain()

def _remember_response(self, request: _CommandRequest) -> None:
key = request.idempotency_key
if key is None:
return
if request.error is not None:
entry = _CachedResponse(error=_as_command_error(request.error))
else:
entry = _CachedResponse(result=request.result)
self._response_cache.pop(key, None)
self._response_cache[key] = entry
while len(self._response_cache) > IDEMPOTENCY_CACHE_SIZE:
self._response_cache.popitem(last=False)

def _replay_cached_response(self, request: _CommandRequest) -> bool:
"""Answer from the cache when this key already ran. Returns True on a hit."""
key = request.idempotency_key
if key is None:
return False
entry = self._response_cache.get(key)
if entry is None:
return False
if entry.error is not None:
request.error = CommandError(
code=entry.error.code,
message=entry.error.message,
hint=entry.error.hint,
details={**(entry.error.details or {}), IDEMPOTENT_REPLAY_FIELD: True},
)
else:
request.result = {**(entry.result or {}), IDEMPOTENT_REPLAY_FIELD: True}
return True

def _drain_requests(self, budget_s: float | None = None) -> None:
"""Execute queued requests; ``budget_s=None`` drains without a deadline.

Expand All @@ -252,16 +327,26 @@ def _drain_requests(self, budget_s: float | None = None) -> None:

with request.lock:
if request.cancelled:
# Never dispatched, so there is nothing to remember: a retry
# of this key must actually run the command.
request.event.set()
continue
request.executing = True

if self._replay_cached_response(request):
request.event.set()
continue

dispatched += 1
try:
request.result = dispatch_command(self._backend, request.name, request.args)
except Exception as exc: # noqa: BLE001
request.error = exc
finally:
# Recorded even when the client already gave up waiting — that
# abandoned response is exactly what a retry needs to replay
# instead of applying the command a second time.
self._remember_response(request)
request.event.set()

def update_display(self) -> None:
Expand Down
25 changes: 25 additions & 0 deletions remote_script/AbletonCliRemote/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
_REQUEST_KEYS = {"type", "name", "args", "meta", "request_id", "protocol_version"}
_REQUEST_TYPES = ("command", "subscribe")

#: Mirrors ableton_cli.client.protocol.IDEMPOTENCY_KEY_MAX_LENGTH. The Remote
#: Script cannot import from the CLI package, so the bound is restated here and
#: pinned by a test.
IDEMPOTENCY_KEY_MAX_LENGTH = 128

#: How long a streaming connection waits for the next event before checking
#: whether the server is shutting down.
_EVENT_POLL_INTERVAL_S = 0.25
Expand Down Expand Up @@ -150,10 +155,30 @@ def _parse_command_request(
message="meta must be an object",
hint="Pass a JSON object for meta.",
)
_validate_idempotency_key(meta)

return request_id, str(request_type), name, args, meta


def _validate_idempotency_key(meta: dict[str, Any]) -> None:
if "idempotency_key" not in meta:
return
key = meta["idempotency_key"]
if not isinstance(key, str) or not key:
raise _invalid_request(
message="meta.idempotency_key must be a non-empty string",
hint="Send a stable opaque token, or omit meta.idempotency_key.",
)
if len(key) > IDEMPOTENCY_KEY_MAX_LENGTH:
raise _invalid_request(
message=(
f"meta.idempotency_key must be at most {IDEMPOTENCY_KEY_MAX_LENGTH} "
f"characters, got {len(key)}"
),
hint="Use a short opaque token such as a uuid4 hex.",
)


class _CommandTCPServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True

Expand Down
10 changes: 6 additions & 4 deletions skills/ableton-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ uv run ableton-cli effect utility observe 0 0
uv run ableton-cli config init
uv run ableton-cli config init --dry-run
uv run ableton-cli config show
uv run ableton-cli config set protocol_version 2
uv run ableton-cli config set protocol_version 3
uv run ableton-cli completion
uv run ableton-cli --install-completion
uv run ableton-cli --show-completion
Expand All @@ -423,8 +423,10 @@ uv run ableton-cli --show-completion
- For low-latency repeated automation operations, prefer `uv run ableton-cli batch stream`.
- Capability and compatibility checks are explicit through `uv run ableton-cli ping` and `uv run ableton-cli doctor`.
- Batch steps run once unless the step declares `retry`. `retry.on` defaults to `["REMOTE_BUSY"]`.
- Listing `TIMEOUT` in `retry.on` fails with `INVALID_ARGUMENT` unless the step's remote command is idempotent (read commands). A timed-out write may already have been applied, so a retry would double-apply it.
- A `TIMEOUT` step failure reports `error.details.may_have_executed` (`true` / `false` / `null` when unknown). Inspect the set with a read command before resending anything other than `false`.
- A retrying step carries one `meta.idempotency_key` across every attempt, so the Remote Script replays its stored response instead of applying the command twice. Adding `TIMEOUT` to `retry.on` is therefore safe for write commands too.
- A replayed response is marked with `idempotent_replay: true` in `result` (or in `error.details` for a replayed failure).
- A `TIMEOUT` step failure that is not retried reports `error.details.may_have_executed` (`true` / `false` / `null` when unknown). Inspect the set with a read command before resending anything other than `false`.
- Protocol version 3 is required. On `PROTOCOL_VERSION_MISMATCH`, run `uv run ableton-cli install-remote-script --yes`, restart Ableton Live, and re-select `AbletonCliRemote`.
- Destructive master device deletion requires `--yes`. Live API unsupported operations fail explicitly with `not_supported_by_live_api`.
- Standard wrapper commands (`synth <type> ...`, `effect <type> ...`) are strict and intentionally fail if required parameter names are missing.
- If you get `Missing required standard ... keys`, use generic commands instead:
Expand Down Expand Up @@ -464,7 +466,7 @@ uv run ableton-cli --output json ping
"result": {
"host": "127.0.0.1",
"port": 8765,
"protocol_version": 2,
"protocol_version": 3,
"remote_script_version": "0.5.0",
"rtt_ms": 2.31
},
Expand Down
14 changes: 11 additions & 3 deletions src/ableton_cli/client/_client_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ def _build_backend(
return RecordingClient(settings, path=record_path)
return LiveBackendClient(settings)

def _dispatch(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
def _dispatch(
self,
name: str,
args: dict[str, Any],
*,
idempotency_key: str | None = None,
) -> dict[str, Any]:
if self.read_only and name not in self._read_only_commands:
raise AppError(
error_code=ErrorCode.READ_ONLY_VIOLATION,
Expand All @@ -59,7 +65,7 @@ def _dispatch(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
exit_code=ExitCode.EXECUTION_FAILED,
details={"command": name},
)
return self._backend.dispatch(name, args)
return self._backend.dispatch(name, args, idempotency_key=idempotency_key)

def _call(self, name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
payload = {} if args is None else dict(args)
Expand All @@ -69,9 +75,11 @@ def execute_remote_command(
self,
name: str,
args: dict[str, Any] | None = None,
*,
idempotency_key: str | None = None,
) -> dict[str, Any]:
payload = {} if args is None else dict(args)
return self._dispatch(name, payload)
return self._dispatch(name, payload, idempotency_key=idempotency_key)

@staticmethod
def _add_if_not_none(args: dict[str, Any], key: str, value: Any) -> None:
Expand Down
Loading
Loading