ADR-0047: Commands carried out once per id¶
Status: Accepted Date: 2026-09-30 Deciders: Alex Nodeland
Context¶
Clients retry commands they never heard back about, and agents retry tool calls. ADR-0044 sent every command through one handler, reflexr.workspace.execute, which raised rejections, and let only REST and the WebSocket deduplicate by command_id: the router remembered their results itself, and MCP tools took no command_id. An MCP client retrying give_feedback, retry_run or a refiring replay_rule did it twice.
artifactr made the same choice for its surfaces in its ADR-0048: one entry point, Runner.execute(workspace, command, *, command_id) -> CommandResult, which remembers results in a CommandResults port for every surface. This record makes reflexr's the same, superseding three parts of ADR-0044: its first decision's one handler, reflexr.workspace.execute; its decision that command ids deduplicate per participant over REST and the WebSocket; and its deliberate difference that command ids are REST's and the WebSocket's.
Decision¶
- One entry point:
Workspaces.execute(workspace, command, *, command_id) -> CommandResult. It carries out a command through the handle, as its actor, and never raises: a rejection is the result. REST and the WebSocket pass the frame'scommand_id, and MCP the tool's, or a new one. It replacesreflexr.workspace.execute(workspace, command)and the router's own memory of results. - A command is carried out once per id.
Workspacesremembers each result in aCommandResultsport,Workspaces(results=), keyed by aCommandKey: the tenant, the workspace, the actor'sparticipantand thecommand_id. A repeated id returns the first result, an outcome or a rejection, whatever command it comes with, and carries nothing out.InMemoryCommandResults, the default, keeps the 10,000 most recent in the process, and forgets the oldest first. The names, the key and the semantics are artifactr's. Workspacesholds the memory, as artifactr'sRunnerdoes: it is the one object per process that every surface shares. AWorkspacehandle cannot reach it, since its keys span tenants and nothing on a handle may reach another tenant.- Every MCP tool that changes something takes an optional
command_id:publish_eventandgive_feedback- the run commands,
retry_run,skip_runandcancel_run replay_rule- the stored-rule tools,
install_rule,update_ruleandarchive_rule
- An MCP call without a
command_idgets a new one, so it is carried out every time. The MCP request id cannot serve, since a retry is a new request. An emptycommand_idis refused, as REST refuses one, since a model that fills optional strings with""would otherwise get its first change's result back for every change. The server's instructions ask a client to give each change an id of its own, and the same one when it retries. - Beyond the memory, the log deduplicates what carries an id. A publish with an event
idappends once, whichever process it reaches, and a stored-rule change that would leave the rule as it is answersduplicate.
ADR-0044's other deliberate differences stand, batch publishing among them.
Options considered¶
| Option | Assessment |
|---|---|
One Workspaces.execute that returns a command_result (chosen) |
Every surface calls the same method the same way, deduplication cannot be skipped, and it is artifactr's shape |
execute, which raises, beside a router that remembers results (ADR-0044) |
Two entry points, and MCP, which calls the first, deduplicates nothing |
A free execute(workspace, command, *, command_id) that reaches the memory through the handle |
A handle that could read another tenant's results |
Consequences¶
- Easier: a client retries any change, on any surface, with the same id, and gets the first result.
- Easier: a system that uses artifactr and reflexr retries commands the same way on both.
- Harder: with the default port, deduplication is per process, and a retry that arrives while the first attempt is still being carried out runs again. A
CommandResultsover shared storage, with a claim on a command while it runs, would close both gaps. - Breaking:
reflexr.workspace.executeandreflexr_router(remembered_commands=)are gone; passWorkspaces(results=InMemoryCommandResults(capacity=...))to change how many results are remembered.Streamtakes theworkspaceswhoseexecutecarries out its commands, in place of anexecutecallback and atelemetry.
Action items¶
-
CommandResults,InMemoryCommandResultsandWorkspaces.execute, used by REST, the WebSocket and MCP. - An optional
command_idon every MCP tool that changes something. - Share command deduplication across processes.