Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
00516af
feat: rebuild a paused turn from its own snapshot
chiragjn Oct 15, 2026
cd79652
feat: offload a paused turn and steal it when the owner is gone
chiragjn Oct 15, 2026
c3f9816
Regenerate OpenAPI document and SDKs
trueforge-dev-bot[bot] Oct 15, 2026
6b589c7
fix: emit turn.done when the next turn supersedes a live run
chiragjn Oct 15, 2026
1839012
Merge branch 'ha/emit-pause' of https://github.com/truefoundry/truefo…
chiragjn Oct 15, 2026
9c564d7
fix: drop the peer inline-skill overlay
chiragjn Oct 16, 2026
5a4ce8d
feat: keep a paused turn in memory for five minutes
chiragjn Oct 16, 2026
a8183b2
Merge branch 'ha/emit-pause' of https://github.com/truefoundry/truefo…
chiragjn Oct 19, 2026
64e7d3d
fix: send the caller request context when peering a paused turn
chiragjn Oct 19, 2026
39160bd
fix: offload paused turns that the keepalive timer could not see
chiragjn Oct 19, 2026
66149c4
fix: require the peer router and session store on turn start
chiragjn Oct 20, 2026
21ac55a
Refactor resolvers
chiragjn Oct 20, 2026
6c35586
Minor rename in inMemory store
chiragjn Oct 20, 2026
9e95d75
refactor: execute turns from stored status instead of a resume flag
chiragjn Oct 21, 2026
9b4ca95
Address some review comments
chiragjn Oct 21, 2026
e4e6ede
Don't cancel during create turn, just freeze
chiragjn Oct 21, 2026
8eaef04
Do not use send to queue up pending events
chiragjn Oct 21, 2026
86224d9
Address more review comments
chiragjn Oct 21, 2026
ac7ccfa
Address more review comments
chiragjn Oct 21, 2026
c6389ef
Run the execution timer only during running phase
chiragjn Oct 21, 2026
8863eb5
Address race conditions pointed by cursor
chiragjn Oct 21, 2026
902799a
Address more race condition with heartbeat gone
chiragjn Oct 21, 2026
6899475
Merge branch 'ha/emit-pause' of https://github.com/truefoundry/truefo…
chiragjn Oct 21, 2026
6cda749
Add logs
chiragjn Oct 21, 2026
b876cc3
Fix approval policy across turns
chiragjn Oct 21, 2026
0b4bb91
Simplify logging and reduce diff in api/sessions.ts
chiragjn Oct 21, 2026
485d552
refactor activeTurns.ts for rebuilding and live phase
chiragjn Oct 21, 2026
b74dbb6
Add comments to activeTurn.ts
chiragjn Oct 21, 2026
853dcd5
Simplify code for stealing decision
chiragjn Oct 21, 2026
867ea89
Simplify code in turn.ts
chiragjn Oct 22, 2026
f9bf11f
Regenerate OpenAPI document and SDKs
trueforge-dev-bot[bot] Oct 22, 2026
001d09f
update error messages
chiragjn Oct 22, 2026
cb4649c
Merge branch 'cj-turn-resume' of https://github.com/truefoundry/truef…
chiragjn Oct 22, 2026
dcfd21f
Log each turn-send decision and reject a running turn the executor di…
chiragjn Oct 23, 2026
ad55dc0
Regenerate OpenAPI document and SDKs
trueforge-dev-bot[bot] Oct 23, 2026
fcaa006
Fix tests
chiragjn Oct 23, 2026
4629532
Merge branch 'cj-turn-resume' of https://github.com/truefoundry/truef…
chiragjn Oct 23, 2026
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
6 changes: 6 additions & 0 deletions .changeset/turn-offload-steal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@truefoundry/trueforge': patch
'@truefoundry/trueforge-core': patch
---

Rebuild a paused turn on its owner, offload it after a keepalive, and steal it only when the owner's heartbeat is gone.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset added on stacked PR

Low Severity

This stacked 5/N change adds a .changeset file. Changesets belong only on a merge to main; stacked or non-main bases should omit them so version bumps stay on the release path.

Fix in Cursor Fix in Web

Triggered by learned rule: Prereleases use rc tags only

Reviewed by Cursor Bugbot for commit c3f9816. Configure here.

