Repository navigation
createTurn cancels the in-flight turn before validating the new request — a duplicate approval submission can truncate the approved turn mid-stream #508
Description
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_requiredat13:35:29.327. - Turn N+1 (the approval turn) is created at
13:35:29.337; the approved tool executes and itstool.responseis 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 /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:
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 blockThe 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_toolswhere 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.
Activity
MachineLearning-Nerd commentedon Aug 30, 2026
Reproduced this end to end against a fresh local 0.1.4 server, with a different model provider than the original report, so it is not model specific.
Sequence:
require_approval_for_tools.user.tool_approvalinput). The approval turn starts, the gated tool executes, and itstool.responsestreams back.Result:
The duplicate request is correctly rejected with 422 and no new turn is created (the session still has exactly 2 turns):
The running approval turn is cancelled anyway, timestamped 2ms after the duplicate arrived, before the model produced any post-tool output:
So a request the server itself rejects as invalid still destroys the in-flight turn. The gated tool's side effect had already executed by then; everything after it (the model's message relaying the result, any follow-up tool calls) is lost.
repro script (Node, uses @truefoundry/trueforge-sdk)
The cause matches what the issue describes:
SessionHandle.createTurnfreezes the previous turn withcancelled-for-next-turnbefore the new request's input is validated, and there is no rollback when validation then fails.debajyoti-truefoundry commentedon Aug 31, 2026
@MachineLearning-Nerd
Is this something that is possible from the TrueForge UI today?
MachineLearning-Nerd commentedon Aug 31, 2026
Yes. This can happen from the bundled TrueForge UI today, although it requires a timing race such as clicking Approve twice. In @truefoundry/trueforge-ui@0.2.4, the approval controls remain enabled while the approval turn is still running. A second click can submit the same
user.tool_approvalinput. The server returns HTTP 422 and creates no new turn for the duplicate, but the request can still cancel the in-flight approval turn, as demonstrated in this issue. The UI should disable the controls immediately after the first click. The server should also validate and persist the new turn before freezing the previous one.dajiaohuang commentedon Sep 4, 2026
I verified that current
mainstill freezes the previous turn before validating a new approval/tool-response resume. I prepared a tested fork-only Draft PR at dajiaohuang#1. It adds a read-only running-turn guard, preserves user-message barge-in behavior, and includes regression coverage.Validation passed for the focused session suite (12 tests), core typecheck, Prettier, and ESLint. The full core suite had 403 passing tests and one unrelated pre-existing Windows path-separator assertion failure in
tests/core/sandbox/Sandbox.paths.test.ts.Because the contribution policy requires maintainer approval before community code contributions are submitted upstream, I did not open an upstream PR. If this direction fits the project, please grant the required invitation/approval for an upstream contribution.