Summary
OpenWorker currently decides whether a persisted tool call has completed by looking for a role="tool" message with the same tool_call_id.
There is a crash window where:
- a side-effecting tool is approved;
- the external system successfully applies the side effect (for example, an email is sent);
- OpenWorker crashes before the corresponding tool result is durably checkpointed;
- durable resume sees the tool call as unanswered;
- the already-resolved approval is reused; and
- the tool executes again.
The current behavior is therefore at-least-once recovery for this window. That is risky because the user-facing approval model implies that one approved action should not be silently performed twice.
I reproduced this deterministically on main at 01b6f83b3927e02912dda84bb392942c13ca70d1.
Reproduction
I used a fault-injection test with a custom requires_approval=True tool that increments an external delivery counter:
- The provider emits a tool call with a fixed
tool_call_id.
- The approval is parked and persisted.
- The approval is resolved and the tool returns successfully (
deliveries == 1).
- The test injects a crash after
registry.execute(...) returns but before _record_result() can append/checkpoint the role="tool" result.
- The persisted conversation contains no tool result, while the external delivery already happened.
- The engine is dropped and durable resume runs.
- Resume reuses the resolved approval and executes the same tool call again (
deliveries == 2).
The test failed with the same duplicate delivery on the initial run and three repeated runs:
durable resume repeated an external side effect whose local result was unknown
assert ['delivered', 'delivered'] == ['delivered']
The existing durable-resume, Inbox, and standing-approval tests still pass (27 passed), so this crash window is not currently covered.
Current behavior
TurnEngine._unanswered_trailing_tool_calls() treats a call as completed only when a persisted role="tool" result exists.
InboxStore.add() reuses an Inbox item by (session_id, tool_call_id), including an already-resolved approval.
TurnEngine._execute_sync() and _record_result() leave an unavoidable gap between the external call returning and its result entering local history.
_CHECKPOINTS does not include tool_finished, so the durable window extends until iteration_end.
- The audit table does not currently record
tool_call_id, a stable execution ID, an attempt number, or a recovery decision.
There is also a persistence asymmetry: approval state is made durable before execution, while successful tool state is not made durable until later in the iteration.
Desired invariants
For approved tools with external side effects:
- A live turn and durable resume must refer to the same internal execution identity.
- A known successful execution must never call the external tool again merely because the conversation result is missing.
- An execution whose external outcome is unknown must not be silently replayed unless the connector explicitly supports a safe recovery strategy.
- The internal execution/idempotency identity must not be exposed as a model-controlled tool argument.
- Recovery decisions and attempts must be observable.
This issue does not propose that OpenWorker can unilaterally guarantee distributed exactly-once semantics across third-party systems.
Proposed direction (request for alignment)
Introduce a small durable tool-execution module, separate from TurnEngine, keyed by an internal stable execution ID derived from (session_id, tool_call_id).
A minimal persisted state model could be:
no record -> dispatched -> succeeded(result snapshot)
For tools that require durable side-effect protection:
- persist
dispatched immediately before invoking the external adapter;
- persist
succeeded plus the recoverable result immediately after it returns;
- append the normal
role="tool" conversation result afterward.
On recovery:
succeeded + missing conversation result: reconstruct the tool result from the durable execution record and do not invoke the adapter;
dispatched: the outcome is unknown:
- retry only when the tool explicitly declares an idempotent recovery policy and the same internal execution ID can be propagated to the adapter;
- reconcile first when a connector explicitly supports reconciliation;
- otherwise halt and surface
recovery_required for human review (the safe default);
- no record: execute normally for calls created under the new protocol.
Recovery policy should be explicit OpenWorker-owned metadata (for example on ToolSpec), defaulting to manual recovery for protected external side effects. It should not be inferred from category, and the first PR would not need to modify aisuite.
Legacy sessions need a conservative rule: for sessions predating the ledger, a resolved approval with no tool result and no execution record should default to recovery_required rather than execution. "No record means safe to execute" only holds once the session is known—through a persisted protocol/version marker—to run under the new execution protocol.
Suggested first PR scope
If this direction matches the project roadmap, I propose keeping the first PR limited to:
- a durable execution record for approved external side-effecting tools;
- one stable internal execution ID across live execution and resume;
- reconstruction of known successful results;
- conservative
recovery_required behavior for unknown non-idempotent outcomes;
- an explicit recovery-policy seam owned by OpenWorker;
- audit fields for
tool_call_id, execution_id, attempt, and recovery_decision;
- deterministic crash-injection tests.
Connector-specific idempotency and reconciliation implementations can follow separately. Low-risk read-only tools and the existing parallel execution path should remain unchanged.
Fault-injection matrix
| Crash point |
Durable state |
Expected recovery |
| Before the dispatch record |
No record |
Execute once only when the session is known to use the new protocol; otherwise require recovery review |
After dispatched, before/while the external call returns |
dispatched |
Do not silently replay; apply the declared recovery policy |
After external success, before succeeded is stored |
dispatched |
Same unknown-outcome policy; no blind retry |
After succeeded, before role="tool"/iteration_end |
succeeded |
Reconstruct the result; do not call the external system |
| After the normal tool result checkpoint |
Completed |
Preserve the current no-replay behavior |
Related work
This issue is different from pairing repair: a missing tool result may mean "waiting for approval", "never dispatched", or "external side effect may already have succeeded". A durable execution record could provide the missing distinction; a generic placeholder alone cannot identify the external outcome.
Questions for maintainers
- Does a small durable execution record fit the intended architecture, or would you prefer a smaller conservative no-replay fix first?
- Should the first PR stop at
recovery_required for unknown outcomes, leaving connector idempotency/reconciliation for follow-ups?
- Is an OpenWorker-owned recovery policy on
ToolSpec the right seam, rather than extending aisuite metadata?
- What user-facing recovery flow would you prefer for an unknown outcome: an Inbox item, a turn-ending event/notice, or another mechanism?
- Is the proposed conservative legacy default—
recovery_required for resolved approvals without a tool result or execution record—acceptable?
I am happy to prepare the fault-injection test and a focused PR after aligning on the recovery semantics and scope.
Summary
OpenWorker currently decides whether a persisted tool call has completed by looking for a
role="tool"message with the sametool_call_id.There is a crash window where:
The current behavior is therefore at-least-once recovery for this window. That is risky because the user-facing approval model implies that one approved action should not be silently performed twice.
I reproduced this deterministically on
mainat01b6f83b3927e02912dda84bb392942c13ca70d1.Reproduction
I used a fault-injection test with a custom
requires_approval=Truetool that increments an external delivery counter:tool_call_id.deliveries == 1).registry.execute(...)returns but before_record_result()can append/checkpoint therole="tool"result.deliveries == 2).The test failed with the same duplicate delivery on the initial run and three repeated runs:
The existing durable-resume, Inbox, and standing-approval tests still pass (
27 passed), so this crash window is not currently covered.Current behavior
TurnEngine._unanswered_trailing_tool_calls()treats a call as completed only when a persistedrole="tool"result exists.InboxStore.add()reuses an Inbox item by(session_id, tool_call_id), including an already-resolved approval.TurnEngine._execute_sync()and_record_result()leave an unavoidable gap between the external call returning and its result entering local history._CHECKPOINTSdoes not includetool_finished, so the durable window extends untiliteration_end.tool_call_id, a stable execution ID, an attempt number, or a recovery decision.There is also a persistence asymmetry: approval state is made durable before execution, while successful tool state is not made durable until later in the iteration.
Desired invariants
For approved tools with external side effects:
This issue does not propose that OpenWorker can unilaterally guarantee distributed exactly-once semantics across third-party systems.
Proposed direction (request for alignment)
Introduce a small durable tool-execution module, separate from
TurnEngine, keyed by an internal stable execution ID derived from(session_id, tool_call_id).A minimal persisted state model could be:
For tools that require durable side-effect protection:
dispatchedimmediately before invoking the external adapter;succeededplus the recoverable result immediately after it returns;role="tool"conversation result afterward.On recovery:
succeeded+ missing conversation result: reconstruct the tool result from the durable execution record and do not invoke the adapter;dispatched: the outcome is unknown:recovery_requiredfor human review (the safe default);Recovery policy should be explicit OpenWorker-owned metadata (for example on
ToolSpec), defaulting to manual recovery for protected external side effects. It should not be inferred fromcategory, and the first PR would not need to modifyaisuite.Legacy sessions need a conservative rule: for sessions predating the ledger, a resolved approval with no tool result and no execution record should default to
recovery_requiredrather than execution. "No record means safe to execute" only holds once the session is known—through a persisted protocol/version marker—to run under the new execution protocol.Suggested first PR scope
If this direction matches the project roadmap, I propose keeping the first PR limited to:
recovery_requiredbehavior for unknown non-idempotent outcomes;tool_call_id,execution_id,attempt, andrecovery_decision;Connector-specific idempotency and reconciliation implementations can follow separately. Low-risk read-only tools and the existing parallel execution path should remain unchanged.
Fault-injection matrix
dispatched, before/while the external call returnsdispatchedsucceededis storeddispatchedsucceeded, beforerole="tool"/iteration_endsucceededRelated work
This issue is different from pairing repair: a missing tool result may mean "waiting for approval", "never dispatched", or "external side effect may already have succeeded". A durable execution record could provide the missing distinction; a generic placeholder alone cannot identify the external outcome.
Questions for maintainers
recovery_requiredfor unknown outcomes, leaving connector idempotency/reconciliation for follow-ups?ToolSpecthe right seam, rather than extendingaisuitemetadata?recovery_requiredfor resolved approvals without a tool result or execution record—acceptable?I am happy to prepare the fault-injection test and a focused PR after aligning on the recovery semantics and scope.