12 changes: 11 additions & 1 deletion .github/fern/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10055,7 +10055,17 @@
}
}
},
"description": "Turn is terminal, or an event id already exists."
"description": "Turn is terminal, running and not accepting events, or an event id already exists."
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RequestErrorResponse"
}
}
},
"description": "The executor ran the request and failed while applying the events."
Comment thread
chiragjn marked this conversation as resolved.
}
},
"summary": "Create turn events",
Expand Down
12 changes: 11 additions & 1 deletion docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10055,7 +10055,17 @@
}
}
},
"description": "Turn is terminal, or an event id already exists."
"description": "Turn is terminal, running and not accepting events, or an event id already exists."
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RequestErrorResponse"
}
}
},
"description": "The executor ran the request and failed while applying the events."
}
},
"summary": "Create turn events",
Expand Down
92 changes: 43 additions & 49 deletions packages/assistant-ui-runtime/src/convertTurnMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,58 +653,52 @@ async function resolveSessionTip(options: {
sessionId: string;
itemsAsc: readonly SessionEventItem[];
}): Promise<SessionTip> {
const { server, sessionId, itemsAsc } = options;
const open = findOpenTurnCreated(itemsAsc);
if (open != null) {
const continuationInput = open.event.input ?? [];
if (typeof server.getTurn === 'function') {
try {
const turn = await server.getTurn({
sessionId,
turnId: open.turnId,
});
if (turn.state.status === TURN_STATUS.RUNNING || turn.state.status === TURN_STATUS.PAUSED) {
return {
runningTurn:
open.update?.state.status === TURN_STATUS.PAUSED && turn.state.status === TURN_STATUS.RUNNING
? turnFromCreatedEvent({
sessionId,
turnId: open.turnId,
event: open.event,
update: open.update,
})
: turn,
...(open.sandboxId != null ? { sandboxId: open.sandboxId } : {}),
continuationInput: turn.input ?? continuationInput,
};
}
// Tip finished between listEvents and getTurn: nothing to
// resume, but its answers still belong in the fold, and the turn
// itself must be committed so the painted user message stays.
return { finishedTurn: turn, continuationInput: turn.input ?? continuationInput };
} catch {
// getTurn failed; synthesize from the open turn.created below.
}
}
return {
runningTurn: turnFromCreatedEvent({
const open = findOpenTurnCreated(options.itemsAsc);
if (open == null) {
return { continuationInput: [] };
}

const { server, sessionId } = options;
const continuationInput = open.event.input ?? [];
if (typeof server.getTurn === 'function') {
try {
const turn = await server.getTurn({
sessionId,
turnId: open.turnId,
event: open.event,
...(open.update != null ? { update: open.update } : {}),
}),
...(open.sandboxId != null ? { sandboxId: open.sandboxId } : {}),
continuationInput,
};
});
if (turn.state.status === TURN_STATUS.RUNNING || turn.state.status === TURN_STATUS.PAUSED) {
return {
runningTurn:
open.update?.state.status === TURN_STATUS.PAUSED && turn.state.status === TURN_STATUS.RUNNING
? turnFromCreatedEvent({
sessionId,
turnId: open.turnId,
event: open.event,
update: open.update,
})
: turn,
...(open.sandboxId != null ? { sandboxId: open.sandboxId } : {}),
continuationInput: turn.input ?? continuationInput,
};
}
// Tip finished between listEvents and getTurn: nothing to
// resume, but its answers still belong in the fold, and the turn
// itself must be committed so the painted user message stays.
return { finishedTurn: turn, continuationInput: turn.input ?? continuationInput };
} catch {
// getTurn failed; synthesize from the open turn.created below.
}
}

// Hosts that exclude the running turn from listEvents still surface it as
// the first row of listTurns when that API is tip-first.
const turnsPage = await server.listTurns({ sessionId, limit: 1 });
const tip = turnsPage.data[0];
return tip?.state.status === TURN_STATUS.RUNNING || tip?.state.status === TURN_STATUS.PAUSED
? { runningTurn: tip, continuationInput: tip.input ?? [] }
: { continuationInput: [] };
return {
runningTurn: turnFromCreatedEvent({
sessionId,
turnId: open.turnId,
event: open.event,
...(open.update != null ? { update: open.update } : {}),
}),
...(open.sandboxId != null ? { sandboxId: open.sandboxId } : {}),
continuationInput,
};
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -999,8 +999,7 @@ export function useTrueForgeAgentMessages({
if (!runStreamStarted) {
// A supersede already aborted the live stream. A send that fails
// before it installs a message must not cancel the painted open tip.
const hasIncompleteStream =
snapshotRef.current.activeStream != null && snapshotRef.current.activeStream.streamComplete !== true;
const hasIncompleteStream = isIncompleteActiveStream(snapshotRef.current);
if ('userMessage' in options && (pendingUserWasSet || hasIncompleteStream)) {
const abandoned = abandonInFlightClientTurn(commitActiveStream(snapshotRef.current));
snapshotRef.current = abandoned;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,7 @@ describe('convertTurnMessages', () => {
status: { type: 'incomplete', reason: 'cancelled' },
metadata: { custom: { cancellationReason: 'abandoned' } },
sequenceNumber: 1,
turnState: { status: 'cancelled', reason: 'abandoned', completedAt: createdAt },
},
]);
});
Expand Down
Loading
Loading