Repository navigation
Conversation
Resume skips turn.created and replays the unconsumed inbox. A new producer reseeds its sequence from the existing stream tail. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep a paused run for a keepalive, then stop it without a terminal write. A later send rebuilds on the owner, or claims the turn only when the owner's heartbeat is missing. Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 21ac55a The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| '@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. |
There was a problem hiding this comment.
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.
Triggered by learned rule: Prereleases use rc tags only
Reviewed by Cursor Bugbot for commit c3f9816. Configure here.
A rebuilt turn starts paused so the first runnable batch persists running. Peer resume applies inline skills from the forwarded request. Creating the next turn aborts the previous run so its drain writes turn.done. Co-authored-by: Cursor <cursoragent@cursor.com>
A peer rebuild has no caller token, and the admin skill resolver is not a full skill store, so wrapping x-tfg-skills there needed a stub. Leave skills on that resolver until the header wrap is shared with models and MCP. Co-authored-by: Cursor <cursoragent@cursor.com>
Thirty seconds offloads a paused run before the next user event typically arrives. Co-authored-by: Cursor <cursoragent@cursor.com>
The owner was rebuilding model and MCP stores as the session creator with no credential. Carry the caller's context on the peer body, and let schedule dispatch pass an explicit null credential. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 4 total unresolved issues (including 2 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 64e7d3d. Configure here.
There was a problem hiding this comment.
turns.ts starts the next turn and has to cancel the previous one. Importing that from sessions.ts was a cycle, because sessions.ts already imports startTurnInProcess from turns.ts. The cancel route, the peer body schema, cancelSessionTurn, cancelSessionTurnPeerHandler, and freezeTurnIgnoringMissing all live in the new file.
cancelTurnOnThisExecutor was not kept as its own function. The peer handler now calls activeTurns.abortAndWait, and the local cancel still calls activeTurns.cancelIfRunning.
TurnHandle.state is a prototype getter, so paused runs never offloaded and shutdown cancelled them. A rebuilt turn stays paused without a new pause event, and a live send must reset the timer so it cannot abort the run before running is persisted. Co-authored-by: Cursor <cursoragent@cursor.com>
| export const NO_RESPONDER_PUBLISH_DETAIL = 'No executor received the published request'; | ||
|
|
||
| /** The target executor's heartbeat is missing or nobody is subscribed (map to HTTP 412). */ | ||
| export class NoResponderError extends Error { |
There was a problem hiding this comment.
wondering why we need to define these error types, doesnt redis have its error types?
| } | ||
|
|
||
| /** Freeze a non-terminal turn; missing turns are a no-op (already gone). */ | ||
| async function freezeTurnIgnoringMissing( |
There was a problem hiding this comment.
why is this needed? freezeTurn should itself ignore missing? was this not happening already?
Creating the next turn cancels a previous run that is not in this process, including a schedule run, so that path needs the session store. The turns router always registers turns/send-events. Co-authored-by: Cursor <cursoragent@cursor.com>
| throw new TurnNotRunningError(input.turn_id, turn.state); | ||
| } | ||
|
|
||
| const tracing = input.resolver.createTracing(); |
There was a problem hiding this comment.
bunch of logic here is same as createTurn, I will extract and make it common in later commits
| for (const event of rows) { | ||
| try { | ||
| for await (const batch of this.orchestrator.send([event])) { | ||
| void batch; | ||
| } | ||
| } catch (error) { | ||
| if (error instanceof InvalidAgentSendInputError) { | ||
| continue; | ||
| } | ||
| throw error; | ||
| } | ||
| } |
There was a problem hiding this comment.
Do we need to validate before sending anything?
should it be send all or nothing?
| /** | ||
| * `state` is a getter on TurnHandle's prototype, so it is not an own property. | ||
| * A test double may omit it; that run is not treated as paused. | ||
| */ | ||
| function isPausedRun(run: ActiveTurnRun | undefined): run is ActiveTurnRun { | ||
| if (!run || !('state' in run.turn)) { |
There was a problem hiding this comment.
why do we need to check 'state' in run.turn ?
| * under the turn lock: the generator stops without a terminal write. | ||
| * A later send on this owner rebuilds. `0` disables the timer. | ||
| */ | ||
| armPausedKeepalive(input: { sessionId: string; turnId: string }): void { |
There was a problem hiding this comment.
nit: change name from arm to start?
| requestHeaders?: Record<string, string> | undefined; | ||
| deps: BeginTurnExecutionDeps; | ||
| }): Promise<{ turn: TurnHandle; startDrain: () => void }> { | ||
| assertGatewayMetadataRequestHeaders(input.requestHeaders); |
There was a problem hiding this comment.
check this once, why assert?
| // Counter only. A missing stream stays at 1; log repair from the TurnRecord is later. | ||
| await turnEventStream.resumeProducer({ streamTTLSeconds: configuration.TURN_STREAM_TTL_SECONDS }); |
There was a problem hiding this comment.
restoring the stream will be another PR
heerambavi1998
left a comment
There was a problem hiding this comment.
- Server execution timeout needs to be increased
- add a metric on # of client retries for peer failures
| }); | ||
| if (!turn) { | ||
| throw new TurnNotFoundError(input.turn_id); | ||
| } |
There was a problem hiding this comment.
race condition possible here because executor ID changes.
There was a problem hiding this comment.
might not need special handling because it can happen at any point during rebuildTurn / send / execute
| return handle; | ||
| } catch (error) { | ||
| await input.resolver.close().catch((closeError: unknown) => { | ||
| input.resolver.logger.warn('TurnResourceResolver.close() failed after rebuildTurn() error', { |
There was a problem hiding this comment.
logger inside resolver?
| private readonly resolver: ITurnResourceResolver<TTurnCustom> | undefined; | ||
| private readonly abortController: AbortController; | ||
| private streamStarted = false; | ||
| /** Resume skips turn.created. Inbox replay happens before stream() is pulled. */ |
There was a problem hiding this comment.
Whether to emit turn.created or not
| orchestrator?: AgentThreadOrchestrator | undefined; | ||
| resolver?: ITurnResourceResolver<TTurnCustom> | undefined; | ||
| abortController?: AbortController | undefined; | ||
| resume?: boolean | undefined; |
There was a problem hiding this comment.
might be better to separate out getTurn ?
| if (!this.orchestrator) { | ||
| throw new Error(`TurnHandle.${method}() is only available on a live turn from SessionHandle.createTurn()`); | ||
| throw new Error( | ||
| `TurnHandle.send() is only available on a live turn. Perhaps this turn handle was created for read only actions?`, | ||
| ); |
There was a problem hiding this comment.
lets separate it out and keep orchestrator mandatory?
| message: z.string(), | ||
| }); | ||
|
|
||
| function isJsonValue(value: unknown): value is JSONValue { |
|
|
||
| const MAX_OWNERSHIP_ATTEMPTS = 4; | ||
|
|
||
| const PeerRequestContextSchema = z.object({ |
There was a problem hiding this comment.
we should keep all peer request schemas + API routes together
| if (!ownedByThisExecutor) { | ||
| await input.activeTurns.dropLocalTurn({ sessionId, turnId: input.turnId }); | ||
| } |
There was a problem hiding this comment.
lets add the scenario where this can happen
| */ | ||
| resume: () => Promise<{ turn: TurnHandle; startDrain: () => void }>; | ||
| /** Peer delivery is owner-local. Only the replica that received the client request may steal. */ | ||
| allowSteal: boolean; |
There was a problem hiding this comment.
this should not be the responsibility of the caller?
sendEvents > check I am owner >
if yes, "rebuild optionally and run".
if not, peer. if no peer, steal with ownership
peer request > check I am owner >
if yes, "rebuild optionally and run".
if not, fail. if no peer, steal with ownership
|
|
||
| type ForwardResult = TurnDeliveryResult | { type: 'peer'; peerOutcome: PeerOutcome }; | ||
|
|
||
| async function forwardTurnEvents(input: { |
There was a problem hiding this comment.
this can be in the actual peer request API
There was a problem hiding this comment.
stealing can be one function, rebuild and run is another.
stealing is only called when actual user request is received. Peered request itself never steals


Summary
Test plan
pausedwhile the local run is gone.What is still open, and should be called out in review rather than treated as surprise bugs:
Note
High Risk
Changes distributed turn ownership, event delivery, and resume/offload semantics for paused agent turns—core session execution paths with Redis peering and new steal/retry behavior.
Overview
Distributed paused turns can be rebuilt on the owning executor, offloaded from memory after a keepalive, and resumed or stolen when events arrive—without incorrectly terminating the turn in the store.
In trueforge-core,
SessionHandle.rebuildTurn()reconstructs a paused turn from its snapshot (with inbox replay viaTurnHandle.replayUnconsumedInbox()),TurnHandlegains a resume path that skipsturn.createdand treatsturn-offloadaborts as non-terminal, andAgentThreadOrchestrator.executeacceptsresumeso execution starts from the paused state.On the server,
ActiveTurnRegistryarmsTURN_PAUSED_KEEPALIVE_SECONDStimers to offload paused runs;deliverTurnEvents(plus Redisturns/send-events) routes POST/eventslocally, forwards to the owner, rebuilds when this replica owns but has no in-memory run, or steals only when the owner heartbeat is missing. Peer publish/timeouts/412 map to 503 retry, not steal. Create-turn now cancels the previous turn locally or via peersessions/cancel(abortAndWait). Cancel logic moves toruntime/cancelTurn.ts.API/SDK: create turn events documents 503 (
ServiceUnavailableError). Store wiring is refactored throughresolvers.tsand directsandboxProviderStore/webSearchProviderStoreinjection for HTTP and peer paths.Reviewed by Cursor Bugbot for commit 6c35586. Bugbot is set up for automated code reviews on this repo. Configure here.