Version: @truefoundry/trueforge 0.1.4 (macOS, Node 24, local server via npx @truefoundry/trueforge@0.1.4 --port 8891)
Summary
SessionHandle.createTurn cancels the in-flight previous turn (cancelled-for-next-turn) before the new turn request is validated or persisted, with no rollback. Any turn-create request that subsequently fails therefore kills the running turn and leaves no successor. Combined with the approval flow — where a human approval decision is itself delivered as a new turn (user.tool_approval in TurnInputItem) — a duplicate Approve submission from the UI can cancel the very turn that is executing the approved tool, truncating it mid-stream.
What we observed
Session with an approval-gated MCP tool (require_approval_for_tools). Timeline from the persisted events:
- Turn N ends paused:
tool.approval_required at 13:35:29.327.
- Turn N+1 (the approval turn) is created at
13:35:29.337; the approved tool executes and its tool.response is persisted.
- ~2.8 s later, turn N+1 is frozen:
{ "status": "cancelled", "reason": "cancelled-for-next-turn", "completed_at": "2026-08-30T13:35:32.154Z" }
- No next turn exists in the session —
GET /turns still returns only the turns up to N+1.
So the assistant was cut off after the tool response but before producing any follow-up output (in our case: the message that relays the tool's result, a second tool call, and a sandbox step). The approved side effect had already executed; the evidence trail for it was never produced.
Root cause (from the published dist)
trueforge-core/dist/agent-session/SessionHandle.mjs, createTurn:
async createTurn(input) {
const previousTurnId = resolvePreviousTurnId(input.previous_turn_id, this.session.last_turn_id);
const previous = previousTurnId ? await this.freezeTurn({
turn_id: previousTurnId,
reason: CancellationReason.CancelledForNextTurn
}) : void 0;
// ... everything that can fail (agent spec resolution, sandbox resolution,
// turn persistence) happens after the freeze, inside the try block
The freeze is unconditional and happens first. If the rest of createTurn throws or the request is aborted, the previous turn is already cancelled and nothing replaces it.
Contributing UI factor (@truefoundry/trueforge-ui): the stock ToolApprovalBar only disables its buttons via disabled && !isDecided, and isDecided flips when the resolved status comes back over the stream. While the approval turn is executing (seconds, for a slow tool), the Approve button remains clickable; a second click submits a second user.tool_approval turn-create, which — because of the server behavior above — cancels the in-flight approval turn and then dies (duplicate/stale approval), matching the "cancelled with no successor" state we recorded.
Repro
- Register an agent with an MCP tool in
require_approval_for_tools where the tool + follow-up output takes a few seconds.
- Run a mission until the turn pauses on approval.
- Click Approve twice ~1–3 s apart (or send any second turn-create that will fail validation while the approval turn is streaming).
- Fetch the session's turns: the approval turn is
cancelled / cancelled-for-next-turn, the approved tool's side effect has executed, and no next turn exists.
Expected
- A turn-create request should not destroy the in-flight turn until the new turn is validated and guaranteed to exist (validate → persist → then freeze predecessor, or freeze with rollback).
- A duplicate approval for an already-resolved approval id should be rejected idempotently without side effects on the running turn.
- (UI) The approval bar could lock optimistically on first click.
Impact
Any approval-gated action can end up executed with its confirmation/output truncated — awkward for audit-style flows where the post-approval output is the evidence that the action happened. Related but distinct: #397 (sandbox lifecycle on cancel).
Happy to provide the full persisted event dumps for the affected session if useful.
Version:
@truefoundry/trueforge0.1.4 (macOS, Node 24, local server vianpx @truefoundry/trueforge@0.1.4 --port 8891)Summary
SessionHandle.createTurncancels the in-flight previous turn (cancelled-for-next-turn) before the new turn request is validated or persisted, with no rollback. Any turn-create request that subsequently fails therefore kills the running turn and leaves no successor. Combined with the approval flow — where a human approval decision is itself delivered as a new turn (user.tool_approvalinTurnInputItem) — a duplicate Approve submission from the UI can cancel the very turn that is executing the approved tool, truncating it mid-stream.What we observed
Session with an approval-gated MCP tool (
require_approval_for_tools). Timeline from the persisted events:tool.approval_requiredat13:35:29.327.13:35:29.337; the approved tool executes and itstool.responseis persisted.{ "status": "cancelled", "reason": "cancelled-for-next-turn", "completed_at": "2026-08-30T13:35:32.154Z" }GET /turnsstill returns only the turns up to N+1.So the assistant was cut off after the tool response but before producing any follow-up output (in our case: the message that relays the tool's result, a second tool call, and a sandbox step). The approved side effect had already executed; the evidence trail for it was never produced.
Root cause (from the published dist)
trueforge-core/dist/agent-session/SessionHandle.mjs,createTurn:The freeze is unconditional and happens first. If the rest of
createTurnthrows or the request is aborted, the previous turn is already cancelled and nothing replaces it.Contributing UI factor (
@truefoundry/trueforge-ui): the stockToolApprovalBaronly disables its buttons viadisabled && !isDecided, andisDecidedflips when the resolved status comes back over the stream. While the approval turn is executing (seconds, for a slow tool), the Approve button remains clickable; a second click submits a seconduser.tool_approvalturn-create, which — because of the server behavior above — cancels the in-flight approval turn and then dies (duplicate/stale approval), matching the "cancelled with no successor" state we recorded.Repro
require_approval_for_toolswhere the tool + follow-up output takes a few seconds.cancelled / cancelled-for-next-turn, the approved tool's side effect has executed, and no next turn exists.Expected
Impact
Any approval-gated action can end up executed with its confirmation/output truncated — awkward for audit-style flows where the post-approval output is the evidence that the action happened. Related but distinct: #397 (sandbox lifecycle on cancel).
Happy to provide the full persisted event dumps for the affected session if useful.