From 67ec4d2c2050fee3e697fba5b968c130e4289270 Mon Sep 17 00:00:00 2001 From: thesujai Date: Mon, 19 Oct 2026 20:12:20 +0530 Subject: [PATCH 1/3] feat: implement zombie turn management with internal API endpoints --- ...61019143019-regenerate-sdk-from-openapi.md | 5 + .changeset/zombie-turn-closer.md | 6 + .github/fern/openapi/openapi.json | 154 ++++++++++ .../templates/controller-deployment.yaml | 2 +- charts/trueforge/templates/deployment.yaml | 2 +- charts/trueforge/values.yaml | 3 + docs/openapi.json | 154 ++++++++++ .../trueforge-core/src/agent-session/index.ts | 2 + .../src/agent-session/schemas/turn.ts | 2 +- .../src/agent-session/store/ISessionStore.ts | 19 ++ .../store/InMemorySessionStore.ts | 43 +++ .../agent-session/store/storeContractSuite.ts | 68 +++++ packages/trueforge-sdk/package.json | 11 + packages/trueforge-sdk/reference.md | 123 ++++++++ .../api/resources/internal/client/Client.ts | 6 + .../api/resources/internal/resources/index.ts | 2 + .../internal/resources/turns/client/Client.ts | 211 ++++++++++++++ .../internal/resources/turns/client/index.ts | 1 + .../client/requests/CloseZombieTurnRequest.ts | 18 ++ .../resources/turns/client/requests/index.ts | 1 + .../internal/resources/turns/exports.ts | 4 + .../internal/resources/turns/index.ts | 1 + .../src/api/types/ListZombieTurnsResponse.ts | 7 + .../trueforge-sdk/src/api/types/ZombieTurn.ts | 10 + packages/trueforge-sdk/src/api/types/index.ts | 2 + .../resources/internal/resources/index.ts | 2 + .../internal/resources/turns/client/index.ts | 1 + .../client/requests/CloseZombieTurnRequest.ts | 22 ++ .../resources/turns/client/requests/index.ts | 1 + .../internal/resources/turns/index.ts | 1 + .../types/ListZombieTurnsResponse.ts | 19 ++ .../src/serialization/types/ZombieTurn.ts | 20 ++ .../src/serialization/types/index.ts | 2 + .../tests/wire/internal/turns.test.ts | 121 ++++++++ packages/trueforge/.env.example | 8 + packages/trueforge/src/apis/zombieTurns.ts | 63 ++++ packages/trueforge/src/app.ts | 14 + packages/trueforge/src/config.ts | 30 ++ packages/trueforge/src/controller.ts | 4 +- .../src/controller/internalTrueForgeClient.ts | 17 ++ .../src/controller/zombieTurnCloser.ts | 79 +++++ .../session-store/PostgresSessionStore.ts | 7 + .../postgres/session-store/queries/turns.ts | 38 +++ .../session-store/SqliteSessionStore.ts | 7 + .../db/sqlite/session-store/queries/turns.ts | 40 +++ .../trueforge/src/routes/zombieTurnRoutes.ts | 57 ++++ packages/trueforge/src/schemas/zombieTurn.ts | 29 ++ .../unit/controller/zombieTurnCloser.test.ts | 57 ++++ python/trueforge_sdk/reference.md | 153 ++++++++++ .../src/trueforge_sdk/__init__.py | 6 + .../src/trueforge_sdk/internal/__init__.py | 5 +- .../src/trueforge_sdk/internal/client.py | 19 ++ .../trueforge_sdk/internal/turns/__init__.py | 4 + .../trueforge_sdk/internal/turns/client.py | 199 +++++++++++++ .../internal/turns/raw_client.py | 275 ++++++++++++++++++ .../src/trueforge_sdk/types/__init__.py | 6 + .../types/list_zombie_turns_response.py | 20 ++ .../src/trueforge_sdk/types/zombie_turn.py | 32 ++ 58 files changed, 2209 insertions(+), 6 deletions(-) create mode 100644 .changeset/20261019143019-regenerate-sdk-from-openapi.md create mode 100644 .changeset/zombie-turn-closer.md create mode 100644 packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts create mode 100644 packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/index.ts create mode 100644 packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts create mode 100644 packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/index.ts create mode 100644 packages/trueforge-sdk/src/api/resources/internal/resources/turns/exports.ts create mode 100644 packages/trueforge-sdk/src/api/resources/internal/resources/turns/index.ts create mode 100644 packages/trueforge-sdk/src/api/types/ListZombieTurnsResponse.ts create mode 100644 packages/trueforge-sdk/src/api/types/ZombieTurn.ts create mode 100644 packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/index.ts create mode 100644 packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts create mode 100644 packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/index.ts create mode 100644 packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/index.ts create mode 100644 packages/trueforge-sdk/src/serialization/types/ListZombieTurnsResponse.ts create mode 100644 packages/trueforge-sdk/src/serialization/types/ZombieTurn.ts create mode 100644 packages/trueforge-sdk/tests/wire/internal/turns.test.ts create mode 100644 packages/trueforge/src/apis/zombieTurns.ts create mode 100644 packages/trueforge/src/controller/zombieTurnCloser.ts create mode 100644 packages/trueforge/src/routes/zombieTurnRoutes.ts create mode 100644 packages/trueforge/src/schemas/zombieTurn.ts create mode 100644 packages/trueforge/tests/unit/controller/zombieTurnCloser.test.ts create mode 100644 python/trueforge_sdk/src/trueforge_sdk/internal/turns/__init__.py create mode 100644 python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py create mode 100644 python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py create mode 100644 python/trueforge_sdk/src/trueforge_sdk/types/list_zombie_turns_response.py create mode 100644 python/trueforge_sdk/src/trueforge_sdk/types/zombie_turn.py diff --git a/.changeset/20261019143019-regenerate-sdk-from-openapi.md b/.changeset/20261019143019-regenerate-sdk-from-openapi.md new file mode 100644 index 000000000..efd8ff00f --- /dev/null +++ b/.changeset/20261019143019-regenerate-sdk-from-openapi.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-sdk": patch +--- + +Regenerate SDK from updated OpenAPI spec. diff --git a/.changeset/zombie-turn-closer.md b/.changeset/zombie-turn-closer.md new file mode 100644 index 000000000..7a9f56740 --- /dev/null +++ b/.changeset/zombie-turn-closer.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge-core': patch +'@truefoundry/trueforge': patch +--- + +Close stale running turns via a controller loop and internal list/close endpoints (15 minutes with no events). diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index e99bf663c..2e86e87b4 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -861,6 +861,32 @@ ], "type": "object" }, + "CloseZombieTurnRequest": { + "additionalProperties": false, + "properties": { + "session_id": { + "description": "Session that owns the turn.", + "minLength": 1, + "type": "string" + }, + "tenant_id": { + "description": "Tenant that owns the session.", + "minLength": 1, + "type": "string" + }, + "turn_id": { + "description": "Turn to close.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tenant_id", + "session_id", + "turn_id" + ], + "type": "object" + }, "CompactionConfig": { "additionalProperties": false, "default": { @@ -2235,6 +2261,21 @@ ], "type": "object" }, + "ListZombieTurnsResponse": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ZombieTurn" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "MCPAuthRequiredEvent": { "allOf": [ { @@ -6210,6 +6251,32 @@ "type" ], "type": "object" + }, + "ZombieTurn": { + "additionalProperties": false, + "properties": { + "session_id": { + "description": "Session that owns the turn.", + "minLength": 1, + "type": "string" + }, + "tenant_id": { + "description": "Tenant that owns the session.", + "minLength": 1, + "type": "string" + }, + "turn_id": { + "description": "Running turn with stale last activity.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tenant_id", + "session_id", + "turn_id" + ], + "type": "object" } }, "securitySchemes": { @@ -6700,6 +6767,93 @@ "x-fern-sdk-method-name": "get_or_create_by_external_id" } }, + "/api/internal/turns/close-zombie": { + "post": { + "description": "Cancels a zombie turn (turn.done with abandoned reason).", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloseZombieTurnRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Closed (or already terminal / missing turn)." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid service credential." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Session not found." + } + }, + "summary": "Close a stale running turn", + "tags": [ + "Internal" + ], + "x-excluded": true, + "x-fern-sdk-group-name": [ + "internal", + "turns" + ], + "x-fern-sdk-method-name": "close_zombie" + } + }, + "/api/internal/turns/zombie": { + "get": { + "description": "Returns all running turns with no events for 15+ minutes.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListZombieTurnsResponse" + } + } + }, + "description": "Stale running turns oldest-first." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid service credential." + } + }, + "summary": "List stale running turns", + "tags": [ + "Internal" + ], + "x-excluded": true, + "x-fern-sdk-group-name": [ + "internal", + "turns" + ], + "x-fern-sdk-method-name": "list_zombie" + } + }, "/api/v1/agents": { "get": { "description": "List configured agents for the tenant, ordered by name. Optional `agent_name` filters by substring.", diff --git a/charts/trueforge/templates/controller-deployment.yaml b/charts/trueforge/templates/controller-deployment.yaml index 50238c894..56c40bee6 100644 --- a/charts/trueforge/templates/controller-deployment.yaml +++ b/charts/trueforge/templates/controller-deployment.yaml @@ -44,7 +44,7 @@ spec: priorityClassName: {{ . | quote }} {{- end }} # Above the controller's GRACEFUL_TIMEOUT_SECONDS so SIGKILL does not cut off loop drain. - terminationGracePeriodSeconds: {{ add .Values.server.gracefulTimeoutSeconds 5 }} + terminationGracePeriodSeconds: {{ .Values.server.terminationGracePeriodSeconds }} {{- with (include "trueforge.customCA.initContainer" .) }} initContainers: {{- . | nindent 8 }} diff --git a/charts/trueforge/templates/deployment.yaml b/charts/trueforge/templates/deployment.yaml index fa3fbfcf8..052bd2b6b 100644 --- a/charts/trueforge/templates/deployment.yaml +++ b/charts/trueforge/templates/deployment.yaml @@ -45,7 +45,7 @@ spec: priorityClassName: {{ . | quote }} {{- end }} # Above the server's GRACEFUL_TIMEOUT_SECONDS so SIGKILL does not cut off turn drain. - terminationGracePeriodSeconds: {{ add .Values.server.gracefulTimeoutSeconds 5 }} + terminationGracePeriodSeconds: {{ .Values.server.terminationGracePeriodSeconds }} {{- $caInit := include "trueforge.customCA.initContainer" . }} {{- if or $caInit .Values.initContainers }} initContainers: diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index 386e2cd7c..f16609032 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -120,6 +120,9 @@ server: publicBaseUrl: "" # Seconds the server is given to drain turns on SIGTERM (GRACEFUL_TIMEOUT_SECONDS). gracefulTimeoutSeconds: 30 + # Seconds Kubernetes waits after SIGTERM before SIGKILL. Must exceed + # gracefulTimeoutSeconds so the process can finish draining. + terminationGracePeriodSeconds: 300 # The server always runs peered (STANDALONE=false) against Postgres + Redis. # Escape hatch for knobs not covered by configs.*. Each entry is a full env # item: {name, value} or {name, valueFrom: {secretKeyRef|configMapKeyRef: ...}}. diff --git a/docs/openapi.json b/docs/openapi.json index e99bf663c..2e86e87b4 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -861,6 +861,32 @@ ], "type": "object" }, + "CloseZombieTurnRequest": { + "additionalProperties": false, + "properties": { + "session_id": { + "description": "Session that owns the turn.", + "minLength": 1, + "type": "string" + }, + "tenant_id": { + "description": "Tenant that owns the session.", + "minLength": 1, + "type": "string" + }, + "turn_id": { + "description": "Turn to close.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tenant_id", + "session_id", + "turn_id" + ], + "type": "object" + }, "CompactionConfig": { "additionalProperties": false, "default": { @@ -2235,6 +2261,21 @@ ], "type": "object" }, + "ListZombieTurnsResponse": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ZombieTurn" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "MCPAuthRequiredEvent": { "allOf": [ { @@ -6210,6 +6251,32 @@ "type" ], "type": "object" + }, + "ZombieTurn": { + "additionalProperties": false, + "properties": { + "session_id": { + "description": "Session that owns the turn.", + "minLength": 1, + "type": "string" + }, + "tenant_id": { + "description": "Tenant that owns the session.", + "minLength": 1, + "type": "string" + }, + "turn_id": { + "description": "Running turn with stale last activity.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tenant_id", + "session_id", + "turn_id" + ], + "type": "object" } }, "securitySchemes": { @@ -6700,6 +6767,93 @@ "x-fern-sdk-method-name": "get_or_create_by_external_id" } }, + "/api/internal/turns/close-zombie": { + "post": { + "description": "Cancels a zombie turn (turn.done with abandoned reason).", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloseZombieTurnRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Closed (or already terminal / missing turn)." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid service credential." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Session not found." + } + }, + "summary": "Close a stale running turn", + "tags": [ + "Internal" + ], + "x-excluded": true, + "x-fern-sdk-group-name": [ + "internal", + "turns" + ], + "x-fern-sdk-method-name": "close_zombie" + } + }, + "/api/internal/turns/zombie": { + "get": { + "description": "Returns all running turns with no events for 15+ minutes.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListZombieTurnsResponse" + } + } + }, + "description": "Stale running turns oldest-first." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid service credential." + } + }, + "summary": "List stale running turns", + "tags": [ + "Internal" + ], + "x-excluded": true, + "x-fern-sdk-group-name": [ + "internal", + "turns" + ], + "x-fern-sdk-method-name": "list_zombie" + } + }, "/api/v1/agents": { "get": { "description": "List configured agents for the tenant, ordered by name. Optional `agent_name` filters by substring.", diff --git a/packages/trueforge-core/src/agent-session/index.ts b/packages/trueforge-core/src/agent-session/index.ts index 1d05a2160..f373097d7 100644 --- a/packages/trueforge-core/src/agent-session/index.ts +++ b/packages/trueforge-core/src/agent-session/index.ts @@ -95,6 +95,7 @@ export type { InsertTurnInboundEventsInput, ListSessionEventsInput, ListSessionsInput, + ListStaleRunningTurnsInput, ListTurnEventsInput, ListTurnsInput, NewThreadInit, @@ -103,6 +104,7 @@ export type { PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, + StaleRunningTurnRef, TurnContextAppend, TurnRecordWithoutSnapshot, UpdateSessionInput, diff --git a/packages/trueforge-core/src/agent-session/schemas/turn.ts b/packages/trueforge-core/src/agent-session/schemas/turn.ts index 700e44787..55911505e 100644 --- a/packages/trueforge-core/src/agent-session/schemas/turn.ts +++ b/packages/trueforge-core/src/agent-session/schemas/turn.ts @@ -18,7 +18,7 @@ export enum CancellationReason { ClientCancelled = 'client-cancelled', // Prior turn aborted because the client started a new turn. CancelledForNextTurn = 'cancelled-for-next-turn', - // Process shutting down (SIGTERM/SIGINT). + // Process shutting down (SIGTERM/SIGINT), or controller closed a zombie turn. Abandoned = 'abandoned', } diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index bdf8bed50..c0b289001 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -160,6 +160,18 @@ export interface ListTurnsInput { page_token: string | undefined; } +/** Input for listing running turns whose last activity is older than a cutoff. */ +export interface ListStaleRunningTurnsInput { + older_than: Date; +} + +/** Session/turn id pair for a stale running turn (tenant needed to load the session). */ +export interface StaleRunningTurnRef { + tenant_id: string; + session_id: string; + turn_id: string; +} + interface TurnStateUpdateKeys { session_id: string; turn_id: string; @@ -371,6 +383,13 @@ export interface ISessionStore< input: ListTurnsInput, ): Promise<{ data: TurnRecordWithoutSnapshot[]; pagination: TokenPagination }>; + /** + * All `running` turns whose last activity is strictly before `older_than`. + * Last activity is `MAX(session_event.created_at)`, or `turn.created_at` when + * the turn has no events. Oldest-first; no limit (caller batches). + */ + listStaleRunningTurns(input: ListStaleRunningTurnsInput): Promise; + /** Atomically performs `running ↔ paused` and appends turn.update. */ updateTurnNonTerminalState(input: UpdateTurnNonTerminalStateInput): Promise; diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index 4f11e3e8f..a7fa3da83 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -22,6 +22,7 @@ import type { ISessionStore, ListSessionEventsInput, ListSessionsInput, + ListStaleRunningTurnsInput, ListTurnEventsInput, ListTurnsInput, NewThreadInit, @@ -30,6 +31,7 @@ import type { PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, + StaleRunningTurnRef, TurnContextAppend, TurnRecordWithoutSnapshot, UpdateSessionInput, @@ -479,6 +481,47 @@ export class InMemorySessionStore< return paginate(records, input.limit, input.page_token); } + async listStaleRunningTurns(input: ListStaleRunningTurnsInput): Promise { + const cutoffMs = input.older_than.getTime(); + const candidates: { + tenant_id: string; + session_id: string; + turn_id: string; + last_activity_ms: number; + }[] = []; + for (const turn of this.turns.values()) { + if (turn.state.status !== 'running') { + continue; + } + const session = this.sessions.get(sessionKey(turn.session_id)); + if (!session) { + continue; + } + const list = this.events.get(turnKey({ session_id: turn.session_id, turn_id: turn.turn_id })) ?? []; + const lastActivityMs = + list.length === 0 ? turn.created_at.getTime() : Math.max(...list.map(event => Date.parse(event.created_at))); + if (lastActivityMs < cutoffMs) { + candidates.push({ + tenant_id: session.record.tenant_id, + session_id: turn.session_id, + turn_id: turn.turn_id, + last_activity_ms: lastActivityMs, + }); + } + } + candidates.sort((a, b) => { + if (a.last_activity_ms !== b.last_activity_ms) { + return a.last_activity_ms - b.last_activity_ms; + } + const bySession = compareSessionId(a.session_id, b.session_id); + if (bySession !== 0) { + return bySession; + } + return a.turn_id < b.turn_id ? -1 : a.turn_id > b.turn_id ? 1 : 0; + }); + return candidates.map(({ tenant_id, session_id, turn_id }) => ({ tenant_id, session_id, turn_id })); + } + async updateTurnNonTerminalState(input: UpdateTurnNonTerminalStateInput): Promise { // Same as createTurn: synchronous body ⇒ atomic under run-to-completion. const tKey = turnKey(input); diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index 48532d783..ac1e555d5 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -3281,6 +3281,74 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { expect(page2.data.map(t => t.turn_id)).toEqual(['t3']); }); + it('listStaleRunningTurns returns only running turns with stale last event', async () => { + const store = createStore(); + await seedSession(store); + const olderThan = new Date('2020-01-02T00:00:00.000Z'); + const staleEventAt = '2020-01-01T00:00:00.000Z'; + const freshEventAt = '2020-01-03T00:00:00.000Z'; + + await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'stale-running' })); + await store.appendToEvents({ + session_id: sessionId, + turn_id: 'stale-running', + events: [{ ...makeTurnCreatedEvent('stale-running'), created_at: staleEventAt }], + }); + + await store.createTurn( + makeCreateTurnInput({ + sessionId, + turnId: 'fresh-running', + previousTurnId: null, + firstTurnId: 'fresh-running', + }), + ); + await store.appendToEvents({ + session_id: sessionId, + turn_id: 'fresh-running', + events: [{ ...makeTurnCreatedEvent('fresh-running'), created_at: freshEventAt }], + }); + + await store.createTurn( + makeCreateTurnInput({ + sessionId, + turnId: 'stale-paused', + previousTurnId: null, + firstTurnId: 'stale-paused', + }), + ); + await store.appendToEvents({ + session_id: sessionId, + turn_id: 'stale-paused', + events: [{ ...makeTurnCreatedEvent('stale-paused'), created_at: staleEventAt }], + }); + const pausedState = makePausedTurnState(); + await store.updateTurnNonTerminalState({ + session_id: sessionId, + turn_id: 'stale-paused', + state: pausedState, + turn_update_event: makeTurnUpdateEvent(pausedState), + }); + + await store.createTurn( + makeCreateTurnInput({ + sessionId, + turnId: 'stale-done', + previousTurnId: null, + firstTurnId: 'stale-done', + }), + ); + await store.appendToEvents({ + session_id: sessionId, + turn_id: 'stale-done', + events: [{ ...makeTurnCreatedEvent('stale-done'), created_at: staleEventAt }], + }); + await finishTurn(store, 'stale-done'); + + const stale = await store.listStaleRunningTurns({ older_than: olderThan }); + expect(stale).toEqual([{ tenant_id: tenant, session_id: sessionId, turn_id: 'stale-running' }]); + }); + it('listTurnEvents supports desc order', async () => { const store = createStore(); await seedSession(store); diff --git a/packages/trueforge-sdk/package.json b/packages/trueforge-sdk/package.json index 5b75d6f84..9cf5e4d93 100644 --- a/packages/trueforge-sdk/package.json +++ b/packages/trueforge-sdk/package.json @@ -253,6 +253,17 @@ }, "default": "./dist/cjs/api/resources/internal/resources/sessions/exports.js" }, + "./internal/turns": { + "import": { + "types": "./dist/esm/api/resources/internal/resources/turns/exports.d.mts", + "default": "./dist/esm/api/resources/internal/resources/turns/exports.mjs" + }, + "require": { + "types": "./dist/cjs/api/resources/internal/resources/turns/exports.d.ts", + "default": "./dist/cjs/api/resources/internal/resources/turns/exports.js" + }, + "default": "./dist/cjs/api/resources/internal/resources/turns/exports.js" + }, "./internal/agents": { "import": { "types": "./dist/esm/api/resources/internal/resources/agents/exports.d.mts", diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 185a73c8b..7892611fa 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -3701,6 +3701,129 @@ await client.internal.sessions.getOrCreateByExternalId({ + + + + +## Internal Turns +
client.internal.turns.closeZombie({ ...params }) -> void +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Cancels a zombie turn (turn.done with abandoned reason). +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.internal.turns.closeZombie({ + sessionId: "session_id", + tenantId: "tenant_id", + turnId: "turn_id" +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TrueForge.internal.CloseZombieTurnRequest` + +
+
+ +
+
+ +**requestOptions:** `TurnsClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ +
client.internal.turns.listZombie() -> TrueForge.ListZombieTurnsResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Returns all running turns with no events for 15+ minutes. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.internal.turns.listZombie(); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**requestOptions:** `TurnsClient.RequestOptions` + +
+
+
+
+ +
diff --git a/packages/trueforge-sdk/src/api/resources/internal/client/Client.ts b/packages/trueforge-sdk/src/api/resources/internal/client/Client.ts index a80d41722..3eac70ac4 100644 --- a/packages/trueforge-sdk/src/api/resources/internal/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/internal/client/Client.ts @@ -14,6 +14,7 @@ import { MetricsClient } from "../resources/metrics/client/Client.js"; import { SandboxEnvironmentsClient } from "../resources/sandboxEnvironments/client/Client.js"; import { SchedulesClient } from "../resources/schedules/client/Client.js"; import { SessionsClient } from "../resources/sessions/client/Client.js"; +import { TurnsClient } from "../resources/turns/client/Client.js"; export declare namespace InternalClient { export type Options = BaseClientOptions; @@ -27,6 +28,7 @@ export class InternalClient { protected _sandboxEnvironments: SandboxEnvironmentsClient | undefined; protected _schedules: SchedulesClient | undefined; protected _sessions: SessionsClient | undefined; + protected _turns: TurnsClient | undefined; protected _agents: AgentsClient | undefined; constructor(options: InternalClient.Options) { @@ -49,6 +51,10 @@ export class InternalClient { return (this._sessions ??= new SessionsClient(this._options)); } + public get turns(): TurnsClient { + return (this._turns ??= new TurnsClient(this._options)); + } + public get agents(): AgentsClient { return (this._agents ??= new AgentsClient(this._options)); } diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/index.ts index 3c1662cf9..4f7cffd1e 100644 --- a/packages/trueforge-sdk/src/api/resources/internal/resources/index.ts +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/index.ts @@ -8,3 +8,5 @@ export * from "./schedules/client/requests/index.js"; export * as schedules from "./schedules/index.js"; export * from "./sessions/client/requests/index.js"; export * as sessions from "./sessions/index.js"; +export * from "./turns/client/requests/index.js"; +export * as turns from "./turns/index.js"; diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts new file mode 100644 index 000000000..adfe1be3b --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts @@ -0,0 +1,211 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { BaseClientOptions, BaseRequestOptions } from "../../../../../../BaseClient.js"; +import { type NormalizedClientOptionsWithAuth, normalizeClientOptionsWithAuth } from "../../../../../../BaseClient.js"; +import { mergeHeaders } from "../../../../../../core/headers.js"; +import * as core from "../../../../../../core/index.js"; +import { mergeAdditionalBodyParameters } from "../../../../../../core/requestBody.js"; +import { handleNonStatusCodeError } from "../../../../../../errors/handleNonStatusCodeError.js"; +import * as errors from "../../../../../../errors/index.js"; +import * as serializers from "../../../../../../serialization/index.js"; +import * as TrueForge from "../../../../../index.js"; + +export declare namespace TurnsClient { + export type Options = BaseClientOptions; + + export interface RequestOptions extends BaseRequestOptions {} +} + +export class TurnsClient { + protected readonly _options: NormalizedClientOptionsWithAuth; + + constructor(options: TurnsClient.Options) { + this._options = normalizeClientOptionsWithAuth(options); + } + + /** + * Cancels a zombie turn (turn.done with abandoned reason). + * + * @param {TrueForge.internal.CloseZombieTurnRequest} request + * @param {TurnsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link TrueForge.UnauthorizedError} + * @throws {@link TrueForge.NotFoundError} + * @throws {@link errors.TrueForgeError} + * @throws {@link errors.TrueForgeTimeoutError} + * + * @example + * await client.internal.turns.closeZombie({ + * sessionId: "session_id", + * tenantId: "tenant_id", + * turnId: "turn_id" + * }) + */ + public closeZombie( + request: TrueForge.internal.CloseZombieTurnRequest, + requestOptions?: TurnsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__closeZombie(request, requestOptions)); + } + + private async __closeZombie( + request: TrueForge.internal.CloseZombieTurnRequest, + requestOptions?: TurnsClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "api/internal/turns/close-zombie", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + requestType: "json", + body: mergeAdditionalBodyParameters( + serializers.internal.CloseZombieTurnRequest.jsonOrThrow(request, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + omitUndefined: true, + }), + requestOptions?.additionalBodyParameters, + ), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: undefined, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 401: + throw new TrueForge.UnauthorizedError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + case 404: + throw new TrueForge.NotFoundError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + default: + throw new errors.TrueForgeError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/api/internal/turns/close-zombie", + ); + } + + /** + * Returns all running turns with no events for 15+ minutes. + * + * @param {TurnsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link TrueForge.UnauthorizedError} + * @throws {@link errors.TrueForgeError} + * @throws {@link errors.TrueForgeTimeoutError} + * + * @example + * await client.internal.turns.listZombie() + */ + public listZombie( + requestOptions?: TurnsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__listZombie(requestOptions)); + } + + private async __listZombie( + requestOptions?: TurnsClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "api/internal/turns/zombie", + ), + method: "GET", + headers: _headers, + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: serializers.ListZombieTurnsResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 401: + throw new TrueForge.UnauthorizedError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + default: + throw new errors.TrueForgeError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/api/internal/turns/zombie"); + } +} diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/index.ts new file mode 100644 index 000000000..195f9aa8a --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/index.ts @@ -0,0 +1 @@ +export * from "./requests/index.js"; diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts new file mode 100644 index 000000000..38fe9fd4c --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts @@ -0,0 +1,18 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * sessionId: "session_id", + * tenantId: "tenant_id", + * turnId: "turn_id" + * } + */ +export interface CloseZombieTurnRequest { + /** Session that owns the turn. */ + sessionId: string; + /** Tenant that owns the session. */ + tenantId: string; + /** Turn to close. */ + turnId: string; +} diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/index.ts new file mode 100644 index 000000000..5e1345aa5 --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/index.ts @@ -0,0 +1 @@ +export type { CloseZombieTurnRequest } from "./CloseZombieTurnRequest.js"; diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/exports.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/exports.ts new file mode 100644 index 000000000..530dd7d3d --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/exports.ts @@ -0,0 +1,4 @@ +// This file was auto-generated by Fern from our API Definition. + +export { TurnsClient } from "./client/Client.js"; +export * from "./client/index.js"; diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/index.ts new file mode 100644 index 000000000..914b8c3c7 --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/index.ts @@ -0,0 +1 @@ +export * from "./client/index.js"; diff --git a/packages/trueforge-sdk/src/api/types/ListZombieTurnsResponse.ts b/packages/trueforge-sdk/src/api/types/ListZombieTurnsResponse.ts new file mode 100644 index 000000000..4597d90b9 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/ListZombieTurnsResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface ListZombieTurnsResponse { + data: TrueForge.ZombieTurn[]; +} diff --git a/packages/trueforge-sdk/src/api/types/ZombieTurn.ts b/packages/trueforge-sdk/src/api/types/ZombieTurn.ts new file mode 100644 index 000000000..31a9c68ce --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/ZombieTurn.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface ZombieTurn { + /** Session that owns the turn. */ + sessionId: string; + /** Tenant that owns the session. */ + tenantId: string; + /** Running turn with stale last activity. */ + turnId: string; +} diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index e05a49b08..c9c71ede2 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -115,6 +115,7 @@ export * from "./ListSkillVersionsResponse.js"; export * from "./ListTurnEventsOrder.js"; export * from "./ListTurnEventsResponse.js"; export * from "./ListTurnsResponse.js"; +export * from "./ListZombieTurnsResponse.js"; export * from "./McpAuthRequiredEvent.js"; export * from "./McpAuthStatus.js"; export * from "./McpAuthStatusStatus.js"; @@ -267,3 +268,4 @@ export * from "./WebSearchCapability.js"; export * from "./WebSearchConfig.js"; export * from "./WebSearchProviderManifest.js"; export * from "./ZaiModelProvider.js"; +export * from "./ZombieTurn.js"; diff --git a/packages/trueforge-sdk/src/serialization/resources/internal/resources/index.ts b/packages/trueforge-sdk/src/serialization/resources/internal/resources/index.ts index 0c87d3959..f5e1a7306 100644 --- a/packages/trueforge-sdk/src/serialization/resources/internal/resources/index.ts +++ b/packages/trueforge-sdk/src/serialization/resources/internal/resources/index.ts @@ -4,3 +4,5 @@ export * from "./schedules/client/requests/index.js"; export * as schedules from "./schedules/index.js"; export * from "./sessions/client/requests/index.js"; export * as sessions from "./sessions/index.js"; +export * from "./turns/client/requests/index.js"; +export * as turns from "./turns/index.js"; diff --git a/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/index.ts b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/index.ts new file mode 100644 index 000000000..195f9aa8a --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/index.ts @@ -0,0 +1 @@ +export * from "./requests/index.js"; diff --git a/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts new file mode 100644 index 000000000..27ce8258c --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts @@ -0,0 +1,22 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../../../../../../api/index.js"; +import * as core from "../../../../../../../core/index.js"; +import type * as serializers from "../../../../../../index.js"; + +export const CloseZombieTurnRequest: core.serialization.Schema< + serializers.internal.CloseZombieTurnRequest.Raw, + TrueForge.internal.CloseZombieTurnRequest +> = core.serialization.object({ + sessionId: core.serialization.property("session_id", core.serialization.string()), + tenantId: core.serialization.property("tenant_id", core.serialization.string()), + turnId: core.serialization.property("turn_id", core.serialization.string()), +}); + +export declare namespace CloseZombieTurnRequest { + export interface Raw { + session_id: string; + tenant_id: string; + turn_id: string; + } +} diff --git a/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/index.ts b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/index.ts new file mode 100644 index 000000000..e90889ed4 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/index.ts @@ -0,0 +1 @@ +export { CloseZombieTurnRequest } from "./CloseZombieTurnRequest.js"; diff --git a/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/index.ts b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/index.ts new file mode 100644 index 000000000..914b8c3c7 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/index.ts @@ -0,0 +1 @@ +export * from "./client/index.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/ListZombieTurnsResponse.ts b/packages/trueforge-sdk/src/serialization/types/ListZombieTurnsResponse.ts new file mode 100644 index 000000000..3cb57e4a3 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/ListZombieTurnsResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { ZombieTurn } from "./ZombieTurn.js"; + +export const ListZombieTurnsResponse: core.serialization.ObjectSchema< + serializers.ListZombieTurnsResponse.Raw, + TrueForge.ListZombieTurnsResponse +> = core.serialization.object({ + data: core.serialization.list(ZombieTurn), +}); + +export declare namespace ListZombieTurnsResponse { + export interface Raw { + data: ZombieTurn.Raw[]; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/ZombieTurn.ts b/packages/trueforge-sdk/src/serialization/types/ZombieTurn.ts new file mode 100644 index 000000000..192f67405 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/ZombieTurn.ts @@ -0,0 +1,20 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const ZombieTurn: core.serialization.ObjectSchema = + core.serialization.object({ + sessionId: core.serialization.property("session_id", core.serialization.string()), + tenantId: core.serialization.property("tenant_id", core.serialization.string()), + turnId: core.serialization.property("turn_id", core.serialization.string()), + }); + +export declare namespace ZombieTurn { + export interface Raw { + session_id: string; + tenant_id: string; + turn_id: string; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index e05a49b08..c9c71ede2 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -115,6 +115,7 @@ export * from "./ListSkillVersionsResponse.js"; export * from "./ListTurnEventsOrder.js"; export * from "./ListTurnEventsResponse.js"; export * from "./ListTurnsResponse.js"; +export * from "./ListZombieTurnsResponse.js"; export * from "./McpAuthRequiredEvent.js"; export * from "./McpAuthStatus.js"; export * from "./McpAuthStatusStatus.js"; @@ -267,3 +268,4 @@ export * from "./WebSearchCapability.js"; export * from "./WebSearchConfig.js"; export * from "./WebSearchProviderManifest.js"; export * from "./ZaiModelProvider.js"; +export * from "./ZombieTurn.js"; diff --git a/packages/trueforge-sdk/tests/wire/internal/turns.test.ts b/packages/trueforge-sdk/tests/wire/internal/turns.test.ts new file mode 100644 index 000000000..c91f35d2b --- /dev/null +++ b/packages/trueforge-sdk/tests/wire/internal/turns.test.ts @@ -0,0 +1,121 @@ +// This file was auto-generated by Fern from our API Definition. + +import * as TrueForgeTypes from "../../../src/api/index"; +import { TrueForge } from "../../../src/Client"; +import { mockServerPool } from "../../mock-server/MockServerPool"; + +describe("TurnsClient", () => { + test("close_zombie (1)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + const rawRequestBody = { session_id: "session_id", tenant_id: "tenant_id", turn_id: "turn_id" }; + + server + .mockEndpoint() + .post("/api/internal/turns/close-zombie") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .build(); + + const response = await client.internal.turns.closeZombie({ + sessionId: "session_id", + tenantId: "tenant_id", + turnId: "turn_id", + }); + expect(response).toEqual(undefined); + }); + + test("close_zombie (2)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + const rawRequestBody = { session_id: "x", tenant_id: "x", turn_id: "x" }; + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .post("/api/internal/turns/close-zombie") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(401) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.internal.turns.closeZombie({ + sessionId: "x", + tenantId: "x", + turnId: "x", + }); + }).rejects.toThrow(TrueForgeTypes.UnauthorizedError); + }); + + test("close_zombie (3)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + const rawRequestBody = { session_id: "x", tenant_id: "x", turn_id: "x" }; + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .post("/api/internal/turns/close-zombie") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(404) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.internal.turns.closeZombie({ + sessionId: "x", + tenantId: "x", + turnId: "x", + }); + }).rejects.toThrow(TrueForgeTypes.NotFoundError); + }); + + test("list_zombie (1)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { data: [{ session_id: "session_id", tenant_id: "tenant_id", turn_id: "turn_id" }] }; + + server + .mockEndpoint() + .get("/api/internal/turns/zombie") + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.internal.turns.listZombie(); + expect(response).toEqual({ + data: [ + { + sessionId: "session_id", + tenantId: "tenant_id", + turnId: "turn_id", + }, + ], + }); + }); + + test("list_zombie (2)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/api/internal/turns/zombie") + .respondWith() + .statusCode(401) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.internal.turns.listZombie(); + }).rejects.toThrow(TrueForgeTypes.UnauthorizedError); + }); +}); diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 684ed8de2..67e7fa29a 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -129,6 +129,14 @@ REDIS_URL=redis://localhost:6379 ## Defaults to http://localhost:$PORT; point it at the server Service in a split deployment. # SERVER_URL=http://localhost:8790 +## Zombie-turn closer (controller + internal /api/internal/turns). All optional. +## Running turns with last activity older than this are closed. Default 900000 (15 minutes). +# ZOMBIE_TURN_STALE_MS=900000 +## Max closes per controller tick. Default 50. +# ZOMBIE_TURN_CLOSE_BATCH=50 +## Gap between closer loop passes. Default 60000. +# ZOMBIE_TURN_CLOSER_INTERVAL_MS=60000 + ## Service credential for controller → /api/internal/schedules. ## Required when STANDALONE=false. Standalone defaults to `trueforge-standalone` (local only). TRUEFORGE_API_KEY=placeholder-value-please-generate-your-own diff --git a/packages/trueforge/src/apis/zombieTurns.ts b/packages/trueforge/src/apis/zombieTurns.ts new file mode 100644 index 000000000..7fc1a7b3f --- /dev/null +++ b/packages/trueforge/src/apis/zombieTurns.ts @@ -0,0 +1,63 @@ +/** + * Internal API for the zombie-turn closer controller. + */ +import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; +import { CancellationReason, type ISessionStore, type Sessions } from '@truefoundry/trueforge-core/agent-session'; +import type { RedisClient } from '@truefoundry/trueforge-core/request-reply'; +import type { Logger } from 'winston'; +import configuration from '../config'; +import { closeZombieTurnRoute, listZombieTurnsRoute } from '../routes/zombieTurnRoutes'; +import type { ActiveTurnRegistry } from '../runtime/activeTurns'; +import { cancelSessionTurn } from './sessions'; + +export interface ZombieTurnsRouterDeps { + sessionStore: ISessionStore; + sessions: Sessions; + activeTurns: ActiveTurnRegistry; + redis?: RedisClient | undefined; + logger: Logger; +} + +export function createZombieTurnsRouter(deps: ZombieTurnsRouterDeps) { + const listHandler: RouteHandler = async c => { + const olderThan = new Date(Date.now() - configuration.ZOMBIE_TURN_STALE_MS); + const stale = await deps.sessionStore.listStaleRunningTurns({ older_than: olderThan }); + return c.json( + { + data: stale.map(row => ({ + tenant_id: row.tenant_id, + session_id: row.session_id, + turn_id: row.turn_id, + })), + }, + 200, + ); + }; + + const closeHandler: RouteHandler = async c => { + const body = c.req.valid('json'); + const session = await deps.sessions.get({ + tenant_id: body.tenant_id, + session_id: body.session_id, + }); + if (!session) { + return c.json({ error: { message: `Session not found: ${body.session_id}` } }, 404); + } + await cancelSessionTurn( + { + activeTurns: deps.activeTurns, + session, + sessionStore: deps.sessionStore, + redis: deps.redis, + logger: deps.logger, + }, + { turnId: body.turn_id, reason: CancellationReason.Abandoned }, + ); + return c.body(null, 204); + }; + + const router = new OpenAPIHono(); + router.openapi(listZombieTurnsRoute, listHandler); + router.openapi(closeZombieTurnRoute, closeHandler); + return router; +} diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index b66bbab55..ab84fc436 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -26,6 +26,7 @@ import { createInternalSessionsRouter, createSessionsRouter } from './apis/sessi import { createSettingsRouter } from './apis/settings'; import { createAvailableSkillsRouter } from './apis/skills'; import { createTurnsRouter } from './apis/turns'; +import { createZombieTurnsRouter } from './apis/zombieTurns'; import type { Authenticator } from './auth/authenticator'; import type { Authorizer } from './auth/authorizer'; import { resolveRequestContext } from './auth/identity'; @@ -491,6 +492,19 @@ export function createServerApp(deps: ServerDeps) { scheduleExecutionAuthMiddleware, ), ); + app.route( + '/api/internal/turns', + withAuth( + createZombieTurnsRouter({ + sessionStore: deps.sessionStore, + sessions: deps.sessions, + activeTurns: deps.activeTurns, + redis: deps.redis, + logger: deps.logger, + }), + scheduleExecutionAuthMiddleware, + ), + ); app.route( '/api/v1/schedules', withAuth( diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 2913f3551..fd3963543 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -825,6 +825,21 @@ export interface SharedServerConfiguration { * Default: `http://localhost:$PORT`; in-cluster deployments MUST point this at the server Service. */ SERVER_URL: string; + /** + * Running turns with last activity older than this are zombies (list/close API). + * Env: `ZOMBIE_TURN_STALE_MS`. Default 900000 (15 minutes). + */ + ZOMBIE_TURN_STALE_MS: number; + /** + * Max zombie turns the controller closes per tick. + * Env: `ZOMBIE_TURN_CLOSE_BATCH`. Default 50. + */ + ZOMBIE_TURN_CLOSE_BATCH: number; + /** + * Gap between zombie-turn closer loop passes. + * Env: `ZOMBIE_TURN_CLOSER_INTERVAL_MS`. Default 60000. + */ + ZOMBIE_TURN_CLOSER_INTERVAL_MS: number; /** * Service credential for controller calls. * Env: `TRUEFORGE_API_KEY`. Required and non-empty when `STANDALONE=false`. @@ -1199,6 +1214,21 @@ const shared: SharedServerConfiguration = { PUBLIC_BASE_URL: parsePublicBaseUrl(getEnv('PUBLIC_BASE_URL', { defaultValue: '' })), SERVER_URL: getEnv('SERVER_URL', { defaultValue: `http://localhost:${String(port)}` }) ?? `http://localhost:${String(port)}`, + ZOMBIE_TURN_STALE_MS: parsePositiveInt({ + envKey: 'ZOMBIE_TURN_STALE_MS', + raw: getEnv('ZOMBIE_TURN_STALE_MS'), + defaultValue: 15 * 60_000, + }), + ZOMBIE_TURN_CLOSE_BATCH: parsePositiveInt({ + envKey: 'ZOMBIE_TURN_CLOSE_BATCH', + raw: getEnv('ZOMBIE_TURN_CLOSE_BATCH'), + defaultValue: 50, + }), + ZOMBIE_TURN_CLOSER_INTERVAL_MS: parsePositiveInt({ + envKey: 'ZOMBIE_TURN_CLOSER_INTERVAL_MS', + raw: getEnv('ZOMBIE_TURN_CLOSER_INTERVAL_MS'), + defaultValue: 60_000, + }), TRUEFORGE_API_KEY: standalone ? (getEnv('TRUEFORGE_API_KEY', { defaultValue: STANDALONE_TRUEFORGE_API_KEY }) ?? STANDALONE_TRUEFORGE_API_KEY) : (getEnv('TRUEFORGE_API_KEY', { required: true }) ?? ''), diff --git a/packages/trueforge/src/controller.ts b/packages/trueforge/src/controller.ts index 21e5958f6..f689a7056 100644 --- a/packages/trueforge/src/controller.ts +++ b/packages/trueforge/src/controller.ts @@ -2,6 +2,7 @@ import type { Logger } from 'winston'; import { Controller } from './controller/Controller'; import { sandboxEnvBuildLoop } from './controller/sandboxEnvBuild'; import { scheduleDispatchLoop } from './controller/scheduleDispatch'; +import { zombieTurnCloserLoop } from './controller/zombieTurnCloser'; import type { IScheduleStore } from './db/scheduleStore'; import type { WithTransaction } from './db/transaction'; @@ -9,7 +10,7 @@ import type { WithTransaction } from './db/transaction'; * Controller whose schedule loop hands runs to the server over HTTP * (`SERVER_URL` + `TRUEFORGE_API_KEY` from process config). Standalone uses * loopback; distributed uses the dedicated controller against the server Service. - * Sandbox-env build loop lists/progresses pending versions over the same HTTP path. + * Sandbox-env build and zombie-turn closer loops use the same HTTP path. */ export function createController(params: { scheduleStore: IScheduleStore; @@ -24,6 +25,7 @@ export function createController(params: { logger: params.logger, }), sandboxEnvBuildLoop({ logger: params.logger }), + zombieTurnCloserLoop({ logger: params.logger }), ], logger: params.logger, }); diff --git a/packages/trueforge/src/controller/internalTrueForgeClient.ts b/packages/trueforge/src/controller/internalTrueForgeClient.ts index 36570085f..5ee90d057 100644 --- a/packages/trueforge/src/controller/internalTrueForgeClient.ts +++ b/packages/trueforge/src/controller/internalTrueForgeClient.ts @@ -35,4 +35,21 @@ export const internalTrueForgeClient = { async progressSandboxEnvironmentVersion(environmentVersionId: string): Promise { await client.internal.sandboxEnvironments.progress({ environmentVersionId }); }, + + async listZombieTurns(): Promise<{ tenantId: string; sessionId: string; turnId: string }[]> { + const response = await client.internal.turns.listZombie(); + return response.data.map(row => ({ + tenantId: row.tenantId, + sessionId: row.sessionId, + turnId: row.turnId, + })); + }, + + async closeZombieTurn(input: { tenantId: string; sessionId: string; turnId: string }): Promise { + await client.internal.turns.closeZombie({ + tenantId: input.tenantId, + sessionId: input.sessionId, + turnId: input.turnId, + }); + }, }; diff --git a/packages/trueforge/src/controller/zombieTurnCloser.ts b/packages/trueforge/src/controller/zombieTurnCloser.ts new file mode 100644 index 000000000..4e6ba3182 --- /dev/null +++ b/packages/trueforge/src/controller/zombieTurnCloser.ts @@ -0,0 +1,79 @@ +/** + * Control loop: list stale running turns and close them over HTTP (batch per tick). + */ +import { TrueForgeApi } from '@truefoundry/trueforge-sdk'; +import type { Logger } from 'winston'; +import configuration from '../config'; +import { captureCriticalException } from '../sentry'; +import type { ControlLoop } from './Controller'; +import { internalTrueForgeClient } from './internalTrueForgeClient'; + +const ZOMBIE_TURN_CLOSER_LOOP_NAME = 'zombie-turn-closer'; + +export async function dispatchZombieTurns({ logger }: { logger: Logger }): Promise { + const zombies = await internalTrueForgeClient.listZombieTurns(); + const batch = zombies.slice(0, configuration.ZOMBIE_TURN_CLOSE_BATCH); + logger.debug('Zombie turn closer tick', { + zombie_count: zombies.length, + close_batch: batch.length, + }); + for (const zombie of batch) { + logger.debug('Closing zombie turn', { + tenant_id: zombie.tenantId, + session_id: zombie.sessionId, + turn_id: zombie.turnId, + }); + try { + await internalTrueForgeClient.closeZombieTurn(zombie); + logger.debug('Zombie turn closed', { + tenant_id: zombie.tenantId, + session_id: zombie.sessionId, + turn_id: zombie.turnId, + }); + } catch (error) { + if (error instanceof TrueForgeApi.NotFoundError) { + logger.warn('Zombie turn session not found; skipping', { + tenant_id: zombie.tenantId, + session_id: zombie.sessionId, + turn_id: zombie.turnId, + }); + captureCriticalException(error, { + tags: { module: 'zombieTurnCloser', operation: 'closeNotFound' }, + extra: { + tenant_id: zombie.tenantId, + session_id: zombie.sessionId, + turn_id: zombie.turnId, + }, + }); + continue; + } + logger.error('Zombie turn close failed', { + tenant_id: zombie.tenantId, + session_id: zombie.sessionId, + turn_id: zombie.turnId, + error: error instanceof Error ? error.message : String(error), + }); + captureCriticalException(error, { + tags: { module: 'zombieTurnCloser', operation: 'close' }, + extra: { + tenant_id: zombie.tenantId, + session_id: zombie.sessionId, + turn_id: zombie.turnId, + }, + }); + } + } +} + +export function zombieTurnCloserLoop(params: { logger: Logger }): ControlLoop { + return { + name: ZOMBIE_TURN_CLOSER_LOOP_NAME, + intervalMs: configuration.ZOMBIE_TURN_CLOSER_INTERVAL_MS, + async tick(signal) { + if (signal.aborted) { + return; + } + await dispatchZombieTurns({ logger: params.logger }); + }, + }; +} diff --git a/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts b/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts index f4316cbda..3402721dc 100644 --- a/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts +++ b/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts @@ -18,6 +18,7 @@ import type { ISessionStore, ListSessionEventsInput, ListSessionsInput, + ListStaleRunningTurnsInput, ListTurnEventsInput, ListTurnsInput, OverwriteThreadContextInput, @@ -25,6 +26,7 @@ import type { PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, + StaleRunningTurnRef, TurnRecordWithoutSnapshot, UpdateSessionInput, UpdateTurnNonTerminalStateInput, @@ -65,6 +67,7 @@ import { createTurn as createTurnQuery, freezeAndGetTurn as freezeAndGetTurnQuery, getTurn as getTurnQuery, + listStaleRunningTurns as listStaleRunningTurnsQuery, listTurns as listTurnsQuery, updateTurnNonTerminalState as updateTurnNonTerminalStateQuery, updateTurnTerminalState as updateTurnTerminalStateQuery, @@ -171,6 +174,10 @@ export class PostgresSessionStore implements ISessionStore { + return listStaleRunningTurnsQuery(this.db, input); + } + async listTurns( input: ListTurnsInput, ): Promise<{ data: TurnRecordWithoutSnapshot[]; pagination: TokenPagination }> { diff --git a/packages/trueforge/src/db/postgres/session-store/queries/turns.ts b/packages/trueforge/src/db/postgres/session-store/queries/turns.ts index 977a49d8c..44b18b0b5 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/turns.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/turns.ts @@ -9,6 +9,8 @@ import { import { assertCreateTurnThreadDelta } from '@truefoundry/trueforge-core/agent-session/store/assertCreateTurnThreadDelta'; import type { FreezeAndGetTurnInput, + ListStaleRunningTurnsInput, + StaleRunningTurnRef, TurnRecordWithoutSnapshot, UpdateTurnNonTerminalStateInput, UpdateTurnTerminalStateInput, @@ -758,6 +760,42 @@ export async function listTurns(db: Kysely, input: ListTurnsInput): Pr }; } +/** + * listStaleRunningTurns — running turns whose last event (or created_at) is before older_than. + * Oldest-first; no limit. Joins session for tenant_id (needed to load the session on close). + */ +export async function listStaleRunningTurns( + db: Kysely, + input: ListStaleRunningTurnsInput, +): Promise { + const rows = await db + .selectFrom('turn as t') + .innerJoin('session as s', 's.session_id', 't.session_id') + .leftJoin( + eb => + eb + .selectFrom('session_event') + .select(['session_id', 'turn_id']) + .select(eb2 => eb2.fn.max('created_at').as('last_event_at')) + .groupBy(['session_id', 'turn_id']) + .as('e'), + join => join.onRef('e.session_id', '=', 't.session_id').onRef('e.turn_id', '=', 't.turn_id'), + ) + .select(['s.tenant_id', 't.session_id', 't.turn_id']) + .where(sql`t.state->>'status' = 'running'`) + .where(sql`COALESCE(e.last_event_at, t.created_at) < ${input.older_than}`) + .orderBy(sql`COALESCE(e.last_event_at, t.created_at)`, 'asc') + .orderBy('t.session_id', 'asc') + .orderBy('t.turn_id', 'asc') + .execute(); + + return rows.map(row => ({ + tenant_id: row.tenant_id, + session_id: row.session_id, + turn_id: row.turn_id, + })); +} + /** Atomically transitions running ↔ paused and appends turn.update. */ export async function updateTurnNonTerminalState( db: Kysely, diff --git a/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts b/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts index 0bd5d876e..8e65eed5f 100644 --- a/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts +++ b/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts @@ -18,6 +18,7 @@ import type { ISessionStore, ListSessionEventsInput, ListSessionsInput, + ListStaleRunningTurnsInput, ListTurnEventsInput, ListTurnsInput, OverwriteThreadContextInput, @@ -25,6 +26,7 @@ import type { PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, + StaleRunningTurnRef, TurnRecordWithoutSnapshot, UpdateSessionInput, UpdateTurnNonTerminalStateInput, @@ -65,6 +67,7 @@ import { createTurn as createTurnQuery, freezeAndGetTurn as freezeAndGetTurnQuery, getTurn as getTurnQuery, + listStaleRunningTurns as listStaleRunningTurnsQuery, listTurns as listTurnsQuery, updateTurnNonTerminalState as updateTurnNonTerminalStateQuery, updateTurnTerminalState as updateTurnTerminalStateQuery, @@ -169,6 +172,10 @@ export class SqliteSessionStore implements ISessionStore { + return listStaleRunningTurnsQuery(this.db, input); + } + async listTurns( input: ListTurnsInput, ): Promise<{ data: TurnRecordWithoutSnapshot[]; pagination: TokenPagination }> { diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/turns.ts b/packages/trueforge/src/db/sqlite/session-store/queries/turns.ts index 5d4ad9115..50acc9375 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/turns.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/turns.ts @@ -9,6 +9,8 @@ import { import { assertCreateTurnThreadDelta } from '@truefoundry/trueforge-core/agent-session/store/assertCreateTurnThreadDelta'; import type { FreezeAndGetTurnInput, + ListStaleRunningTurnsInput, + StaleRunningTurnRef, TurnRecordWithoutSnapshot, UpdateTurnNonTerminalStateInput, UpdateTurnTerminalStateInput, @@ -810,6 +812,44 @@ export async function listTurns(db: Kysely, input: ListTurnsInput): Pr }; } +/** + * listStaleRunningTurns — running turns whose last event (or created_at) is before older_than. + * Oldest-first; no limit. Timestamps are ISO strings on SQLite. + * Joins session for tenant_id (needed to load the session on close). + */ +export async function listStaleRunningTurns( + db: Kysely, + input: ListStaleRunningTurnsInput, +): Promise { + const olderThanIso = input.older_than.toISOString(); + const rows = await db + .selectFrom('turn as t') + .innerJoin('session as s', 's.session_id', 't.session_id') + .leftJoin( + eb => + eb + .selectFrom('session_event') + .select(['session_id', 'turn_id']) + .select(eb2 => eb2.fn.max('created_at').as('last_event_at')) + .groupBy(['session_id', 'turn_id']) + .as('e'), + join => join.onRef('e.session_id', '=', 't.session_id').onRef('e.turn_id', '=', 't.turn_id'), + ) + .select(['s.tenant_id', 't.session_id', 't.turn_id']) + .where(sql`t.state->>'status' = 'running'`) + .where(sql`COALESCE(e.last_event_at, t.created_at) < ${olderThanIso}`) + .orderBy(sql`COALESCE(e.last_event_at, t.created_at)`, 'asc') + .orderBy('t.session_id', 'asc') + .orderBy('t.turn_id', 'asc') + .execute(); + + return rows.map(row => ({ + tenant_id: row.tenant_id, + session_id: row.session_id, + turn_id: row.turn_id, + })); +} + /** Atomically transitions running ↔ paused and appends turn.update. */ export async function updateTurnNonTerminalState( db: Kysely, diff --git a/packages/trueforge/src/routes/zombieTurnRoutes.ts b/packages/trueforge/src/routes/zombieTurnRoutes.ts new file mode 100644 index 000000000..d0e7d8c55 --- /dev/null +++ b/packages/trueforge/src/routes/zombieTurnRoutes.ts @@ -0,0 +1,57 @@ +/** + * Internal zombie-turn closer routes (API-key auth). + * Mounted at /api/internal/turns. + */ +import { createRoute } from '@hono/zod-openapi'; +import { RequestErrorResponseSchema } from '../schemas/errors'; +import { CloseZombieTurnRequestSchema, ListZombieTurnsResponseSchema } from '../schemas/zombieTurn'; +import { OpenApiTag } from './openapiTags'; + +export const listZombieTurnsRoute = createRoute({ + method: 'get', + path: '/zombie', + tags: [OpenApiTag.INTERNAL], + summary: 'List stale running turns', + description: 'Returns all running turns whose last activity is past the stale threshold.', + 'x-fern-sdk-group-name': ['internal', 'turns'], + 'x-fern-sdk-method-name': 'list_zombie', + 'x-excluded': true, + responses: { + 200: { + content: { 'application/json': { schema: ListZombieTurnsResponseSchema } }, + description: 'Stale running turns oldest-first.', + }, + 401: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Invalid service credential.', + }, + }, +}); + +export const closeZombieTurnRoute = createRoute({ + method: 'post', + path: '/close-zombie', + tags: [OpenApiTag.INTERNAL], + summary: 'Close a stale running turn', + description: 'Cancels a zombie turn (turn.done with abandoned reason).', + 'x-fern-sdk-group-name': ['internal', 'turns'], + 'x-fern-sdk-method-name': 'close_zombie', + 'x-excluded': true, + request: { + body: { + content: { 'application/json': { schema: CloseZombieTurnRequestSchema } }, + required: true, + }, + }, + responses: { + 204: { description: 'Closed (or already terminal / missing turn).' }, + 401: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Invalid service credential.', + }, + 404: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Session not found.', + }, + }, +}); diff --git a/packages/trueforge/src/schemas/zombieTurn.ts b/packages/trueforge/src/schemas/zombieTurn.ts new file mode 100644 index 000000000..48c50e562 --- /dev/null +++ b/packages/trueforge/src/schemas/zombieTurn.ts @@ -0,0 +1,29 @@ +/** + * Wire schemas for internal zombie-turn closer endpoints. + */ +import { z } from '@hono/zod-openapi'; + +export const ZombieTurnSchema = z + .object({ + tenant_id: z.string().min(1).describe('Tenant that owns the session.'), + session_id: z.string().min(1).describe('Session that owns the turn.'), + turn_id: z.string().min(1).describe('Running turn with stale last activity.'), + }) + .strict() + .openapi('ZombieTurn'); + +export const ListZombieTurnsResponseSchema = z + .object({ + data: z.array(ZombieTurnSchema), + }) + .strict() + .openapi('ListZombieTurnsResponse'); + +export const CloseZombieTurnRequestSchema = z + .object({ + tenant_id: z.string().min(1).describe('Tenant that owns the session.'), + session_id: z.string().min(1).describe('Session that owns the turn.'), + turn_id: z.string().min(1).describe('Turn to close.'), + }) + .strict() + .openapi('CloseZombieTurnRequest'); diff --git a/packages/trueforge/tests/unit/controller/zombieTurnCloser.test.ts b/packages/trueforge/tests/unit/controller/zombieTurnCloser.test.ts new file mode 100644 index 000000000..820bb8cd7 --- /dev/null +++ b/packages/trueforge/tests/unit/controller/zombieTurnCloser.test.ts @@ -0,0 +1,57 @@ +import { TrueForgeApi } from '@truefoundry/trueforge-sdk'; +import configuration from '../../../src/config'; +import { internalTrueForgeClient } from '../../../src/controller/internalTrueForgeClient'; +import { dispatchZombieTurns, zombieTurnCloserLoop } from '../../../src/controller/zombieTurnCloser'; + +function fakeLogger() { + return { error: jest.fn(), debug: jest.fn(), info: jest.fn(), warn: jest.fn() }; +} + +function zombie(n: number): { tenantId: string; sessionId: string; turnId: string } { + return { tenantId: 't1', sessionId: `s-${String(n)}`, turnId: `turn-${String(n)}` }; +} + +describe('zombieTurnCloserLoop', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('closes each zombie from the internal API up to the batch limit', async () => { + const batch = configuration.ZOMBIE_TURN_CLOSE_BATCH; + const all = Array.from({ length: batch + 5 }, (_, i) => zombie(i)); + jest.spyOn(internalTrueForgeClient, 'listZombieTurns').mockResolvedValue(all); + const close = jest.spyOn(internalTrueForgeClient, 'closeZombieTurn').mockResolvedValue(undefined); + + const logger = fakeLogger(); + const loop = zombieTurnCloserLoop({ logger: logger as never }); + await loop.tick(new AbortController().signal); + + expect(close).toHaveBeenCalledTimes(batch); + expect(close).toHaveBeenNthCalledWith(1, zombie(0)); + expect(close).toHaveBeenNthCalledWith(batch, zombie(batch - 1)); + }); + + it('skips NotFoundError and continues the tick', async () => { + jest.spyOn(internalTrueForgeClient, 'listZombieTurns').mockResolvedValue([zombie(1), zombie(2)]); + const close = jest.spyOn(internalTrueForgeClient, 'closeZombieTurn').mockImplementation(async input => { + if (input.turnId === 'turn-1') { + throw new TrueForgeApi.NotFoundError({ error: { message: 'not found' } }); + } + }); + const logger = fakeLogger(); + + await dispatchZombieTurns({ logger: logger as never }); + + expect(close).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('does not tick when aborted', async () => { + const list = jest.spyOn(internalTrueForgeClient, 'listZombieTurns'); + const controller = new AbortController(); + controller.abort(); + const loop = zombieTurnCloserLoop({ logger: fakeLogger() as never }); + await loop.tick(controller.signal); + expect(list).not.toHaveBeenCalled(); + }); +}); diff --git a/python/trueforge_sdk/reference.md b/python/trueforge_sdk/reference.md index 46fef12c4..c35dcb7b5 100644 --- a/python/trueforge_sdk/reference.md +++ b/python/trueforge_sdk/reference.md @@ -4379,6 +4379,159 @@ client.internal.sessions.get_or_create_by_external_id( + + + + +## Internal Turns +
client.internal.turns.close_zombie(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Cancels a zombie turn (turn.done with abandoned reason). +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from trueforge_sdk import TrueForge + +client = TrueForge( + token="", + base_url="https://yourhost.com/path/to/api", +) + +client.internal.turns.close_zombie( + session_id="session_id", + tenant_id="tenant_id", + turn_id="turn_id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**session_id:** `str` — Session that owns the turn. + +
+
+ +
+
+ +**tenant_id:** `str` — Tenant that owns the session. + +
+
+ +
+
+ +**turn_id:** `str` — Turn to close. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ + +
+
+
+ +
client.internal.turns.list_zombie() -> ListZombieTurnsResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Returns all running turns with no events for 15+ minutes. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from trueforge_sdk import TrueForge + +client = TrueForge( + token="", + base_url="https://yourhost.com/path/to/api", +) + +client.internal.turns.list_zombie() + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ +
diff --git a/python/trueforge_sdk/src/trueforge_sdk/__init__.py b/python/trueforge_sdk/src/trueforge_sdk/__init__.py index c4ccacf02..1a774544f 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/__init__.py +++ b/python/trueforge_sdk/src/trueforge_sdk/__init__.py @@ -124,6 +124,7 @@ ListTurnEventsOrder, ListTurnEventsResponse, ListTurnsResponse, + ListZombieTurnsResponse, McpAuthRequiredEvent, McpAuthStatus, McpAuthStatusStatus, @@ -276,6 +277,7 @@ WebSearchConfig, WebSearchProviderManifest, ZaiModelProvider, + ZombieTurn, ) from .errors import ( BadGatewayError, @@ -437,6 +439,7 @@ "ListTurnEventsOrder": ".types", "ListTurnEventsResponse": ".types", "ListTurnsResponse": ".types", + "ListZombieTurnsResponse": ".types", "McpAuthRequiredEvent": ".types", "McpAuthStatus": ".types", "McpAuthStatusStatus": ".types", @@ -594,6 +597,7 @@ "WebSearchConfig": ".types", "WebSearchProviderManifest": ".types", "ZaiModelProvider": ".types", + "ZombieTurn": ".types", "__version__": ".version", "agents": ".agents", "auth": ".auth", @@ -760,6 +764,7 @@ def __dir__(): "ListTurnEventsOrder", "ListTurnEventsResponse", "ListTurnsResponse", + "ListZombieTurnsResponse", "McpAuthRequiredEvent", "McpAuthStatus", "McpAuthStatusStatus", @@ -917,6 +922,7 @@ def __dir__(): "WebSearchConfig", "WebSearchProviderManifest", "ZaiModelProvider", + "ZombieTurn", "__version__", "agents", "auth", diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/__init__.py b/python/trueforge_sdk/src/trueforge_sdk/internal/__init__.py index e74a16a7e..9c236edaf 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/internal/__init__.py +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/__init__.py @@ -6,13 +6,14 @@ from importlib import import_module if typing.TYPE_CHECKING: - from . import agents, metrics, sandbox_environments, schedules, sessions + from . import agents, metrics, sandbox_environments, schedules, sessions, turns _dynamic_imports: typing.Dict[str, str] = { "agents": ".agents", "metrics": ".metrics", "sandbox_environments": ".sandbox_environments", "schedules": ".schedules", "sessions": ".sessions", + "turns": ".turns", } @@ -37,4 +38,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["agents", "metrics", "sandbox_environments", "schedules", "sessions"] +__all__ = ["agents", "metrics", "sandbox_environments", "schedules", "sessions", "turns"] diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/client.py b/python/trueforge_sdk/src/trueforge_sdk/internal/client.py index ee9183905..ebc870b9d 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/internal/client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/client.py @@ -16,6 +16,7 @@ from .sandbox_environments.client import AsyncSandboxEnvironmentsClient, SandboxEnvironmentsClient from .schedules.client import AsyncSchedulesClient, SchedulesClient from .sessions.client import AsyncSessionsClient, SessionsClient + from .turns.client import AsyncTurnsClient, TurnsClient # this is used as the default value for optional parameters OMIT = typing.cast(typing.Any, ...) @@ -28,6 +29,7 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): self._sandbox_environments: typing.Optional[SandboxEnvironmentsClient] = None self._schedules: typing.Optional[SchedulesClient] = None self._sessions: typing.Optional[SessionsClient] = None + self._turns: typing.Optional[TurnsClient] = None self._agents: typing.Optional[AgentsClient] = None @property @@ -116,6 +118,14 @@ def sessions(self): self._sessions = SessionsClient(client_wrapper=self._client_wrapper) return self._sessions + @property + def turns(self): + if self._turns is None: + from .turns.client import TurnsClient # noqa: E402 + + self._turns = TurnsClient(client_wrapper=self._client_wrapper) + return self._turns + @property def agents(self): if self._agents is None: @@ -133,6 +143,7 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): self._sandbox_environments: typing.Optional[AsyncSandboxEnvironmentsClient] = None self._schedules: typing.Optional[AsyncSchedulesClient] = None self._sessions: typing.Optional[AsyncSessionsClient] = None + self._turns: typing.Optional[AsyncTurnsClient] = None self._agents: typing.Optional[AsyncAgentsClient] = None @property @@ -229,6 +240,14 @@ def sessions(self): self._sessions = AsyncSessionsClient(client_wrapper=self._client_wrapper) return self._sessions + @property + def turns(self): + if self._turns is None: + from .turns.client import AsyncTurnsClient # noqa: E402 + + self._turns = AsyncTurnsClient(client_wrapper=self._client_wrapper) + return self._turns + @property def agents(self): if self._agents is None: diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/__init__.py b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/__init__.py new file mode 100644 index 000000000..5cde0202d --- /dev/null +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/__init__.py @@ -0,0 +1,4 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py new file mode 100644 index 000000000..08c2c5226 --- /dev/null +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py @@ -0,0 +1,199 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.request_options import RequestOptions +from ...types.list_zombie_turns_response import ListZombieTurnsResponse +from .raw_client import AsyncRawTurnsClient, RawTurnsClient + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class TurnsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._raw_client = RawTurnsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> RawTurnsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + RawTurnsClient + """ + return self._raw_client + + def close_zombie( + self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + Cancels a zombie turn (turn.done with abandoned reason). + + Parameters + ---------- + session_id : str + Session that owns the turn. + + tenant_id : str + Tenant that owns the session. + + turn_id : str + Turn to close. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from trueforge_sdk import TrueForge + + client = TrueForge( + token="YOUR_TOKEN", + base_url="https://yourhost.com/path/to/api", + ) + client.internal.turns.close_zombie( + session_id="session_id", + tenant_id="tenant_id", + turn_id="turn_id", + ) + """ + _response = self._raw_client.close_zombie( + session_id=session_id, tenant_id=tenant_id, turn_id=turn_id, request_options=request_options + ) + return _response.data + + def list_zombie(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListZombieTurnsResponse: + """ + Returns all running turns with no events for 15+ minutes. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListZombieTurnsResponse + Stale running turns oldest-first. + + Examples + -------- + from trueforge_sdk import TrueForge + + client = TrueForge( + token="YOUR_TOKEN", + base_url="https://yourhost.com/path/to/api", + ) + client.internal.turns.list_zombie() + """ + _response = self._raw_client.list_zombie(request_options=request_options) + return _response.data + + +class AsyncTurnsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._raw_client = AsyncRawTurnsClient(client_wrapper=client_wrapper) + + @property + def with_raw_response(self) -> AsyncRawTurnsClient: + """ + Retrieves a raw implementation of this client that returns raw responses. + + Returns + ------- + AsyncRawTurnsClient + """ + return self._raw_client + + async def close_zombie( + self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> None: + """ + Cancels a zombie turn (turn.done with abandoned reason). + + Parameters + ---------- + session_id : str + Session that owns the turn. + + tenant_id : str + Tenant that owns the session. + + turn_id : str + Turn to close. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from trueforge_sdk import AsyncTrueForge + + client = AsyncTrueForge( + token="YOUR_TOKEN", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.internal.turns.close_zombie( + session_id="session_id", + tenant_id="tenant_id", + turn_id="turn_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.close_zombie( + session_id=session_id, tenant_id=tenant_id, turn_id=turn_id, request_options=request_options + ) + return _response.data + + async def list_zombie(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListZombieTurnsResponse: + """ + Returns all running turns with no events for 15+ minutes. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ListZombieTurnsResponse + Stale running turns oldest-first. + + Examples + -------- + import asyncio + + from trueforge_sdk import AsyncTrueForge + + client = AsyncTrueForge( + token="YOUR_TOKEN", + base_url="https://yourhost.com/path/to/api", + ) + + + async def main() -> None: + await client.internal.turns.list_zombie() + + + asyncio.run(main()) + """ + _response = await self._raw_client.list_zombie(request_options=request_options) + return _response.data diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py new file mode 100644 index 000000000..bffbca65e --- /dev/null +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py @@ -0,0 +1,275 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing +from json.decoder import JSONDecodeError + +from ...core.api_error import ApiError +from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from ...core.http_response import AsyncHttpResponse, HttpResponse +from ...core.parse_error import ParsingError +from ...core.request_options import RequestOptions +from ...core.unchecked_base_model import construct_type +from ...errors.not_found_error import NotFoundError +from ...errors.unauthorized_error import UnauthorizedError +from ...types.list_zombie_turns_response import ListZombieTurnsResponse +from ...types.request_error_response import RequestErrorResponse +from pydantic import ValidationError + +# this is used as the default value for optional parameters +OMIT = typing.cast(typing.Any, ...) + + +class RawTurnsClient: + def __init__(self, *, client_wrapper: SyncClientWrapper): + self._client_wrapper = client_wrapper + + def close_zombie( + self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[None]: + """ + Cancels a zombie turn (turn.done with abandoned reason). + + Parameters + ---------- + session_id : str + Session that owns the turn. + + tenant_id : str + Tenant that owns the session. + + turn_id : str + Turn to close. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + "api/internal/turns/close-zombie", + method="POST", + json={ + "session_id": session_id, + "tenant_id": tenant_id, + "turn_id": turn_id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + RequestErrorResponse, + construct_type( + type_=RequestErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + RequestErrorResponse, + construct_type( + type_=RequestErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def list_zombie( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[ListZombieTurnsResponse]: + """ + Returns all running turns with no events for 15+ minutes. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListZombieTurnsResponse] + Stale running turns oldest-first. + """ + _response = self._client_wrapper.httpx_client.request( + "api/internal/turns/zombie", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListZombieTurnsResponse, + construct_type( + type_=ListZombieTurnsResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + RequestErrorResponse, + construct_type( + type_=RequestErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + +class AsyncRawTurnsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def close_zombie( + self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[None]: + """ + Cancels a zombie turn (turn.done with abandoned reason). + + Parameters + ---------- + session_id : str + Session that owns the turn. + + tenant_id : str + Tenant that owns the session. + + turn_id : str + Turn to close. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + "api/internal/turns/close-zombie", + method="POST", + json={ + "session_id": session_id, + "tenant_id": tenant_id, + "turn_id": turn_id, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + RequestErrorResponse, + construct_type( + type_=RequestErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + RequestErrorResponse, + construct_type( + type_=RequestErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def list_zombie( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ListZombieTurnsResponse]: + """ + Returns all running turns with no events for 15+ minutes. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ListZombieTurnsResponse] + Stale running turns oldest-first. + """ + _response = await self._client_wrapper.httpx_client.request( + "api/internal/turns/zombie", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListZombieTurnsResponse, + construct_type( + type_=ListZombieTurnsResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + RequestErrorResponse, + construct_type( + type_=RequestErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/python/trueforge_sdk/src/trueforge_sdk/types/__init__.py b/python/trueforge_sdk/src/trueforge_sdk/types/__init__.py index 6a92da596..7be6f3e1f 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/types/__init__.py +++ b/python/trueforge_sdk/src/trueforge_sdk/types/__init__.py @@ -123,6 +123,7 @@ from .list_turn_events_order import ListTurnEventsOrder from .list_turn_events_response import ListTurnEventsResponse from .list_turns_response import ListTurnsResponse + from .list_zombie_turns_response import ListZombieTurnsResponse from .mcp_auth_required_event import McpAuthRequiredEvent from .mcp_auth_status import McpAuthStatus from .mcp_auth_status_status import McpAuthStatusStatus @@ -275,6 +276,7 @@ from .web_search_config import WebSearchConfig from .web_search_provider_manifest import WebSearchProviderManifest from .zai_model_provider import ZaiModelProvider + from .zombie_turn import ZombieTurn _dynamic_imports: typing.Dict[str, str] = { "ActionRequired": ".action_required", "ActionRequiredEvent": ".action_required_event", @@ -393,6 +395,7 @@ "ListTurnEventsOrder": ".list_turn_events_order", "ListTurnEventsResponse": ".list_turn_events_response", "ListTurnsResponse": ".list_turns_response", + "ListZombieTurnsResponse": ".list_zombie_turns_response", "McpAuthRequiredEvent": ".mcp_auth_required_event", "McpAuthStatus": ".mcp_auth_status", "McpAuthStatusStatus": ".mcp_auth_status_status", @@ -545,6 +548,7 @@ "WebSearchConfig": ".web_search_config", "WebSearchProviderManifest": ".web_search_provider_manifest", "ZaiModelProvider": ".zai_model_provider", + "ZombieTurn": ".zombie_turn", } @@ -687,6 +691,7 @@ def __dir__(): "ListTurnEventsOrder", "ListTurnEventsResponse", "ListTurnsResponse", + "ListZombieTurnsResponse", "McpAuthRequiredEvent", "McpAuthStatus", "McpAuthStatusStatus", @@ -839,4 +844,5 @@ def __dir__(): "WebSearchConfig", "WebSearchProviderManifest", "ZaiModelProvider", + "ZombieTurn", ] diff --git a/python/trueforge_sdk/src/trueforge_sdk/types/list_zombie_turns_response.py b/python/trueforge_sdk/src/trueforge_sdk/types/list_zombie_turns_response.py new file mode 100644 index 000000000..9c3407db3 --- /dev/null +++ b/python/trueforge_sdk/src/trueforge_sdk/types/list_zombie_turns_response.py @@ -0,0 +1,20 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.unchecked_base_model import UncheckedBaseModel +from .zombie_turn import ZombieTurn + + +class ListZombieTurnsResponse(UncheckedBaseModel): + data: typing.List[ZombieTurn] + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + smart_union = True + extra = pydantic.Extra.allow diff --git a/python/trueforge_sdk/src/trueforge_sdk/types/zombie_turn.py b/python/trueforge_sdk/src/trueforge_sdk/types/zombie_turn.py new file mode 100644 index 000000000..4153c9270 --- /dev/null +++ b/python/trueforge_sdk/src/trueforge_sdk/types/zombie_turn.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2 +from ..core.unchecked_base_model import UncheckedBaseModel + + +class ZombieTurn(UncheckedBaseModel): + session_id: str = pydantic.Field() + """ + Session that owns the turn. + """ + + tenant_id: str = pydantic.Field() + """ + Tenant that owns the session. + """ + + turn_id: str = pydantic.Field() + """ + Running turn with stale last activity. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2 + else: + + class Config: + smart_union = True + extra = pydantic.Extra.allow From e77649f38cbe8fd53c53cfc08742c805bb34ac25 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Mon, 19 Oct 2026 14:47:13 +0000 Subject: [PATCH 2/3] Regenerate OpenAPI document and SDKs --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- packages/trueforge-sdk/reference.md | 2 +- .../api/resources/internal/resources/turns/client/Client.ts | 2 +- python/trueforge_sdk/reference.md | 2 +- .../trueforge_sdk/src/trueforge_sdk/internal/turns/client.py | 4 ++-- .../src/trueforge_sdk/internal/turns/raw_client.py | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 2e86e87b4..049b2c3f3 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -6819,7 +6819,7 @@ }, "/api/internal/turns/zombie": { "get": { - "description": "Returns all running turns with no events for 15+ minutes.", + "description": "Returns all running turns whose last activity is past the stale threshold.", "responses": { "200": { "content": { diff --git a/docs/openapi.json b/docs/openapi.json index 2e86e87b4..049b2c3f3 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -6819,7 +6819,7 @@ }, "/api/internal/turns/zombie": { "get": { - "description": "Returns all running turns with no events for 15+ minutes.", + "description": "Returns all running turns whose last activity is past the stale threshold.", "responses": { "200": { "content": { diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 7892611fa..95a931f7d 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -3785,7 +3785,7 @@ await client.internal.turns.closeZombie({
-Returns all running turns with no events for 15+ minutes. +Returns all running turns whose last activity is past the stale threshold.
diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts index adfe1be3b..47a9ee299 100644 --- a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts @@ -130,7 +130,7 @@ export class TurnsClient { } /** - * Returns all running turns with no events for 15+ minutes. + * Returns all running turns whose last activity is past the stale threshold. * * @param {TurnsClient.RequestOptions} requestOptions - Request-specific configuration. * diff --git a/python/trueforge_sdk/reference.md b/python/trueforge_sdk/reference.md index c35dcb7b5..ffb7893fd 100644 --- a/python/trueforge_sdk/reference.md +++ b/python/trueforge_sdk/reference.md @@ -4486,7 +4486,7 @@ client.internal.turns.close_zombie(
-Returns all running turns with no events for 15+ minutes. +Returns all running turns whose last activity is past the stale threshold.
diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py index 08c2c5226..99fe0db42 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py @@ -71,7 +71,7 @@ def close_zombie( def list_zombie(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListZombieTurnsResponse: """ - Returns all running turns with no events for 15+ minutes. + Returns all running turns whose last activity is past the stale threshold. Parameters ---------- @@ -165,7 +165,7 @@ async def main() -> None: async def list_zombie(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListZombieTurnsResponse: """ - Returns all running turns with no events for 15+ minutes. + Returns all running turns whose last activity is past the stale threshold. Parameters ---------- diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py index bffbca65e..9ea37e4ad 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py @@ -99,7 +99,7 @@ def list_zombie( self, *, request_options: typing.Optional[RequestOptions] = None ) -> HttpResponse[ListZombieTurnsResponse]: """ - Returns all running turns with no events for 15+ minutes. + Returns all running turns whose last activity is past the stale threshold. Parameters ---------- @@ -227,7 +227,7 @@ async def list_zombie( self, *, request_options: typing.Optional[RequestOptions] = None ) -> AsyncHttpResponse[ListZombieTurnsResponse]: """ - Returns all running turns with no events for 15+ minutes. + Returns all running turns whose last activity is past the stale threshold. Parameters ---------- From 433eea460837d09a04170e83ef4814558a18c202 Mon Sep 17 00:00:00 2001 From: thesujai Date: Mon, 19 Oct 2026 20:31:13 +0530 Subject: [PATCH 3/3] feat: implement abandoned turn management with new internal API endpoints --- ...urn-closer.md => abandoned-turn-closer.md} | 2 +- .github/fern/openapi/openapi.json | 150 ++++++++--------- docs/openapi.json | 150 ++++++++--------- .../src/agent-session/schemas/turn.ts | 2 +- packages/trueforge-sdk/reference.md | 36 ++-- .../internal/resources/turns/client/Client.ts | 132 +++++++-------- ...equest.ts => CloseAbandonedTurnRequest.ts} | 2 +- .../resources/turns/client/requests/index.ts | 2 +- .../types/{ZombieTurn.ts => AbandonedTurn.ts} | 2 +- ...ponse.ts => ListAbandonedTurnsResponse.ts} | 4 +- packages/trueforge-sdk/src/api/types/index.ts | 4 +- ...equest.ts => CloseAbandonedTurnRequest.ts} | 8 +- .../resources/turns/client/requests/index.ts | 2 +- .../types/{ZombieTurn.ts => AbandonedTurn.ts} | 4 +- .../types/ListAbandonedTurnsResponse.ts | 19 +++ .../types/ListZombieTurnsResponse.ts | 19 --- .../src/serialization/types/index.ts | 4 +- .../tests/wire/internal/turns.test.ts | 104 ++++++------ packages/trueforge/.env.example | 8 +- .../{zombieTurns.ts => abandonedTurns.ts} | 18 +- packages/trueforge/src/app.ts | 4 +- packages/trueforge/src/config.ts | 36 ++-- packages/trueforge/src/controller.ts | 6 +- .../src/controller/abandonedTurnCloser.ts | 79 +++++++++ .../src/controller/internalTrueForgeClient.ts | 8 +- .../src/controller/zombieTurnCloser.ts | 79 --------- .../postgres/session-store/queries/turns.ts | 14 +- .../db/sqlite/session-store/queries/turns.ts | 14 +- ...ieTurnRoutes.ts => abandonedTurnRoutes.ts} | 26 +-- .../{zombieTurn.ts => abandonedTurn.ts} | 16 +- .../tests/unit/apis/abandonedTurns.test.ts | 156 ++++++++++++++++++ .../controller/abandonedTurnCloser.test.ts | 57 +++++++ .../unit/controller/zombieTurnCloser.test.ts | 57 ------- python/trueforge_sdk/reference.md | 68 ++++---- .../src/trueforge_sdk/__init__.py | 12 +- .../trueforge_sdk/internal/turns/client.py | 116 ++++++------- .../internal/turns/raw_client.py | 142 ++++++++-------- .../src/trueforge_sdk/types/__init__.py | 12 +- .../{zombie_turn.py => abandoned_turn.py} | 2 +- ...se.py => list_abandoned_turns_response.py} | 6 +- 40 files changed, 876 insertions(+), 706 deletions(-) rename .changeset/{zombie-turn-closer.md => abandoned-turn-closer.md} (75%) rename packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/{CloseZombieTurnRequest.ts => CloseAbandonedTurnRequest.ts} (89%) rename packages/trueforge-sdk/src/api/types/{ZombieTurn.ts => AbandonedTurn.ts} (88%) rename packages/trueforge-sdk/src/api/types/{ListZombieTurnsResponse.ts => ListAbandonedTurnsResponse.ts} (58%) rename packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/{CloseZombieTurnRequest.ts => CloseAbandonedTurnRequest.ts} (74%) rename packages/trueforge-sdk/src/serialization/types/{ZombieTurn.ts => AbandonedTurn.ts} (80%) create mode 100644 packages/trueforge-sdk/src/serialization/types/ListAbandonedTurnsResponse.ts delete mode 100644 packages/trueforge-sdk/src/serialization/types/ListZombieTurnsResponse.ts rename packages/trueforge/src/apis/{zombieTurns.ts => abandonedTurns.ts} (70%) create mode 100644 packages/trueforge/src/controller/abandonedTurnCloser.ts delete mode 100644 packages/trueforge/src/controller/zombieTurnCloser.ts rename packages/trueforge/src/routes/{zombieTurnRoutes.ts => abandonedTurnRoutes.ts} (61%) rename packages/trueforge/src/schemas/{zombieTurn.ts => abandonedTurn.ts} (62%) create mode 100644 packages/trueforge/tests/unit/apis/abandonedTurns.test.ts create mode 100644 packages/trueforge/tests/unit/controller/abandonedTurnCloser.test.ts delete mode 100644 packages/trueforge/tests/unit/controller/zombieTurnCloser.test.ts rename python/trueforge_sdk/src/trueforge_sdk/types/{zombie_turn.py => abandoned_turn.py} (94%) rename python/trueforge_sdk/src/trueforge_sdk/types/{list_zombie_turns_response.py => list_abandoned_turns_response.py} (77%) diff --git a/.changeset/zombie-turn-closer.md b/.changeset/abandoned-turn-closer.md similarity index 75% rename from .changeset/zombie-turn-closer.md rename to .changeset/abandoned-turn-closer.md index 7a9f56740..812bdf6e5 100644 --- a/.changeset/zombie-turn-closer.md +++ b/.changeset/abandoned-turn-closer.md @@ -3,4 +3,4 @@ '@truefoundry/trueforge': patch --- -Close stale running turns via a controller loop and internal list/close endpoints (15 minutes with no events). +Close stale running turns via a controller loop and internal list/close endpoints (abandoned reason). diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 049b2c3f3..6f9427a69 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -2,6 +2,32 @@ "components": { "parameters": {}, "schemas": { + "AbandonedTurn": { + "additionalProperties": false, + "properties": { + "session_id": { + "description": "Session that owns the turn.", + "minLength": 1, + "type": "string" + }, + "tenant_id": { + "description": "Tenant that owns the session.", + "minLength": 1, + "type": "string" + }, + "turn_id": { + "description": "Running turn with stale last activity.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tenant_id", + "session_id", + "turn_id" + ], + "type": "object" + }, "ActionRequired": { "properties": { "id": { @@ -861,7 +887,7 @@ ], "type": "object" }, - "CloseZombieTurnRequest": { + "CloseAbandonedTurnRequest": { "additionalProperties": false, "properties": { "session_id": { @@ -1916,6 +1942,21 @@ }, "type": "object" }, + "ListAbandonedTurnsResponse": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AbandonedTurn" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "ListAgentsResponse": { "properties": { "data": { @@ -2261,21 +2302,6 @@ ], "type": "object" }, - "ListZombieTurnsResponse": { - "additionalProperties": false, - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/ZombieTurn" - }, - "type": "array" - } - }, - "required": [ - "data" - ], - "type": "object" - }, "MCPAuthRequiredEvent": { "allOf": [ { @@ -6251,32 +6277,6 @@ "type" ], "type": "object" - }, - "ZombieTurn": { - "additionalProperties": false, - "properties": { - "session_id": { - "description": "Session that owns the turn.", - "minLength": 1, - "type": "string" - }, - "tenant_id": { - "description": "Tenant that owns the session.", - "minLength": 1, - "type": "string" - }, - "turn_id": { - "description": "Running turn with stale last activity.", - "minLength": 1, - "type": "string" - } - }, - "required": [ - "tenant_id", - "session_id", - "turn_id" - ], - "type": "object" } }, "securitySchemes": { @@ -6767,34 +6767,21 @@ "x-fern-sdk-method-name": "get_or_create_by_external_id" } }, - "/api/internal/turns/close-zombie": { - "post": { - "description": "Cancels a zombie turn (turn.done with abandoned reason).", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloseZombieTurnRequest" - } - } - }, - "required": true - }, + "/api/internal/turns/abandoned": { + "get": { + "description": "Returns all running turns whose last activity is past the stale threshold.", "responses": { - "204": { - "description": "Closed (or already terminal / missing turn)." - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestErrorResponse" + "$ref": "#/components/schemas/ListAbandonedTurnsResponse" } } }, - "description": "Invalid service credential." + "description": "Stale running turns oldest-first." }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -6802,10 +6789,10 @@ } } }, - "description": "Session not found." + "description": "Invalid service credential." } }, - "summary": "Close a stale running turn", + "summary": "List abandoned running turns", "tags": [ "Internal" ], @@ -6814,24 +6801,37 @@ "internal", "turns" ], - "x-fern-sdk-method-name": "close_zombie" + "x-fern-sdk-method-name": "list_abandoned" } }, - "/api/internal/turns/zombie": { - "get": { - "description": "Returns all running turns whose last activity is past the stale threshold.", + "/api/internal/turns/close-abandoned": { + "post": { + "description": "Cancels a stale running turn (turn.done with abandoned reason).", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloseAbandonedTurnRequest" + } + } + }, + "required": true + }, "responses": { - "200": { + "204": { + "description": "Closed (or already terminal / missing turn)." + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListZombieTurnsResponse" + "$ref": "#/components/schemas/RequestErrorResponse" } } }, - "description": "Stale running turns oldest-first." + "description": "Invalid service credential." }, - "401": { + "404": { "content": { "application/json": { "schema": { @@ -6839,10 +6839,10 @@ } } }, - "description": "Invalid service credential." + "description": "Session not found." } }, - "summary": "List stale running turns", + "summary": "Close an abandoned running turn", "tags": [ "Internal" ], @@ -6851,7 +6851,7 @@ "internal", "turns" ], - "x-fern-sdk-method-name": "list_zombie" + "x-fern-sdk-method-name": "close_abandoned" } }, "/api/v1/agents": { diff --git a/docs/openapi.json b/docs/openapi.json index 049b2c3f3..6f9427a69 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,6 +2,32 @@ "components": { "parameters": {}, "schemas": { + "AbandonedTurn": { + "additionalProperties": false, + "properties": { + "session_id": { + "description": "Session that owns the turn.", + "minLength": 1, + "type": "string" + }, + "tenant_id": { + "description": "Tenant that owns the session.", + "minLength": 1, + "type": "string" + }, + "turn_id": { + "description": "Running turn with stale last activity.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tenant_id", + "session_id", + "turn_id" + ], + "type": "object" + }, "ActionRequired": { "properties": { "id": { @@ -861,7 +887,7 @@ ], "type": "object" }, - "CloseZombieTurnRequest": { + "CloseAbandonedTurnRequest": { "additionalProperties": false, "properties": { "session_id": { @@ -1916,6 +1942,21 @@ }, "type": "object" }, + "ListAbandonedTurnsResponse": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AbandonedTurn" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "ListAgentsResponse": { "properties": { "data": { @@ -2261,21 +2302,6 @@ ], "type": "object" }, - "ListZombieTurnsResponse": { - "additionalProperties": false, - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/ZombieTurn" - }, - "type": "array" - } - }, - "required": [ - "data" - ], - "type": "object" - }, "MCPAuthRequiredEvent": { "allOf": [ { @@ -6251,32 +6277,6 @@ "type" ], "type": "object" - }, - "ZombieTurn": { - "additionalProperties": false, - "properties": { - "session_id": { - "description": "Session that owns the turn.", - "minLength": 1, - "type": "string" - }, - "tenant_id": { - "description": "Tenant that owns the session.", - "minLength": 1, - "type": "string" - }, - "turn_id": { - "description": "Running turn with stale last activity.", - "minLength": 1, - "type": "string" - } - }, - "required": [ - "tenant_id", - "session_id", - "turn_id" - ], - "type": "object" } }, "securitySchemes": { @@ -6767,34 +6767,21 @@ "x-fern-sdk-method-name": "get_or_create_by_external_id" } }, - "/api/internal/turns/close-zombie": { - "post": { - "description": "Cancels a zombie turn (turn.done with abandoned reason).", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloseZombieTurnRequest" - } - } - }, - "required": true - }, + "/api/internal/turns/abandoned": { + "get": { + "description": "Returns all running turns whose last activity is past the stale threshold.", "responses": { - "204": { - "description": "Closed (or already terminal / missing turn)." - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestErrorResponse" + "$ref": "#/components/schemas/ListAbandonedTurnsResponse" } } }, - "description": "Invalid service credential." + "description": "Stale running turns oldest-first." }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -6802,10 +6789,10 @@ } } }, - "description": "Session not found." + "description": "Invalid service credential." } }, - "summary": "Close a stale running turn", + "summary": "List abandoned running turns", "tags": [ "Internal" ], @@ -6814,24 +6801,37 @@ "internal", "turns" ], - "x-fern-sdk-method-name": "close_zombie" + "x-fern-sdk-method-name": "list_abandoned" } }, - "/api/internal/turns/zombie": { - "get": { - "description": "Returns all running turns whose last activity is past the stale threshold.", + "/api/internal/turns/close-abandoned": { + "post": { + "description": "Cancels a stale running turn (turn.done with abandoned reason).", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloseAbandonedTurnRequest" + } + } + }, + "required": true + }, "responses": { - "200": { + "204": { + "description": "Closed (or already terminal / missing turn)." + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListZombieTurnsResponse" + "$ref": "#/components/schemas/RequestErrorResponse" } } }, - "description": "Stale running turns oldest-first." + "description": "Invalid service credential." }, - "401": { + "404": { "content": { "application/json": { "schema": { @@ -6839,10 +6839,10 @@ } } }, - "description": "Invalid service credential." + "description": "Session not found." } }, - "summary": "List stale running turns", + "summary": "Close an abandoned running turn", "tags": [ "Internal" ], @@ -6851,7 +6851,7 @@ "internal", "turns" ], - "x-fern-sdk-method-name": "list_zombie" + "x-fern-sdk-method-name": "close_abandoned" } }, "/api/v1/agents": { diff --git a/packages/trueforge-core/src/agent-session/schemas/turn.ts b/packages/trueforge-core/src/agent-session/schemas/turn.ts index 55911505e..6f2005f37 100644 --- a/packages/trueforge-core/src/agent-session/schemas/turn.ts +++ b/packages/trueforge-core/src/agent-session/schemas/turn.ts @@ -18,7 +18,7 @@ export enum CancellationReason { ClientCancelled = 'client-cancelled', // Prior turn aborted because the client started a new turn. CancelledForNextTurn = 'cancelled-for-next-turn', - // Process shutting down (SIGTERM/SIGINT), or controller closed a zombie turn. + // Process shutting down (SIGTERM/SIGINT), or controller closed a stale running turn. Abandoned = 'abandoned', } diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 95a931f7d..30944b8e4 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -3706,7 +3706,7 @@ await client.internal.sessions.getOrCreateByExternalId({ ## Internal Turns -
client.internal.turns.closeZombie({ ...params }) -> void +
client.internal.turns.listAbandoned() -> TrueForge.ListAbandonedTurnsResponse
@@ -3718,7 +3718,7 @@ await client.internal.sessions.getOrCreateByExternalId({
-Cancels a zombie turn (turn.done with abandoned reason). +Returns all running turns whose last activity is past the stale threshold.
@@ -3733,11 +3733,7 @@ Cancels a zombie turn (turn.done with abandoned reason).
```typescript -await client.internal.turns.closeZombie({ - sessionId: "session_id", - tenantId: "tenant_id", - turnId: "turn_id" -}); +await client.internal.turns.listAbandoned(); ```
@@ -3753,14 +3749,6 @@ await client.internal.turns.closeZombie({
-**request:** `TrueForge.internal.CloseZombieTurnRequest` - -
-
- -
-
- **requestOptions:** `TurnsClient.RequestOptions`
@@ -3773,7 +3761,7 @@ await client.internal.turns.closeZombie({
-
client.internal.turns.listZombie() -> TrueForge.ListZombieTurnsResponse +
client.internal.turns.closeAbandoned({ ...params }) -> void
@@ -3785,7 +3773,7 @@ await client.internal.turns.closeZombie({
-Returns all running turns whose last activity is past the stale threshold. +Cancels a stale running turn (turn.done with abandoned reason).
@@ -3800,7 +3788,11 @@ Returns all running turns whose last activity is past the stale threshold.
```typescript -await client.internal.turns.listZombie(); +await client.internal.turns.closeAbandoned({ + sessionId: "session_id", + tenantId: "tenant_id", + turnId: "turn_id" +}); ```
@@ -3816,6 +3808,14 @@ await client.internal.turns.listZombie();
+**request:** `TrueForge.internal.CloseAbandonedTurnRequest` + +
+
+ +
+
+ **requestOptions:** `TurnsClient.RequestOptions`
diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts index 47a9ee299..c51f752ef 100644 --- a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/Client.ts @@ -24,34 +24,26 @@ export class TurnsClient { } /** - * Cancels a zombie turn (turn.done with abandoned reason). + * Returns all running turns whose last activity is past the stale threshold. * - * @param {TrueForge.internal.CloseZombieTurnRequest} request * @param {TurnsClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link TrueForge.UnauthorizedError} - * @throws {@link TrueForge.NotFoundError} * @throws {@link errors.TrueForgeError} * @throws {@link errors.TrueForgeTimeoutError} * * @example - * await client.internal.turns.closeZombie({ - * sessionId: "session_id", - * tenantId: "tenant_id", - * turnId: "turn_id" - * }) + * await client.internal.turns.listAbandoned() */ - public closeZombie( - request: TrueForge.internal.CloseZombieTurnRequest, + public listAbandoned( requestOptions?: TurnsClient.RequestOptions, - ): core.HttpResponsePromise { - return core.HttpResponsePromise.fromPromise(this.__closeZombie(request, requestOptions)); + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__listAbandoned(requestOptions)); } - private async __closeZombie( - request: TrueForge.internal.CloseZombieTurnRequest, + private async __listAbandoned( requestOptions?: TurnsClient.RequestOptions, - ): Promise> { + ): Promise> { const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, @@ -62,22 +54,11 @@ export class TurnsClient { url: core.url.join( (await core.Supplier.get(this._options.baseUrl)) ?? (await core.Supplier.get(this._options.environment)), - "api/internal/turns/close-zombie", + "api/internal/turns/abandoned", ), - method: "POST", + method: "GET", headers: _headers, - contentType: "application/json", queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), - requestType: "json", - body: mergeAdditionalBodyParameters( - serializers.internal.CloseZombieTurnRequest.jsonOrThrow(request, { - unrecognizedObjectKeys: "passthrough", - allowUnrecognizedUnionMembers: true, - allowUnrecognizedEnumValues: true, - omitUndefined: true, - }), - requestOptions?.additionalBodyParameters, - ), timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, abortSignal: requestOptions?.abortSignal, @@ -85,7 +66,16 @@ export class TurnsClient { logging: this._options.logging, }); if (_response.ok) { - return { data: undefined, rawResponse: _response.rawResponse }; + return { + data: serializers.ListAbandonedTurnsResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + rawResponse: _response.rawResponse, + }; } if (_response.error.reason === "status-code") { @@ -101,17 +91,6 @@ export class TurnsClient { }), _response.rawResponse, ); - case 404: - throw new TrueForge.NotFoundError( - serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { - unrecognizedObjectKeys: "passthrough", - allowUnrecognizedUnionMembers: true, - allowUnrecognizedEnumValues: true, - skipValidation: true, - breadcrumbsPrefix: ["response"], - }), - _response.rawResponse, - ); default: throw new errors.TrueForgeError({ statusCode: _response.error.statusCode, @@ -121,35 +100,38 @@ export class TurnsClient { } } - return handleNonStatusCodeError( - _response.error, - _response.rawResponse, - "POST", - "/api/internal/turns/close-zombie", - ); + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/api/internal/turns/abandoned"); } /** - * Returns all running turns whose last activity is past the stale threshold. + * Cancels a stale running turn (turn.done with abandoned reason). * + * @param {TrueForge.internal.CloseAbandonedTurnRequest} request * @param {TurnsClient.RequestOptions} requestOptions - Request-specific configuration. * * @throws {@link TrueForge.UnauthorizedError} + * @throws {@link TrueForge.NotFoundError} * @throws {@link errors.TrueForgeError} * @throws {@link errors.TrueForgeTimeoutError} * * @example - * await client.internal.turns.listZombie() + * await client.internal.turns.closeAbandoned({ + * sessionId: "session_id", + * tenantId: "tenant_id", + * turnId: "turn_id" + * }) */ - public listZombie( + public closeAbandoned( + request: TrueForge.internal.CloseAbandonedTurnRequest, requestOptions?: TurnsClient.RequestOptions, - ): core.HttpResponsePromise { - return core.HttpResponsePromise.fromPromise(this.__listZombie(requestOptions)); + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__closeAbandoned(request, requestOptions)); } - private async __listZombie( + private async __closeAbandoned( + request: TrueForge.internal.CloseAbandonedTurnRequest, requestOptions?: TurnsClient.RequestOptions, - ): Promise> { + ): Promise> { const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); const _headers: core.Fetcher.Args["headers"] = mergeHeaders( _authRequest.headers, @@ -160,11 +142,22 @@ export class TurnsClient { url: core.url.join( (await core.Supplier.get(this._options.baseUrl)) ?? (await core.Supplier.get(this._options.environment)), - "api/internal/turns/zombie", + "api/internal/turns/close-abandoned", ), - method: "GET", + method: "POST", headers: _headers, + contentType: "application/json", queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + requestType: "json", + body: mergeAdditionalBodyParameters( + serializers.internal.CloseAbandonedTurnRequest.jsonOrThrow(request, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + omitUndefined: true, + }), + requestOptions?.additionalBodyParameters, + ), timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, abortSignal: requestOptions?.abortSignal, @@ -172,16 +165,7 @@ export class TurnsClient { logging: this._options.logging, }); if (_response.ok) { - return { - data: serializers.ListZombieTurnsResponse.parseOrThrow(_response.body, { - unrecognizedObjectKeys: "passthrough", - allowUnrecognizedUnionMembers: true, - allowUnrecognizedEnumValues: true, - skipValidation: true, - breadcrumbsPrefix: ["response"], - }), - rawResponse: _response.rawResponse, - }; + return { data: undefined, rawResponse: _response.rawResponse }; } if (_response.error.reason === "status-code") { @@ -197,6 +181,17 @@ export class TurnsClient { }), _response.rawResponse, ); + case 404: + throw new TrueForge.NotFoundError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); default: throw new errors.TrueForgeError({ statusCode: _response.error.statusCode, @@ -206,6 +201,11 @@ export class TurnsClient { } } - return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/api/internal/turns/zombie"); + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/api/internal/turns/close-abandoned", + ); } } diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseAbandonedTurnRequest.ts similarity index 89% rename from packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts rename to packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseAbandonedTurnRequest.ts index 38fe9fd4c..ccecf290b 100644 --- a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/CloseAbandonedTurnRequest.ts @@ -8,7 +8,7 @@ * turnId: "turn_id" * } */ -export interface CloseZombieTurnRequest { +export interface CloseAbandonedTurnRequest { /** Session that owns the turn. */ sessionId: string; /** Tenant that owns the session. */ diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/index.ts index 5e1345aa5..05220cf4d 100644 --- a/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/index.ts +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/turns/client/requests/index.ts @@ -1 +1 @@ -export type { CloseZombieTurnRequest } from "./CloseZombieTurnRequest.js"; +export type { CloseAbandonedTurnRequest } from "./CloseAbandonedTurnRequest.js"; diff --git a/packages/trueforge-sdk/src/api/types/ZombieTurn.ts b/packages/trueforge-sdk/src/api/types/AbandonedTurn.ts similarity index 88% rename from packages/trueforge-sdk/src/api/types/ZombieTurn.ts rename to packages/trueforge-sdk/src/api/types/AbandonedTurn.ts index 31a9c68ce..e85e8ee2c 100644 --- a/packages/trueforge-sdk/src/api/types/ZombieTurn.ts +++ b/packages/trueforge-sdk/src/api/types/AbandonedTurn.ts @@ -1,6 +1,6 @@ // This file was auto-generated by Fern from our API Definition. -export interface ZombieTurn { +export interface AbandonedTurn { /** Session that owns the turn. */ sessionId: string; /** Tenant that owns the session. */ diff --git a/packages/trueforge-sdk/src/api/types/ListZombieTurnsResponse.ts b/packages/trueforge-sdk/src/api/types/ListAbandonedTurnsResponse.ts similarity index 58% rename from packages/trueforge-sdk/src/api/types/ListZombieTurnsResponse.ts rename to packages/trueforge-sdk/src/api/types/ListAbandonedTurnsResponse.ts index 4597d90b9..14b8f59db 100644 --- a/packages/trueforge-sdk/src/api/types/ListZombieTurnsResponse.ts +++ b/packages/trueforge-sdk/src/api/types/ListAbandonedTurnsResponse.ts @@ -2,6 +2,6 @@ import type * as TrueForge from "../index.js"; -export interface ListZombieTurnsResponse { - data: TrueForge.ZombieTurn[]; +export interface ListAbandonedTurnsResponse { + data: TrueForge.AbandonedTurn[]; } diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index c9c71ede2..fa4010c31 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -1,3 +1,4 @@ +export * from "./AbandonedTurn.js"; export * from "./ActionRequired.js"; export * from "./ActionRequiredEvent.js"; export * from "./Agent.js"; @@ -94,6 +95,7 @@ export * from "./GoogleGeminiModelProvider.js"; export * from "./InitialUserMessage.js"; export * from "./InputTokensCompactionTrigger.js"; export * from "./LargeToolResponseConfig.js"; +export * from "./ListAbandonedTurnsResponse.js"; export * from "./ListAgentsResponse.js"; export * from "./ListAvailableMcpServersResponse.js"; export * from "./ListAvailableModelsResponse.js"; @@ -115,7 +117,6 @@ export * from "./ListSkillVersionsResponse.js"; export * from "./ListTurnEventsOrder.js"; export * from "./ListTurnEventsResponse.js"; export * from "./ListTurnsResponse.js"; -export * from "./ListZombieTurnsResponse.js"; export * from "./McpAuthRequiredEvent.js"; export * from "./McpAuthStatus.js"; export * from "./McpAuthStatusStatus.js"; @@ -268,4 +269,3 @@ export * from "./WebSearchCapability.js"; export * from "./WebSearchConfig.js"; export * from "./WebSearchProviderManifest.js"; export * from "./ZaiModelProvider.js"; -export * from "./ZombieTurn.js"; diff --git a/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseAbandonedTurnRequest.ts similarity index 74% rename from packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts rename to packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseAbandonedTurnRequest.ts index 27ce8258c..aeb89f8cd 100644 --- a/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseZombieTurnRequest.ts +++ b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/CloseAbandonedTurnRequest.ts @@ -4,16 +4,16 @@ import type * as TrueForge from "../../../../../../../api/index.js"; import * as core from "../../../../../../../core/index.js"; import type * as serializers from "../../../../../../index.js"; -export const CloseZombieTurnRequest: core.serialization.Schema< - serializers.internal.CloseZombieTurnRequest.Raw, - TrueForge.internal.CloseZombieTurnRequest +export const CloseAbandonedTurnRequest: core.serialization.Schema< + serializers.internal.CloseAbandonedTurnRequest.Raw, + TrueForge.internal.CloseAbandonedTurnRequest > = core.serialization.object({ sessionId: core.serialization.property("session_id", core.serialization.string()), tenantId: core.serialization.property("tenant_id", core.serialization.string()), turnId: core.serialization.property("turn_id", core.serialization.string()), }); -export declare namespace CloseZombieTurnRequest { +export declare namespace CloseAbandonedTurnRequest { export interface Raw { session_id: string; tenant_id: string; diff --git a/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/index.ts b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/index.ts index e90889ed4..9c8f1e270 100644 --- a/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/index.ts +++ b/packages/trueforge-sdk/src/serialization/resources/internal/resources/turns/client/requests/index.ts @@ -1 +1 @@ -export { CloseZombieTurnRequest } from "./CloseZombieTurnRequest.js"; +export { CloseAbandonedTurnRequest } from "./CloseAbandonedTurnRequest.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/ZombieTurn.ts b/packages/trueforge-sdk/src/serialization/types/AbandonedTurn.ts similarity index 80% rename from packages/trueforge-sdk/src/serialization/types/ZombieTurn.ts rename to packages/trueforge-sdk/src/serialization/types/AbandonedTurn.ts index 192f67405..d0fb79370 100644 --- a/packages/trueforge-sdk/src/serialization/types/ZombieTurn.ts +++ b/packages/trueforge-sdk/src/serialization/types/AbandonedTurn.ts @@ -4,14 +4,14 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; -export const ZombieTurn: core.serialization.ObjectSchema = +export const AbandonedTurn: core.serialization.ObjectSchema = core.serialization.object({ sessionId: core.serialization.property("session_id", core.serialization.string()), tenantId: core.serialization.property("tenant_id", core.serialization.string()), turnId: core.serialization.property("turn_id", core.serialization.string()), }); -export declare namespace ZombieTurn { +export declare namespace AbandonedTurn { export interface Raw { session_id: string; tenant_id: string; diff --git a/packages/trueforge-sdk/src/serialization/types/ListAbandonedTurnsResponse.ts b/packages/trueforge-sdk/src/serialization/types/ListAbandonedTurnsResponse.ts new file mode 100644 index 000000000..b906d8e1b --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/ListAbandonedTurnsResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { AbandonedTurn } from "./AbandonedTurn.js"; + +export const ListAbandonedTurnsResponse: core.serialization.ObjectSchema< + serializers.ListAbandonedTurnsResponse.Raw, + TrueForge.ListAbandonedTurnsResponse +> = core.serialization.object({ + data: core.serialization.list(AbandonedTurn), +}); + +export declare namespace ListAbandonedTurnsResponse { + export interface Raw { + data: AbandonedTurn.Raw[]; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/ListZombieTurnsResponse.ts b/packages/trueforge-sdk/src/serialization/types/ListZombieTurnsResponse.ts deleted file mode 100644 index 3cb57e4a3..000000000 --- a/packages/trueforge-sdk/src/serialization/types/ListZombieTurnsResponse.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as TrueForge from "../../api/index.js"; -import * as core from "../../core/index.js"; -import type * as serializers from "../index.js"; -import { ZombieTurn } from "./ZombieTurn.js"; - -export const ListZombieTurnsResponse: core.serialization.ObjectSchema< - serializers.ListZombieTurnsResponse.Raw, - TrueForge.ListZombieTurnsResponse -> = core.serialization.object({ - data: core.serialization.list(ZombieTurn), -}); - -export declare namespace ListZombieTurnsResponse { - export interface Raw { - data: ZombieTurn.Raw[]; - } -} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index c9c71ede2..fa4010c31 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -1,3 +1,4 @@ +export * from "./AbandonedTurn.js"; export * from "./ActionRequired.js"; export * from "./ActionRequiredEvent.js"; export * from "./Agent.js"; @@ -94,6 +95,7 @@ export * from "./GoogleGeminiModelProvider.js"; export * from "./InitialUserMessage.js"; export * from "./InputTokensCompactionTrigger.js"; export * from "./LargeToolResponseConfig.js"; +export * from "./ListAbandonedTurnsResponse.js"; export * from "./ListAgentsResponse.js"; export * from "./ListAvailableMcpServersResponse.js"; export * from "./ListAvailableModelsResponse.js"; @@ -115,7 +117,6 @@ export * from "./ListSkillVersionsResponse.js"; export * from "./ListTurnEventsOrder.js"; export * from "./ListTurnEventsResponse.js"; export * from "./ListTurnsResponse.js"; -export * from "./ListZombieTurnsResponse.js"; export * from "./McpAuthRequiredEvent.js"; export * from "./McpAuthStatus.js"; export * from "./McpAuthStatusStatus.js"; @@ -268,4 +269,3 @@ export * from "./WebSearchCapability.js"; export * from "./WebSearchConfig.js"; export * from "./WebSearchProviderManifest.js"; export * from "./ZaiModelProvider.js"; -export * from "./ZombieTurn.js"; diff --git a/packages/trueforge-sdk/tests/wire/internal/turns.test.ts b/packages/trueforge-sdk/tests/wire/internal/turns.test.ts index c91f35d2b..bb6086fb4 100644 --- a/packages/trueforge-sdk/tests/wire/internal/turns.test.ts +++ b/packages/trueforge-sdk/tests/wire/internal/turns.test.ts @@ -5,117 +5,117 @@ import { TrueForge } from "../../../src/Client"; import { mockServerPool } from "../../mock-server/MockServerPool"; describe("TurnsClient", () => { - test("close_zombie (1)", async () => { + test("list_abandoned (1)", async () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); - const rawRequestBody = { session_id: "session_id", tenant_id: "tenant_id", turn_id: "turn_id" }; + + const rawResponseBody = { data: [{ session_id: "session_id", tenant_id: "tenant_id", turn_id: "turn_id" }] }; server .mockEndpoint() - .post("/api/internal/turns/close-zombie") - .jsonBody(rawRequestBody) + .get("/api/internal/turns/abandoned") .respondWith() .statusCode(200) + .jsonBody(rawResponseBody) .build(); - const response = await client.internal.turns.closeZombie({ - sessionId: "session_id", - tenantId: "tenant_id", - turnId: "turn_id", + const response = await client.internal.turns.listAbandoned(); + expect(response).toEqual({ + data: [ + { + sessionId: "session_id", + tenantId: "tenant_id", + turnId: "turn_id", + }, + ], }); - expect(response).toEqual(undefined); }); - test("close_zombie (2)", async () => { + test("list_abandoned (2)", async () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); - const rawRequestBody = { session_id: "x", tenant_id: "x", turn_id: "x" }; + const rawResponseBody = { error: { message: "message" } }; server .mockEndpoint() - .post("/api/internal/turns/close-zombie") - .jsonBody(rawRequestBody) + .get("/api/internal/turns/abandoned") .respondWith() .statusCode(401) .jsonBody(rawResponseBody) .build(); await expect(async () => { - return await client.internal.turns.closeZombie({ - sessionId: "x", - tenantId: "x", - turnId: "x", - }); + return await client.internal.turns.listAbandoned(); }).rejects.toThrow(TrueForgeTypes.UnauthorizedError); }); - test("close_zombie (3)", async () => { + test("close_abandoned (1)", async () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); - const rawRequestBody = { session_id: "x", tenant_id: "x", turn_id: "x" }; - const rawResponseBody = { error: { message: "message" } }; + const rawRequestBody = { session_id: "session_id", tenant_id: "tenant_id", turn_id: "turn_id" }; server .mockEndpoint() - .post("/api/internal/turns/close-zombie") + .post("/api/internal/turns/close-abandoned") .jsonBody(rawRequestBody) .respondWith() - .statusCode(404) - .jsonBody(rawResponseBody) + .statusCode(200) .build(); - await expect(async () => { - return await client.internal.turns.closeZombie({ - sessionId: "x", - tenantId: "x", - turnId: "x", - }); - }).rejects.toThrow(TrueForgeTypes.NotFoundError); + const response = await client.internal.turns.closeAbandoned({ + sessionId: "session_id", + tenantId: "tenant_id", + turnId: "turn_id", + }); + expect(response).toEqual(undefined); }); - test("list_zombie (1)", async () => { + test("close_abandoned (2)", async () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); - - const rawResponseBody = { data: [{ session_id: "session_id", tenant_id: "tenant_id", turn_id: "turn_id" }] }; + const rawRequestBody = { session_id: "x", tenant_id: "x", turn_id: "x" }; + const rawResponseBody = { error: { message: "message" } }; server .mockEndpoint() - .get("/api/internal/turns/zombie") + .post("/api/internal/turns/close-abandoned") + .jsonBody(rawRequestBody) .respondWith() - .statusCode(200) + .statusCode(401) .jsonBody(rawResponseBody) .build(); - const response = await client.internal.turns.listZombie(); - expect(response).toEqual({ - data: [ - { - sessionId: "session_id", - tenantId: "tenant_id", - turnId: "turn_id", - }, - ], - }); + await expect(async () => { + return await client.internal.turns.closeAbandoned({ + sessionId: "x", + tenantId: "x", + turnId: "x", + }); + }).rejects.toThrow(TrueForgeTypes.UnauthorizedError); }); - test("list_zombie (2)", async () => { + test("close_abandoned (3)", async () => { const server = mockServerPool.createServer(); const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); - + const rawRequestBody = { session_id: "x", tenant_id: "x", turn_id: "x" }; const rawResponseBody = { error: { message: "message" } }; server .mockEndpoint() - .get("/api/internal/turns/zombie") + .post("/api/internal/turns/close-abandoned") + .jsonBody(rawRequestBody) .respondWith() - .statusCode(401) + .statusCode(404) .jsonBody(rawResponseBody) .build(); await expect(async () => { - return await client.internal.turns.listZombie(); - }).rejects.toThrow(TrueForgeTypes.UnauthorizedError); + return await client.internal.turns.closeAbandoned({ + sessionId: "x", + tenantId: "x", + turnId: "x", + }); + }).rejects.toThrow(TrueForgeTypes.NotFoundError); }); }); diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 67e7fa29a..68bb9825f 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -129,13 +129,13 @@ REDIS_URL=redis://localhost:6379 ## Defaults to http://localhost:$PORT; point it at the server Service in a split deployment. # SERVER_URL=http://localhost:8790 -## Zombie-turn closer (controller + internal /api/internal/turns). All optional. +## Abandoned-turn closer (controller + internal /api/internal/turns). All optional. ## Running turns with last activity older than this are closed. Default 900000 (15 minutes). -# ZOMBIE_TURN_STALE_MS=900000 +# ABANDONED_TURN_STALE_MS=900000 ## Max closes per controller tick. Default 50. -# ZOMBIE_TURN_CLOSE_BATCH=50 +# ABANDONED_TURN_CLOSE_BATCH=50 ## Gap between closer loop passes. Default 60000. -# ZOMBIE_TURN_CLOSER_INTERVAL_MS=60000 +# ABANDONED_TURN_CLOSER_INTERVAL_MS=60000 ## Service credential for controller → /api/internal/schedules. ## Required when STANDALONE=false. Standalone defaults to `trueforge-standalone` (local only). diff --git a/packages/trueforge/src/apis/zombieTurns.ts b/packages/trueforge/src/apis/abandonedTurns.ts similarity index 70% rename from packages/trueforge/src/apis/zombieTurns.ts rename to packages/trueforge/src/apis/abandonedTurns.ts index 7fc1a7b3f..269e26d2b 100644 --- a/packages/trueforge/src/apis/zombieTurns.ts +++ b/packages/trueforge/src/apis/abandonedTurns.ts @@ -1,16 +1,16 @@ /** - * Internal API for the zombie-turn closer controller. + * Internal API for the abandoned-turn closer controller. */ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; import { CancellationReason, type ISessionStore, type Sessions } from '@truefoundry/trueforge-core/agent-session'; import type { RedisClient } from '@truefoundry/trueforge-core/request-reply'; import type { Logger } from 'winston'; import configuration from '../config'; -import { closeZombieTurnRoute, listZombieTurnsRoute } from '../routes/zombieTurnRoutes'; +import { closeAbandonedTurnRoute, listAbandonedTurnsRoute } from '../routes/abandonedTurnRoutes'; import type { ActiveTurnRegistry } from '../runtime/activeTurns'; import { cancelSessionTurn } from './sessions'; -export interface ZombieTurnsRouterDeps { +export interface AbandonedTurnsRouterDeps { sessionStore: ISessionStore; sessions: Sessions; activeTurns: ActiveTurnRegistry; @@ -18,9 +18,9 @@ export interface ZombieTurnsRouterDeps { logger: Logger; } -export function createZombieTurnsRouter(deps: ZombieTurnsRouterDeps) { - const listHandler: RouteHandler = async c => { - const olderThan = new Date(Date.now() - configuration.ZOMBIE_TURN_STALE_MS); +export function createAbandonedTurnsRouter(deps: AbandonedTurnsRouterDeps) { + const listHandler: RouteHandler = async c => { + const olderThan = new Date(Date.now() - configuration.ABANDONED_TURN_STALE_MS); const stale = await deps.sessionStore.listStaleRunningTurns({ older_than: olderThan }); return c.json( { @@ -34,7 +34,7 @@ export function createZombieTurnsRouter(deps: ZombieTurnsRouterDeps) { ); }; - const closeHandler: RouteHandler = async c => { + const closeHandler: RouteHandler = async c => { const body = c.req.valid('json'); const session = await deps.sessions.get({ tenant_id: body.tenant_id, @@ -57,7 +57,7 @@ export function createZombieTurnsRouter(deps: ZombieTurnsRouterDeps) { }; const router = new OpenAPIHono(); - router.openapi(listZombieTurnsRoute, listHandler); - router.openapi(closeZombieTurnRoute, closeHandler); + router.openapi(listAbandonedTurnsRoute, listHandler); + router.openapi(closeAbandonedTurnRoute, closeHandler); return router; } diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index ab84fc436..0b4234292 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -10,6 +10,7 @@ import { HTTPException } from 'hono/http-exception'; import { randomUUID } from 'node:crypto'; import type { Configuration } from 'openid-client'; import type { Logger } from 'winston'; +import { createAbandonedTurnsRouter } from './apis/abandonedTurns'; import { createAgentsRouter } from './apis/agents'; import { createAuthRouter } from './apis/auth'; import { createCapabilitiesRouter } from './apis/capabilities'; @@ -26,7 +27,6 @@ import { createInternalSessionsRouter, createSessionsRouter } from './apis/sessi import { createSettingsRouter } from './apis/settings'; import { createAvailableSkillsRouter } from './apis/skills'; import { createTurnsRouter } from './apis/turns'; -import { createZombieTurnsRouter } from './apis/zombieTurns'; import type { Authenticator } from './auth/authenticator'; import type { Authorizer } from './auth/authorizer'; import { resolveRequestContext } from './auth/identity'; @@ -495,7 +495,7 @@ export function createServerApp(deps: ServerDeps) { app.route( '/api/internal/turns', withAuth( - createZombieTurnsRouter({ + createAbandonedTurnsRouter({ sessionStore: deps.sessionStore, sessions: deps.sessions, activeTurns: deps.activeTurns, diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index fd3963543..4693ecff0 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -826,20 +826,20 @@ export interface SharedServerConfiguration { */ SERVER_URL: string; /** - * Running turns with last activity older than this are zombies (list/close API). - * Env: `ZOMBIE_TURN_STALE_MS`. Default 900000 (15 minutes). + * Running turns with last activity older than this are closed as abandoned. + * Env: `ABANDONED_TURN_STALE_MS`. Default 900000 (15 minutes). */ - ZOMBIE_TURN_STALE_MS: number; + ABANDONED_TURN_STALE_MS: number; /** - * Max zombie turns the controller closes per tick. - * Env: `ZOMBIE_TURN_CLOSE_BATCH`. Default 50. + * Max abandoned turns the controller closes per tick. + * Env: `ABANDONED_TURN_CLOSE_BATCH`. Default 50. */ - ZOMBIE_TURN_CLOSE_BATCH: number; + ABANDONED_TURN_CLOSE_BATCH: number; /** - * Gap between zombie-turn closer loop passes. - * Env: `ZOMBIE_TURN_CLOSER_INTERVAL_MS`. Default 60000. + * Gap between abandoned-turn closer loop passes. + * Env: `ABANDONED_TURN_CLOSER_INTERVAL_MS`. Default 60000. */ - ZOMBIE_TURN_CLOSER_INTERVAL_MS: number; + ABANDONED_TURN_CLOSER_INTERVAL_MS: number; /** * Service credential for controller calls. * Env: `TRUEFORGE_API_KEY`. Required and non-empty when `STANDALONE=false`. @@ -1214,19 +1214,19 @@ const shared: SharedServerConfiguration = { PUBLIC_BASE_URL: parsePublicBaseUrl(getEnv('PUBLIC_BASE_URL', { defaultValue: '' })), SERVER_URL: getEnv('SERVER_URL', { defaultValue: `http://localhost:${String(port)}` }) ?? `http://localhost:${String(port)}`, - ZOMBIE_TURN_STALE_MS: parsePositiveInt({ - envKey: 'ZOMBIE_TURN_STALE_MS', - raw: getEnv('ZOMBIE_TURN_STALE_MS'), + ABANDONED_TURN_STALE_MS: parsePositiveInt({ + envKey: 'ABANDONED_TURN_STALE_MS', + raw: getEnv('ABANDONED_TURN_STALE_MS'), defaultValue: 15 * 60_000, }), - ZOMBIE_TURN_CLOSE_BATCH: parsePositiveInt({ - envKey: 'ZOMBIE_TURN_CLOSE_BATCH', - raw: getEnv('ZOMBIE_TURN_CLOSE_BATCH'), + ABANDONED_TURN_CLOSE_BATCH: parsePositiveInt({ + envKey: 'ABANDONED_TURN_CLOSE_BATCH', + raw: getEnv('ABANDONED_TURN_CLOSE_BATCH'), defaultValue: 50, }), - ZOMBIE_TURN_CLOSER_INTERVAL_MS: parsePositiveInt({ - envKey: 'ZOMBIE_TURN_CLOSER_INTERVAL_MS', - raw: getEnv('ZOMBIE_TURN_CLOSER_INTERVAL_MS'), + ABANDONED_TURN_CLOSER_INTERVAL_MS: parsePositiveInt({ + envKey: 'ABANDONED_TURN_CLOSER_INTERVAL_MS', + raw: getEnv('ABANDONED_TURN_CLOSER_INTERVAL_MS'), defaultValue: 60_000, }), TRUEFORGE_API_KEY: standalone diff --git a/packages/trueforge/src/controller.ts b/packages/trueforge/src/controller.ts index f689a7056..b39784f05 100644 --- a/packages/trueforge/src/controller.ts +++ b/packages/trueforge/src/controller.ts @@ -1,8 +1,8 @@ import type { Logger } from 'winston'; +import { abandonedTurnCloserLoop } from './controller/abandonedTurnCloser'; import { Controller } from './controller/Controller'; import { sandboxEnvBuildLoop } from './controller/sandboxEnvBuild'; import { scheduleDispatchLoop } from './controller/scheduleDispatch'; -import { zombieTurnCloserLoop } from './controller/zombieTurnCloser'; import type { IScheduleStore } from './db/scheduleStore'; import type { WithTransaction } from './db/transaction'; @@ -10,7 +10,7 @@ import type { WithTransaction } from './db/transaction'; * Controller whose schedule loop hands runs to the server over HTTP * (`SERVER_URL` + `TRUEFORGE_API_KEY` from process config). Standalone uses * loopback; distributed uses the dedicated controller against the server Service. - * Sandbox-env build and zombie-turn closer loops use the same HTTP path. + * Sandbox-env build and abandoned-turn closer loops use the same HTTP path. */ export function createController(params: { scheduleStore: IScheduleStore; @@ -25,7 +25,7 @@ export function createController(params: { logger: params.logger, }), sandboxEnvBuildLoop({ logger: params.logger }), - zombieTurnCloserLoop({ logger: params.logger }), + abandonedTurnCloserLoop({ logger: params.logger }), ], logger: params.logger, }); diff --git a/packages/trueforge/src/controller/abandonedTurnCloser.ts b/packages/trueforge/src/controller/abandonedTurnCloser.ts new file mode 100644 index 000000000..1d59b2b83 --- /dev/null +++ b/packages/trueforge/src/controller/abandonedTurnCloser.ts @@ -0,0 +1,79 @@ +/** + * Control loop: list stale running turns and close them over HTTP (batch per tick). + */ +import { TrueForgeApi } from '@truefoundry/trueforge-sdk'; +import type { Logger } from 'winston'; +import configuration from '../config'; +import { captureCriticalException } from '../sentry'; +import type { ControlLoop } from './Controller'; +import { internalTrueForgeClient } from './internalTrueForgeClient'; + +const ABANDONED_TURN_CLOSER_LOOP_NAME = 'abandoned-turn-closer'; + +export async function dispatchAbandonedTurns({ logger }: { logger: Logger }): Promise { + const abandoned = await internalTrueForgeClient.listAbandonedTurns(); + const batch = abandoned.slice(0, configuration.ABANDONED_TURN_CLOSE_BATCH); + logger.debug('Abandoned turn closer tick', { + abandoned_count: abandoned.length, + close_batch: batch.length, + }); + for (const turn of batch) { + logger.debug('Closing abandoned turn', { + tenant_id: turn.tenantId, + session_id: turn.sessionId, + turn_id: turn.turnId, + }); + try { + await internalTrueForgeClient.closeAbandonedTurn(turn); + logger.debug('Abandoned turn closed', { + tenant_id: turn.tenantId, + session_id: turn.sessionId, + turn_id: turn.turnId, + }); + } catch (error) { + if (error instanceof TrueForgeApi.NotFoundError) { + logger.warn('Abandoned turn session not found; skipping', { + tenant_id: turn.tenantId, + session_id: turn.sessionId, + turn_id: turn.turnId, + }); + captureCriticalException(error, { + tags: { module: 'abandonedTurnCloser', operation: 'closeNotFound' }, + extra: { + tenant_id: turn.tenantId, + session_id: turn.sessionId, + turn_id: turn.turnId, + }, + }); + continue; + } + logger.error('Abandoned turn close failed', { + tenant_id: turn.tenantId, + session_id: turn.sessionId, + turn_id: turn.turnId, + error: error instanceof Error ? error.message : String(error), + }); + captureCriticalException(error, { + tags: { module: 'abandonedTurnCloser', operation: 'close' }, + extra: { + tenant_id: turn.tenantId, + session_id: turn.sessionId, + turn_id: turn.turnId, + }, + }); + } + } +} + +export function abandonedTurnCloserLoop(params: { logger: Logger }): ControlLoop { + return { + name: ABANDONED_TURN_CLOSER_LOOP_NAME, + intervalMs: configuration.ABANDONED_TURN_CLOSER_INTERVAL_MS, + async tick(signal) { + if (signal.aborted) { + return; + } + await dispatchAbandonedTurns({ logger: params.logger }); + }, + }; +} diff --git a/packages/trueforge/src/controller/internalTrueForgeClient.ts b/packages/trueforge/src/controller/internalTrueForgeClient.ts index 5ee90d057..aeea808ac 100644 --- a/packages/trueforge/src/controller/internalTrueForgeClient.ts +++ b/packages/trueforge/src/controller/internalTrueForgeClient.ts @@ -36,8 +36,8 @@ export const internalTrueForgeClient = { await client.internal.sandboxEnvironments.progress({ environmentVersionId }); }, - async listZombieTurns(): Promise<{ tenantId: string; sessionId: string; turnId: string }[]> { - const response = await client.internal.turns.listZombie(); + async listAbandonedTurns(): Promise<{ tenantId: string; sessionId: string; turnId: string }[]> { + const response = await client.internal.turns.listAbandoned(); return response.data.map(row => ({ tenantId: row.tenantId, sessionId: row.sessionId, @@ -45,8 +45,8 @@ export const internalTrueForgeClient = { })); }, - async closeZombieTurn(input: { tenantId: string; sessionId: string; turnId: string }): Promise { - await client.internal.turns.closeZombie({ + async closeAbandonedTurn(input: { tenantId: string; sessionId: string; turnId: string }): Promise { + await client.internal.turns.closeAbandoned({ tenantId: input.tenantId, sessionId: input.sessionId, turnId: input.turnId, diff --git a/packages/trueforge/src/controller/zombieTurnCloser.ts b/packages/trueforge/src/controller/zombieTurnCloser.ts deleted file mode 100644 index 4e6ba3182..000000000 --- a/packages/trueforge/src/controller/zombieTurnCloser.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Control loop: list stale running turns and close them over HTTP (batch per tick). - */ -import { TrueForgeApi } from '@truefoundry/trueforge-sdk'; -import type { Logger } from 'winston'; -import configuration from '../config'; -import { captureCriticalException } from '../sentry'; -import type { ControlLoop } from './Controller'; -import { internalTrueForgeClient } from './internalTrueForgeClient'; - -const ZOMBIE_TURN_CLOSER_LOOP_NAME = 'zombie-turn-closer'; - -export async function dispatchZombieTurns({ logger }: { logger: Logger }): Promise { - const zombies = await internalTrueForgeClient.listZombieTurns(); - const batch = zombies.slice(0, configuration.ZOMBIE_TURN_CLOSE_BATCH); - logger.debug('Zombie turn closer tick', { - zombie_count: zombies.length, - close_batch: batch.length, - }); - for (const zombie of batch) { - logger.debug('Closing zombie turn', { - tenant_id: zombie.tenantId, - session_id: zombie.sessionId, - turn_id: zombie.turnId, - }); - try { - await internalTrueForgeClient.closeZombieTurn(zombie); - logger.debug('Zombie turn closed', { - tenant_id: zombie.tenantId, - session_id: zombie.sessionId, - turn_id: zombie.turnId, - }); - } catch (error) { - if (error instanceof TrueForgeApi.NotFoundError) { - logger.warn('Zombie turn session not found; skipping', { - tenant_id: zombie.tenantId, - session_id: zombie.sessionId, - turn_id: zombie.turnId, - }); - captureCriticalException(error, { - tags: { module: 'zombieTurnCloser', operation: 'closeNotFound' }, - extra: { - tenant_id: zombie.tenantId, - session_id: zombie.sessionId, - turn_id: zombie.turnId, - }, - }); - continue; - } - logger.error('Zombie turn close failed', { - tenant_id: zombie.tenantId, - session_id: zombie.sessionId, - turn_id: zombie.turnId, - error: error instanceof Error ? error.message : String(error), - }); - captureCriticalException(error, { - tags: { module: 'zombieTurnCloser', operation: 'close' }, - extra: { - tenant_id: zombie.tenantId, - session_id: zombie.sessionId, - turn_id: zombie.turnId, - }, - }); - } - } -} - -export function zombieTurnCloserLoop(params: { logger: Logger }): ControlLoop { - return { - name: ZOMBIE_TURN_CLOSER_LOOP_NAME, - intervalMs: configuration.ZOMBIE_TURN_CLOSER_INTERVAL_MS, - async tick(signal) { - if (signal.aborted) { - return; - } - await dispatchZombieTurns({ logger: params.logger }); - }, - }; -} diff --git a/packages/trueforge/src/db/postgres/session-store/queries/turns.ts b/packages/trueforge/src/db/postgres/session-store/queries/turns.ts index 44b18b0b5..659c0d89d 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/turns.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/turns.ts @@ -763,6 +763,8 @@ export async function listTurns(db: Kysely, input: ListTurnsInput): Pr /** * listStaleRunningTurns — running turns whose last event (or created_at) is before older_than. * Oldest-first; no limit. Joins session for tenant_id (needed to load the session on close). + * Last-event aggregate is restricted to currently running turns so finished turns' events + * are not scanned. */ export async function listStaleRunningTurns( db: Kysely, @@ -774,10 +776,14 @@ export async function listStaleRunningTurns( .leftJoin( eb => eb - .selectFrom('session_event') - .select(['session_id', 'turn_id']) - .select(eb2 => eb2.fn.max('created_at').as('last_event_at')) - .groupBy(['session_id', 'turn_id']) + .selectFrom('session_event as e') + .innerJoin('turn as rt', join => + join.onRef('rt.session_id', '=', 'e.session_id').onRef('rt.turn_id', '=', 'e.turn_id'), + ) + .select(['e.session_id', 'e.turn_id']) + .select(eb2 => eb2.fn.max('e.created_at').as('last_event_at')) + .where(sql`rt.state->>'status' = 'running'`) + .groupBy(['e.session_id', 'e.turn_id']) .as('e'), join => join.onRef('e.session_id', '=', 't.session_id').onRef('e.turn_id', '=', 't.turn_id'), ) diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/turns.ts b/packages/trueforge/src/db/sqlite/session-store/queries/turns.ts index 50acc9375..3183c6699 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/turns.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/turns.ts @@ -816,6 +816,8 @@ export async function listTurns(db: Kysely, input: ListTurnsInput): Pr * listStaleRunningTurns — running turns whose last event (or created_at) is before older_than. * Oldest-first; no limit. Timestamps are ISO strings on SQLite. * Joins session for tenant_id (needed to load the session on close). + * Last-event aggregate is restricted to currently running turns so finished turns' events + * are not scanned. */ export async function listStaleRunningTurns( db: Kysely, @@ -828,10 +830,14 @@ export async function listStaleRunningTurns( .leftJoin( eb => eb - .selectFrom('session_event') - .select(['session_id', 'turn_id']) - .select(eb2 => eb2.fn.max('created_at').as('last_event_at')) - .groupBy(['session_id', 'turn_id']) + .selectFrom('session_event as e') + .innerJoin('turn as rt', join => + join.onRef('rt.session_id', '=', 'e.session_id').onRef('rt.turn_id', '=', 'e.turn_id'), + ) + .select(['e.session_id', 'e.turn_id']) + .select(eb2 => eb2.fn.max('e.created_at').as('last_event_at')) + .where(sql`rt.state->>'status' = 'running'`) + .groupBy(['e.session_id', 'e.turn_id']) .as('e'), join => join.onRef('e.session_id', '=', 't.session_id').onRef('e.turn_id', '=', 't.turn_id'), ) diff --git a/packages/trueforge/src/routes/zombieTurnRoutes.ts b/packages/trueforge/src/routes/abandonedTurnRoutes.ts similarity index 61% rename from packages/trueforge/src/routes/zombieTurnRoutes.ts rename to packages/trueforge/src/routes/abandonedTurnRoutes.ts index d0e7d8c55..09427827a 100644 --- a/packages/trueforge/src/routes/zombieTurnRoutes.ts +++ b/packages/trueforge/src/routes/abandonedTurnRoutes.ts @@ -1,24 +1,24 @@ /** - * Internal zombie-turn closer routes (API-key auth). + * Internal abandoned-turn closer routes (API-key auth). * Mounted at /api/internal/turns. */ import { createRoute } from '@hono/zod-openapi'; +import { CloseAbandonedTurnRequestSchema, ListAbandonedTurnsResponseSchema } from '../schemas/abandonedTurn'; import { RequestErrorResponseSchema } from '../schemas/errors'; -import { CloseZombieTurnRequestSchema, ListZombieTurnsResponseSchema } from '../schemas/zombieTurn'; import { OpenApiTag } from './openapiTags'; -export const listZombieTurnsRoute = createRoute({ +export const listAbandonedTurnsRoute = createRoute({ method: 'get', - path: '/zombie', + path: '/abandoned', tags: [OpenApiTag.INTERNAL], - summary: 'List stale running turns', + summary: 'List abandoned running turns', description: 'Returns all running turns whose last activity is past the stale threshold.', 'x-fern-sdk-group-name': ['internal', 'turns'], - 'x-fern-sdk-method-name': 'list_zombie', + 'x-fern-sdk-method-name': 'list_abandoned', 'x-excluded': true, responses: { 200: { - content: { 'application/json': { schema: ListZombieTurnsResponseSchema } }, + content: { 'application/json': { schema: ListAbandonedTurnsResponseSchema } }, description: 'Stale running turns oldest-first.', }, 401: { @@ -28,18 +28,18 @@ export const listZombieTurnsRoute = createRoute({ }, }); -export const closeZombieTurnRoute = createRoute({ +export const closeAbandonedTurnRoute = createRoute({ method: 'post', - path: '/close-zombie', + path: '/close-abandoned', tags: [OpenApiTag.INTERNAL], - summary: 'Close a stale running turn', - description: 'Cancels a zombie turn (turn.done with abandoned reason).', + summary: 'Close an abandoned running turn', + description: 'Cancels a stale running turn (turn.done with abandoned reason).', 'x-fern-sdk-group-name': ['internal', 'turns'], - 'x-fern-sdk-method-name': 'close_zombie', + 'x-fern-sdk-method-name': 'close_abandoned', 'x-excluded': true, request: { body: { - content: { 'application/json': { schema: CloseZombieTurnRequestSchema } }, + content: { 'application/json': { schema: CloseAbandonedTurnRequestSchema } }, required: true, }, }, diff --git a/packages/trueforge/src/schemas/zombieTurn.ts b/packages/trueforge/src/schemas/abandonedTurn.ts similarity index 62% rename from packages/trueforge/src/schemas/zombieTurn.ts rename to packages/trueforge/src/schemas/abandonedTurn.ts index 48c50e562..c904b413e 100644 --- a/packages/trueforge/src/schemas/zombieTurn.ts +++ b/packages/trueforge/src/schemas/abandonedTurn.ts @@ -1,29 +1,29 @@ /** - * Wire schemas for internal zombie-turn closer endpoints. + * Wire schemas for internal abandoned-turn closer endpoints. */ import { z } from '@hono/zod-openapi'; -export const ZombieTurnSchema = z +export const AbandonedTurnSchema = z .object({ tenant_id: z.string().min(1).describe('Tenant that owns the session.'), session_id: z.string().min(1).describe('Session that owns the turn.'), turn_id: z.string().min(1).describe('Running turn with stale last activity.'), }) .strict() - .openapi('ZombieTurn'); + .openapi('AbandonedTurn'); -export const ListZombieTurnsResponseSchema = z +export const ListAbandonedTurnsResponseSchema = z .object({ - data: z.array(ZombieTurnSchema), + data: z.array(AbandonedTurnSchema), }) .strict() - .openapi('ListZombieTurnsResponse'); + .openapi('ListAbandonedTurnsResponse'); -export const CloseZombieTurnRequestSchema = z +export const CloseAbandonedTurnRequestSchema = z .object({ tenant_id: z.string().min(1).describe('Tenant that owns the session.'), session_id: z.string().min(1).describe('Session that owns the turn.'), turn_id: z.string().min(1).describe('Turn to close.'), }) .strict() - .openapi('CloseZombieTurnRequest'); + .openapi('CloseAbandonedTurnRequest'); diff --git a/packages/trueforge/tests/unit/apis/abandonedTurns.test.ts b/packages/trueforge/tests/unit/apis/abandonedTurns.test.ts new file mode 100644 index 000000000..4dc510931 --- /dev/null +++ b/packages/trueforge/tests/unit/apis/abandonedTurns.test.ts @@ -0,0 +1,156 @@ +import { CancellationReason, InMemorySessionStore, Sessions } from '@truefoundry/trueforge-core/agent-session'; +import { createLogger } from 'winston'; +import { + makeAgentSpec, + makeCreateTurnInput, + makeTurnCreatedEvent, +} from '../../../../trueforge-core/tests/agent-session/testHelpers'; +import { createAbandonedTurnsRouter } from '../../../src/apis/abandonedTurns'; +import configuration from '../../../src/config'; +import { ActiveTurnRegistry } from '../../../src/runtime/activeTurns'; + +const TENANT = 'default'; +const SESSION_ID = 'abandoned-session'; +const logger = createLogger({ silent: true }); + +describe('abandoned turns internal API', () => { + async function setup() { + const sessionStore = new InMemorySessionStore(); + const sessions = new Sessions({ sessionStore }); + const activeTurns = new ActiveTurnRegistry(); + await sessionStore.createSession({ + tenant_id: TENANT, + session_id: SESSION_ID, + created_by_subject: { subject_id: 'user-1', subject_type: 'user', subject_display_name: 'user-1' }, + agent: { type: 'inline', spec: makeAgentSpec() }, + custom: null, + metadata: {}, + external_id: null, + source: null, + }); + const router = createAbandonedTurnsRouter({ + sessionStore, + sessions, + activeTurns, + logger, + }); + return { sessionStore, sessions, router }; + } + + it('lists only stale running turns and closes them as abandoned', async () => { + const { sessionStore, router } = await setup(); + const staleAt = new Date(Date.now() - configuration.ABANDONED_TURN_STALE_MS - 60_000).toISOString(); + const freshAt = new Date().toISOString(); + + await sessionStore.createTurn(makeCreateTurnInput({ sessionId: SESSION_ID, turnId: 'stale-1' })); + await sessionStore.appendToEvents({ + session_id: SESSION_ID, + turn_id: 'stale-1', + events: [{ ...makeTurnCreatedEvent('stale-1'), created_at: staleAt }], + }); + + await sessionStore.createTurn( + makeCreateTurnInput({ + sessionId: SESSION_ID, + turnId: 'fresh-1', + previousTurnId: null, + firstTurnId: 'fresh-1', + }), + ); + await sessionStore.appendToEvents({ + session_id: SESSION_ID, + turn_id: 'fresh-1', + events: [{ ...makeTurnCreatedEvent('fresh-1'), created_at: freshAt }], + }); + + const listRes = await router.request('/abandoned'); + expect(listRes.status).toBe(200); + expect(await listRes.json()).toEqual({ + data: [{ tenant_id: TENANT, session_id: SESSION_ID, turn_id: 'stale-1' }], + }); + + const closeRes = await router.request('/close-abandoned', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tenant_id: TENANT, + session_id: SESSION_ID, + turn_id: 'stale-1', + }), + }); + expect(closeRes.status).toBe(204); + + const closed = await sessionStore.getTurn({ session_id: SESSION_ID, turn_id: 'stale-1' }); + expect(closed?.state).toMatchObject({ + status: 'cancelled', + reason: CancellationReason.Abandoned, + }); + + const fresh = await sessionStore.getTurn({ session_id: SESSION_ID, turn_id: 'fresh-1' }); + expect(fresh?.state.status).toBe('running'); + + const listAfter = await router.request('/abandoned'); + expect(listAfter.status).toBe(200); + expect(await listAfter.json()).toEqual({ data: [] }); + }); + + it('returns 404 when closing a missing session', async () => { + const { router } = await setup(); + const response = await router.request('/close-abandoned', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tenant_id: TENANT, + session_id: 'missing-session', + turn_id: 'turn-1', + }), + }); + expect(response.status).toBe(404); + }); + + it('close is a no-op 204 when the turn is already terminal', async () => { + const { sessionStore, router } = await setup(); + const staleAt = new Date(Date.now() - configuration.ABANDONED_TURN_STALE_MS - 60_000).toISOString(); + await sessionStore.createTurn(makeCreateTurnInput({ sessionId: SESSION_ID, turnId: 'done-1' })); + await sessionStore.appendToEvents({ + session_id: SESSION_ID, + turn_id: 'done-1', + events: [{ ...makeTurnCreatedEvent('done-1'), created_at: staleAt }], + }); + await sessionStore.updateTurnTerminalState({ + session_id: SESSION_ID, + turn_id: 'done-1', + state: { + status: 'done', + output: null, + required_actions: [], + completed_at: new Date().toISOString(), + }, + turn_done_event: { + type: 'turn.done', + id: 'evt-done-1', + created_at: new Date().toISOString(), + state: { + status: 'done', + output: null, + required_actions: [], + completed_at: new Date().toISOString(), + }, + thread_id: null, + }, + }); + + const response = await router.request('/close-abandoned', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tenant_id: TENANT, + session_id: SESSION_ID, + turn_id: 'done-1', + }), + }); + expect(response.status).toBe(204); + const turn = await sessionStore.getTurn({ session_id: SESSION_ID, turn_id: 'done-1' }); + expect(turn?.state.status).toBe('done'); + }); +}); diff --git a/packages/trueforge/tests/unit/controller/abandonedTurnCloser.test.ts b/packages/trueforge/tests/unit/controller/abandonedTurnCloser.test.ts new file mode 100644 index 000000000..ebebf5a9a --- /dev/null +++ b/packages/trueforge/tests/unit/controller/abandonedTurnCloser.test.ts @@ -0,0 +1,57 @@ +import { TrueForgeApi } from '@truefoundry/trueforge-sdk'; +import configuration from '../../../src/config'; +import { abandonedTurnCloserLoop, dispatchAbandonedTurns } from '../../../src/controller/abandonedTurnCloser'; +import { internalTrueForgeClient } from '../../../src/controller/internalTrueForgeClient'; + +function fakeLogger() { + return { error: jest.fn(), debug: jest.fn(), info: jest.fn(), warn: jest.fn() }; +} + +function abandoned(n: number): { tenantId: string; sessionId: string; turnId: string } { + return { tenantId: 't1', sessionId: `s-${String(n)}`, turnId: `turn-${String(n)}` }; +} + +describe('abandonedTurnCloserLoop', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('closes each abandoned turn from the internal API up to the batch limit', async () => { + const batch = configuration.ABANDONED_TURN_CLOSE_BATCH; + const all = Array.from({ length: batch + 5 }, (_, i) => abandoned(i)); + jest.spyOn(internalTrueForgeClient, 'listAbandonedTurns').mockResolvedValue(all); + const close = jest.spyOn(internalTrueForgeClient, 'closeAbandonedTurn').mockResolvedValue(undefined); + + const logger = fakeLogger(); + const loop = abandonedTurnCloserLoop({ logger: logger as never }); + await loop.tick(new AbortController().signal); + + expect(close).toHaveBeenCalledTimes(batch); + expect(close).toHaveBeenNthCalledWith(1, abandoned(0)); + expect(close).toHaveBeenNthCalledWith(batch, abandoned(batch - 1)); + }); + + it('skips NotFoundError and continues the tick', async () => { + jest.spyOn(internalTrueForgeClient, 'listAbandonedTurns').mockResolvedValue([abandoned(1), abandoned(2)]); + const close = jest.spyOn(internalTrueForgeClient, 'closeAbandonedTurn').mockImplementation(async input => { + if (input.turnId === 'turn-1') { + throw new TrueForgeApi.NotFoundError({ error: { message: 'not found' } }); + } + }); + const logger = fakeLogger(); + + await dispatchAbandonedTurns({ logger: logger as never }); + + expect(close).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('does not tick when aborted', async () => { + const list = jest.spyOn(internalTrueForgeClient, 'listAbandonedTurns'); + const controller = new AbortController(); + controller.abort(); + const loop = abandonedTurnCloserLoop({ logger: fakeLogger() as never }); + await loop.tick(controller.signal); + expect(list).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/trueforge/tests/unit/controller/zombieTurnCloser.test.ts b/packages/trueforge/tests/unit/controller/zombieTurnCloser.test.ts deleted file mode 100644 index 820bb8cd7..000000000 --- a/packages/trueforge/tests/unit/controller/zombieTurnCloser.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { TrueForgeApi } from '@truefoundry/trueforge-sdk'; -import configuration from '../../../src/config'; -import { internalTrueForgeClient } from '../../../src/controller/internalTrueForgeClient'; -import { dispatchZombieTurns, zombieTurnCloserLoop } from '../../../src/controller/zombieTurnCloser'; - -function fakeLogger() { - return { error: jest.fn(), debug: jest.fn(), info: jest.fn(), warn: jest.fn() }; -} - -function zombie(n: number): { tenantId: string; sessionId: string; turnId: string } { - return { tenantId: 't1', sessionId: `s-${String(n)}`, turnId: `turn-${String(n)}` }; -} - -describe('zombieTurnCloserLoop', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('closes each zombie from the internal API up to the batch limit', async () => { - const batch = configuration.ZOMBIE_TURN_CLOSE_BATCH; - const all = Array.from({ length: batch + 5 }, (_, i) => zombie(i)); - jest.spyOn(internalTrueForgeClient, 'listZombieTurns').mockResolvedValue(all); - const close = jest.spyOn(internalTrueForgeClient, 'closeZombieTurn').mockResolvedValue(undefined); - - const logger = fakeLogger(); - const loop = zombieTurnCloserLoop({ logger: logger as never }); - await loop.tick(new AbortController().signal); - - expect(close).toHaveBeenCalledTimes(batch); - expect(close).toHaveBeenNthCalledWith(1, zombie(0)); - expect(close).toHaveBeenNthCalledWith(batch, zombie(batch - 1)); - }); - - it('skips NotFoundError and continues the tick', async () => { - jest.spyOn(internalTrueForgeClient, 'listZombieTurns').mockResolvedValue([zombie(1), zombie(2)]); - const close = jest.spyOn(internalTrueForgeClient, 'closeZombieTurn').mockImplementation(async input => { - if (input.turnId === 'turn-1') { - throw new TrueForgeApi.NotFoundError({ error: { message: 'not found' } }); - } - }); - const logger = fakeLogger(); - - await dispatchZombieTurns({ logger: logger as never }); - - expect(close).toHaveBeenCalledTimes(2); - expect(logger.warn).toHaveBeenCalled(); - }); - - it('does not tick when aborted', async () => { - const list = jest.spyOn(internalTrueForgeClient, 'listZombieTurns'); - const controller = new AbortController(); - controller.abort(); - const loop = zombieTurnCloserLoop({ logger: fakeLogger() as never }); - await loop.tick(controller.signal); - expect(list).not.toHaveBeenCalled(); - }); -}); diff --git a/python/trueforge_sdk/reference.md b/python/trueforge_sdk/reference.md index ffb7893fd..f21653c56 100644 --- a/python/trueforge_sdk/reference.md +++ b/python/trueforge_sdk/reference.md @@ -4384,7 +4384,7 @@ client.internal.sessions.get_or_create_by_external_id(
## Internal Turns -
client.internal.turns.close_zombie(...) +
client.internal.turns.list_abandoned() -> ListAbandonedTurnsResponse
@@ -4396,7 +4396,7 @@ client.internal.sessions.get_or_create_by_external_id(
-Cancels a zombie turn (turn.done with abandoned reason). +Returns all running turns whose last activity is past the stale threshold.
@@ -4418,11 +4418,7 @@ client = TrueForge( base_url="https://yourhost.com/path/to/api", ) -client.internal.turns.close_zombie( - session_id="session_id", - tenant_id="tenant_id", - turn_id="turn_id", -) +client.internal.turns.list_abandoned() ``` @@ -4438,30 +4434,6 @@ client.internal.turns.close_zombie(
-**session_id:** `str` — Session that owns the turn. - -
-
- -
-
- -**tenant_id:** `str` — Tenant that owns the session. - -
-
- -
-
- -**turn_id:** `str` — Turn to close. - -
-
- -
-
- **request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration.
@@ -4474,7 +4446,7 @@ client.internal.turns.close_zombie(
-
client.internal.turns.list_zombie() -> ListZombieTurnsResponse +
client.internal.turns.close_abandoned(...)
@@ -4486,7 +4458,7 @@ client.internal.turns.close_zombie(
-Returns all running turns whose last activity is past the stale threshold. +Cancels a stale running turn (turn.done with abandoned reason).
@@ -4508,7 +4480,11 @@ client = TrueForge( base_url="https://yourhost.com/path/to/api", ) -client.internal.turns.list_zombie() +client.internal.turns.close_abandoned( + session_id="session_id", + tenant_id="tenant_id", + turn_id="turn_id", +) ``` @@ -4524,6 +4500,30 @@ client.internal.turns.list_zombie()
+**session_id:** `str` — Session that owns the turn. + +
+
+ +
+
+ +**tenant_id:** `str` — Tenant that owns the session. + +
+
+ +
+
+ +**turn_id:** `str` — Turn to close. + +
+
+ +
+
+ **request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration.
diff --git a/python/trueforge_sdk/src/trueforge_sdk/__init__.py b/python/trueforge_sdk/src/trueforge_sdk/__init__.py index 1a774544f..3b1f5c430 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/__init__.py +++ b/python/trueforge_sdk/src/trueforge_sdk/__init__.py @@ -7,6 +7,7 @@ if typing.TYPE_CHECKING: from .types import ( + AbandonedTurn, ActionRequired, ActionRequiredEvent, Agent, @@ -103,6 +104,7 @@ InitialUserMessage, InputTokensCompactionTrigger, LargeToolResponseConfig, + ListAbandonedTurnsResponse, ListAgentsResponse, ListAvailableMcpServersResponse, ListAvailableModelsResponse, @@ -124,7 +126,6 @@ ListTurnEventsOrder, ListTurnEventsResponse, ListTurnsResponse, - ListZombieTurnsResponse, McpAuthRequiredEvent, McpAuthStatus, McpAuthStatusStatus, @@ -277,7 +278,6 @@ WebSearchConfig, WebSearchProviderManifest, ZaiModelProvider, - ZombieTurn, ) from .errors import ( BadGatewayError, @@ -311,6 +311,7 @@ from .client import AsyncTrueForge, TrueForge from .version import __version__ _dynamic_imports: typing.Dict[str, str] = { + "AbandonedTurn": ".types", "ActionRequired": ".types", "ActionRequiredEvent": ".types", "Agent": ".types", @@ -418,6 +419,7 @@ "InputTokensCompactionTrigger": ".types", "InternalServerError": ".errors", "LargeToolResponseConfig": ".types", + "ListAbandonedTurnsResponse": ".types", "ListAgentsResponse": ".types", "ListAvailableMcpServersResponse": ".types", "ListAvailableModelsResponse": ".types", @@ -439,7 +441,6 @@ "ListTurnEventsOrder": ".types", "ListTurnEventsResponse": ".types", "ListTurnsResponse": ".types", - "ListZombieTurnsResponse": ".types", "McpAuthRequiredEvent": ".types", "McpAuthStatus": ".types", "McpAuthStatusStatus": ".types", @@ -597,7 +598,6 @@ "WebSearchConfig": ".types", "WebSearchProviderManifest": ".types", "ZaiModelProvider": ".types", - "ZombieTurn": ".types", "__version__": ".version", "agents": ".agents", "auth": ".auth", @@ -636,6 +636,7 @@ def __dir__(): __all__ = [ + "AbandonedTurn", "ActionRequired", "ActionRequiredEvent", "Agent", @@ -743,6 +744,7 @@ def __dir__(): "InputTokensCompactionTrigger", "InternalServerError", "LargeToolResponseConfig", + "ListAbandonedTurnsResponse", "ListAgentsResponse", "ListAvailableMcpServersResponse", "ListAvailableModelsResponse", @@ -764,7 +766,6 @@ def __dir__(): "ListTurnEventsOrder", "ListTurnEventsResponse", "ListTurnsResponse", - "ListZombieTurnsResponse", "McpAuthRequiredEvent", "McpAuthStatus", "McpAuthStatusStatus", @@ -922,7 +923,6 @@ def __dir__(): "WebSearchConfig", "WebSearchProviderManifest", "ZaiModelProvider", - "ZombieTurn", "__version__", "agents", "auth", diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py index 99fe0db42..800148c56 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/client.py @@ -4,7 +4,7 @@ from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...core.request_options import RequestOptions -from ...types.list_zombie_turns_response import ListZombieTurnsResponse +from ...types.list_abandoned_turns_response import ListAbandonedTurnsResponse from .raw_client import AsyncRawTurnsClient, RawTurnsClient # this is used as the default value for optional parameters @@ -26,29 +26,19 @@ def with_raw_response(self) -> RawTurnsClient: """ return self._raw_client - def close_zombie( - self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None - ) -> None: + def list_abandoned(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListAbandonedTurnsResponse: """ - Cancels a zombie turn (turn.done with abandoned reason). + Returns all running turns whose last activity is past the stale threshold. Parameters ---------- - session_id : str - Session that owns the turn. - - tenant_id : str - Tenant that owns the session. - - turn_id : str - Turn to close. - request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - None + ListAbandonedTurnsResponse + Stale running turns oldest-first. Examples -------- @@ -58,30 +48,34 @@ def close_zombie( token="YOUR_TOKEN", base_url="https://yourhost.com/path/to/api", ) - client.internal.turns.close_zombie( - session_id="session_id", - tenant_id="tenant_id", - turn_id="turn_id", - ) + client.internal.turns.list_abandoned() """ - _response = self._raw_client.close_zombie( - session_id=session_id, tenant_id=tenant_id, turn_id=turn_id, request_options=request_options - ) + _response = self._raw_client.list_abandoned(request_options=request_options) return _response.data - def list_zombie(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListZombieTurnsResponse: + def close_abandoned( + self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> None: """ - Returns all running turns whose last activity is past the stale threshold. + Cancels a stale running turn (turn.done with abandoned reason). Parameters ---------- + session_id : str + Session that owns the turn. + + tenant_id : str + Tenant that owns the session. + + turn_id : str + Turn to close. + request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - ListZombieTurnsResponse - Stale running turns oldest-first. + None Examples -------- @@ -91,9 +85,15 @@ def list_zombie(self, *, request_options: typing.Optional[RequestOptions] = None token="YOUR_TOKEN", base_url="https://yourhost.com/path/to/api", ) - client.internal.turns.list_zombie() + client.internal.turns.close_abandoned( + session_id="session_id", + tenant_id="tenant_id", + turn_id="turn_id", + ) """ - _response = self._raw_client.list_zombie(request_options=request_options) + _response = self._raw_client.close_abandoned( + session_id=session_id, tenant_id=tenant_id, turn_id=turn_id, request_options=request_options + ) return _response.data @@ -112,29 +112,21 @@ def with_raw_response(self) -> AsyncRawTurnsClient: """ return self._raw_client - async def close_zombie( - self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None - ) -> None: + async def list_abandoned( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> ListAbandonedTurnsResponse: """ - Cancels a zombie turn (turn.done with abandoned reason). + Returns all running turns whose last activity is past the stale threshold. Parameters ---------- - session_id : str - Session that owns the turn. - - tenant_id : str - Tenant that owns the session. - - turn_id : str - Turn to close. - request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - None + ListAbandonedTurnsResponse + Stale running turns oldest-first. Examples -------- @@ -149,33 +141,37 @@ async def close_zombie( async def main() -> None: - await client.internal.turns.close_zombie( - session_id="session_id", - tenant_id="tenant_id", - turn_id="turn_id", - ) + await client.internal.turns.list_abandoned() asyncio.run(main()) """ - _response = await self._raw_client.close_zombie( - session_id=session_id, tenant_id=tenant_id, turn_id=turn_id, request_options=request_options - ) + _response = await self._raw_client.list_abandoned(request_options=request_options) return _response.data - async def list_zombie(self, *, request_options: typing.Optional[RequestOptions] = None) -> ListZombieTurnsResponse: + async def close_abandoned( + self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None + ) -> None: """ - Returns all running turns whose last activity is past the stale threshold. + Cancels a stale running turn (turn.done with abandoned reason). Parameters ---------- + session_id : str + Session that owns the turn. + + tenant_id : str + Tenant that owns the session. + + turn_id : str + Turn to close. + request_options : typing.Optional[RequestOptions] Request-specific configuration. Returns ------- - ListZombieTurnsResponse - Stale running turns oldest-first. + None Examples -------- @@ -190,10 +186,16 @@ async def list_zombie(self, *, request_options: typing.Optional[RequestOptions] async def main() -> None: - await client.internal.turns.list_zombie() + await client.internal.turns.close_abandoned( + session_id="session_id", + tenant_id="tenant_id", + turn_id="turn_id", + ) asyncio.run(main()) """ - _response = await self._raw_client.list_zombie(request_options=request_options) + _response = await self._raw_client.close_abandoned( + session_id=session_id, tenant_id=tenant_id, turn_id=turn_id, request_options=request_options + ) return _response.data diff --git a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py index 9ea37e4ad..9d065cf29 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py +++ b/python/trueforge_sdk/src/trueforge_sdk/internal/turns/raw_client.py @@ -11,7 +11,7 @@ from ...core.unchecked_base_model import construct_type from ...errors.not_found_error import NotFoundError from ...errors.unauthorized_error import UnauthorizedError -from ...types.list_zombie_turns_response import ListZombieTurnsResponse +from ...types.list_abandoned_turns_response import ListAbandonedTurnsResponse from ...types.request_error_response import RequestErrorResponse from pydantic import ValidationError @@ -23,11 +23,62 @@ class RawTurnsClient: def __init__(self, *, client_wrapper: SyncClientWrapper): self._client_wrapper = client_wrapper - def close_zombie( + def list_abandoned( + self, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[ListAbandonedTurnsResponse]: + """ + Returns all running turns whose last activity is past the stale threshold. + + Parameters + ---------- + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ListAbandonedTurnsResponse] + Stale running turns oldest-first. + """ + _response = self._client_wrapper.httpx_client.request( + "api/internal/turns/abandoned", + method="GET", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ListAbandonedTurnsResponse, + construct_type( + type_=ListAbandonedTurnsResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + RequestErrorResponse, + construct_type( + type_=RequestErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def close_abandoned( self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None ) -> HttpResponse[None]: """ - Cancels a zombie turn (turn.done with abandoned reason). + Cancels a stale running turn (turn.done with abandoned reason). Parameters ---------- @@ -48,7 +99,7 @@ def close_zombie( HttpResponse[None] """ _response = self._client_wrapper.httpx_client.request( - "api/internal/turns/close-zombie", + "api/internal/turns/close-abandoned", method="POST", json={ "session_id": session_id, @@ -95,9 +146,14 @@ def close_zombie( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - def list_zombie( + +class AsyncRawTurnsClient: + def __init__(self, *, client_wrapper: AsyncClientWrapper): + self._client_wrapper = client_wrapper + + async def list_abandoned( self, *, request_options: typing.Optional[RequestOptions] = None - ) -> HttpResponse[ListZombieTurnsResponse]: + ) -> AsyncHttpResponse[ListAbandonedTurnsResponse]: """ Returns all running turns whose last activity is past the stale threshold. @@ -108,24 +164,24 @@ def list_zombie( Returns ------- - HttpResponse[ListZombieTurnsResponse] + AsyncHttpResponse[ListAbandonedTurnsResponse] Stale running turns oldest-first. """ - _response = self._client_wrapper.httpx_client.request( - "api/internal/turns/zombie", + _response = await self._client_wrapper.httpx_client.request( + "api/internal/turns/abandoned", method="GET", request_options=request_options, ) try: if 200 <= _response.status_code < 300: _data = typing.cast( - ListZombieTurnsResponse, + ListAbandonedTurnsResponse, construct_type( - type_=ListZombieTurnsResponse, # type: ignore + type_=ListAbandonedTurnsResponse, # type: ignore object_=_response.json(), ), ) - return HttpResponse(response=_response, data=_data) + return AsyncHttpResponse(response=_response, data=_data) if _response.status_code == 401: raise UnauthorizedError( headers=dict(_response.headers), @@ -146,16 +202,11 @@ def list_zombie( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - -class AsyncRawTurnsClient: - def __init__(self, *, client_wrapper: AsyncClientWrapper): - self._client_wrapper = client_wrapper - - async def close_zombie( + async def close_abandoned( self, *, session_id: str, tenant_id: str, turn_id: str, request_options: typing.Optional[RequestOptions] = None ) -> AsyncHttpResponse[None]: """ - Cancels a zombie turn (turn.done with abandoned reason). + Cancels a stale running turn (turn.done with abandoned reason). Parameters ---------- @@ -176,7 +227,7 @@ async def close_zombie( AsyncHttpResponse[None] """ _response = await self._client_wrapper.httpx_client.request( - "api/internal/turns/close-zombie", + "api/internal/turns/close-abandoned", method="POST", json={ "session_id": session_id, @@ -222,54 +273,3 @@ async def close_zombie( status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) - - async def list_zombie( - self, *, request_options: typing.Optional[RequestOptions] = None - ) -> AsyncHttpResponse[ListZombieTurnsResponse]: - """ - Returns all running turns whose last activity is past the stale threshold. - - Parameters - ---------- - request_options : typing.Optional[RequestOptions] - Request-specific configuration. - - Returns - ------- - AsyncHttpResponse[ListZombieTurnsResponse] - Stale running turns oldest-first. - """ - _response = await self._client_wrapper.httpx_client.request( - "api/internal/turns/zombie", - method="GET", - request_options=request_options, - ) - try: - if 200 <= _response.status_code < 300: - _data = typing.cast( - ListZombieTurnsResponse, - construct_type( - type_=ListZombieTurnsResponse, # type: ignore - object_=_response.json(), - ), - ) - return AsyncHttpResponse(response=_response, data=_data) - if _response.status_code == 401: - raise UnauthorizedError( - headers=dict(_response.headers), - body=typing.cast( - RequestErrorResponse, - construct_type( - type_=RequestErrorResponse, # type: ignore - object_=_response.json(), - ), - ), - ) - _response_json = _response.json() - except JSONDecodeError: - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) - except ValidationError as e: - raise ParsingError( - status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e - ) - raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) diff --git a/python/trueforge_sdk/src/trueforge_sdk/types/__init__.py b/python/trueforge_sdk/src/trueforge_sdk/types/__init__.py index 7be6f3e1f..2b3b2f665 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/types/__init__.py +++ b/python/trueforge_sdk/src/trueforge_sdk/types/__init__.py @@ -6,6 +6,7 @@ from importlib import import_module if typing.TYPE_CHECKING: + from .abandoned_turn import AbandonedTurn from .action_required import ActionRequired from .action_required_event import ActionRequiredEvent from .agent import Agent @@ -102,6 +103,7 @@ from .initial_user_message import InitialUserMessage from .input_tokens_compaction_trigger import InputTokensCompactionTrigger from .large_tool_response_config import LargeToolResponseConfig + from .list_abandoned_turns_response import ListAbandonedTurnsResponse from .list_agents_response import ListAgentsResponse from .list_available_mcp_servers_response import ListAvailableMcpServersResponse from .list_available_models_response import ListAvailableModelsResponse @@ -123,7 +125,6 @@ from .list_turn_events_order import ListTurnEventsOrder from .list_turn_events_response import ListTurnEventsResponse from .list_turns_response import ListTurnsResponse - from .list_zombie_turns_response import ListZombieTurnsResponse from .mcp_auth_required_event import McpAuthRequiredEvent from .mcp_auth_status import McpAuthStatus from .mcp_auth_status_status import McpAuthStatusStatus @@ -276,8 +277,8 @@ from .web_search_config import WebSearchConfig from .web_search_provider_manifest import WebSearchProviderManifest from .zai_model_provider import ZaiModelProvider - from .zombie_turn import ZombieTurn _dynamic_imports: typing.Dict[str, str] = { + "AbandonedTurn": ".abandoned_turn", "ActionRequired": ".action_required", "ActionRequiredEvent": ".action_required_event", "Agent": ".agent", @@ -374,6 +375,7 @@ "InitialUserMessage": ".initial_user_message", "InputTokensCompactionTrigger": ".input_tokens_compaction_trigger", "LargeToolResponseConfig": ".large_tool_response_config", + "ListAbandonedTurnsResponse": ".list_abandoned_turns_response", "ListAgentsResponse": ".list_agents_response", "ListAvailableMcpServersResponse": ".list_available_mcp_servers_response", "ListAvailableModelsResponse": ".list_available_models_response", @@ -395,7 +397,6 @@ "ListTurnEventsOrder": ".list_turn_events_order", "ListTurnEventsResponse": ".list_turn_events_response", "ListTurnsResponse": ".list_turns_response", - "ListZombieTurnsResponse": ".list_zombie_turns_response", "McpAuthRequiredEvent": ".mcp_auth_required_event", "McpAuthStatus": ".mcp_auth_status", "McpAuthStatusStatus": ".mcp_auth_status_status", @@ -548,7 +549,6 @@ "WebSearchConfig": ".web_search_config", "WebSearchProviderManifest": ".web_search_provider_manifest", "ZaiModelProvider": ".zai_model_provider", - "ZombieTurn": ".zombie_turn", } @@ -574,6 +574,7 @@ def __dir__(): __all__ = [ + "AbandonedTurn", "ActionRequired", "ActionRequiredEvent", "Agent", @@ -670,6 +671,7 @@ def __dir__(): "InitialUserMessage", "InputTokensCompactionTrigger", "LargeToolResponseConfig", + "ListAbandonedTurnsResponse", "ListAgentsResponse", "ListAvailableMcpServersResponse", "ListAvailableModelsResponse", @@ -691,7 +693,6 @@ def __dir__(): "ListTurnEventsOrder", "ListTurnEventsResponse", "ListTurnsResponse", - "ListZombieTurnsResponse", "McpAuthRequiredEvent", "McpAuthStatus", "McpAuthStatusStatus", @@ -844,5 +845,4 @@ def __dir__(): "WebSearchConfig", "WebSearchProviderManifest", "ZaiModelProvider", - "ZombieTurn", ] diff --git a/python/trueforge_sdk/src/trueforge_sdk/types/zombie_turn.py b/python/trueforge_sdk/src/trueforge_sdk/types/abandoned_turn.py similarity index 94% rename from python/trueforge_sdk/src/trueforge_sdk/types/zombie_turn.py rename to python/trueforge_sdk/src/trueforge_sdk/types/abandoned_turn.py index 4153c9270..1a90a1973 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/types/zombie_turn.py +++ b/python/trueforge_sdk/src/trueforge_sdk/types/abandoned_turn.py @@ -7,7 +7,7 @@ from ..core.unchecked_base_model import UncheckedBaseModel -class ZombieTurn(UncheckedBaseModel): +class AbandonedTurn(UncheckedBaseModel): session_id: str = pydantic.Field() """ Session that owns the turn. diff --git a/python/trueforge_sdk/src/trueforge_sdk/types/list_zombie_turns_response.py b/python/trueforge_sdk/src/trueforge_sdk/types/list_abandoned_turns_response.py similarity index 77% rename from python/trueforge_sdk/src/trueforge_sdk/types/list_zombie_turns_response.py rename to python/trueforge_sdk/src/trueforge_sdk/types/list_abandoned_turns_response.py index 9c3407db3..eaf952572 100644 --- a/python/trueforge_sdk/src/trueforge_sdk/types/list_zombie_turns_response.py +++ b/python/trueforge_sdk/src/trueforge_sdk/types/list_abandoned_turns_response.py @@ -5,11 +5,11 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2 from ..core.unchecked_base_model import UncheckedBaseModel -from .zombie_turn import ZombieTurn +from .abandoned_turn import AbandonedTurn -class ListZombieTurnsResponse(UncheckedBaseModel): - data: typing.List[ZombieTurn] +class ListAbandonedTurnsResponse(UncheckedBaseModel): + data: typing.List[AbandonedTurn] if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow") # type: ignore # Pydantic v2