From 6bacba67085ed90b2e7a7befe7b2667a6d20d143 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 10:12:12 +0000 Subject: [PATCH 1/9] Move the Environment's tenant, configuration and preparation input onto its own row --- .../db/queries/admin_session_archive.sql | 4 +- services/core/internal/db/queries/devices.sql | 17 ++- .../db/queries/environment_connections.sql | 14 +-- .../environment_executor_credentials.sql | 8 +- .../db/queries/environment_file_writes.sql | 9 +- .../db/queries/environment_initialization.sql | 9 +- .../db/queries/environment_input_activity.sql | 12 +- .../internal/db/queries/environment_setup.sql | 14 +-- .../core/internal/db/queries/environments.sql | 28 +++-- .../db/queries/initial_environment_files.sql | 13 +- .../db/queries/local_environment_devices.sql | 13 +- .../db/queries/runtime_allocations.sql | 20 +-- .../db/queries/runtime_cancellation.sql | 4 +- .../db/queries/runtime_deployment.sql | 10 +- .../db/queries/runtime_enrollment.sql | 8 +- .../internal/db/queries/runtime_history.sql | 4 +- .../db/queries/runtime_lifecycle_nodes.sql | 26 ++-- .../internal/db/queries/runtime_nodes.sql | 8 +- .../db/queries/runtime_suspension.sql | 30 ++--- .../queries/runtime_suspension_pressure.sql | 6 +- .../internal/db/queries/sandbox_reset.sql | 18 +-- .../core/internal/db/queries/workspaces.sql | 23 ++-- .../db/sqlc/admin_session_archive.sql.go | 4 +- .../internal/db/sqlc/admin_summary.sql.go | 3 +- services/core/internal/db/sqlc/devices.sql.go | 36 ++++-- .../db/sqlc/environment_connections.sql.go | 34 +++-- .../environment_executor_credentials.sql.go | 8 +- .../db/sqlc/environment_file_writes.sql.go | 15 +-- .../db/sqlc/environment_initialization.sql.go | 13 +- .../db/sqlc/environment_input_activity.sql.go | 16 +-- .../internal/db/sqlc/environment_setup.sql.go | 39 +++--- .../core/internal/db/sqlc/environments.sql.go | 116 ++++++++++++++---- .../db/sqlc/initial_environment_files.sql.go | 58 +++++---- .../db/sqlc/local_environment_devices.sql.go | 15 ++- services/core/internal/db/sqlc/models.go | 25 ++-- .../db/sqlc/runtime_allocations.sql.go | 48 ++++---- .../db/sqlc/runtime_cancellation.sql.go | 4 +- .../db/sqlc/runtime_deployment.sql.go | 10 +- .../db/sqlc/runtime_enrollment.sql.go | 8 +- .../internal/db/sqlc/runtime_history.sql.go | 4 +- .../db/sqlc/runtime_lifecycle_nodes.sql.go | 40 +++--- .../internal/db/sqlc/runtime_nodes.sql.go | 20 +-- .../db/sqlc/runtime_suspension.sql.go | 42 ++++--- .../sqlc/runtime_suspension_pressure.sql.go | 6 +- .../internal/db/sqlc/sandbox_reset.sql.go | 18 +-- .../core/internal/db/sqlc/sessions.sql.go | 18 ++- .../core/internal/db/sqlc/workspaces.sql.go | 39 +++--- .../000014_first_class_environments.sql | 91 ++++++++++++++ 48 files changed, 617 insertions(+), 411 deletions(-) create mode 100644 services/core/migrations/000014_first_class_environments.sql diff --git a/services/core/internal/db/queries/admin_session_archive.sql b/services/core/internal/db/queries/admin_session_archive.sql index 3562ee949..9cd6ba017 100644 --- a/services/core/internal/db/queries/admin_session_archive.sql +++ b/services/core/internal/db/queries/admin_session_archive.sql @@ -1,6 +1,6 @@ -- name: GetManagedSessionArchive :one SELECT s.id AS session_id, e.id AS environment_id, - (s.configuration->'environment'->>'type')::text AS environment_type, + COALESCE(e.configuration->>'type', '')::text AS environment_type, CASE WHEN a.state = 'released' THEN 'released' WHEN a.state = 'cleanup_pending' THEN 'cleanup_pending' @@ -8,6 +8,6 @@ SELECT s.id AS session_id, e.id AS environment_id, CASE WHEN a.id IS NULL THEN 'released' ELSE 'cleanup_pending' END ELSE 'active' END::text AS state -FROM sessions s JOIN environments e ON e.session_id = s.id +FROM sessions s JOIN environments e ON e.id = s.environment_id LEFT JOIN runtime_allocations a ON a.environment_id = e.id AND a.state <> 'released' WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL; diff --git a/services/core/internal/db/queries/devices.sql b/services/core/internal/db/queries/devices.sql index 80262106e..945f3e863 100644 --- a/services/core/internal/db/queries/devices.sql +++ b/services/core/internal/db/queries/devices.sql @@ -23,9 +23,7 @@ WHERE devices.id = $1 AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE INSERT INTO session_devices (session_id, device_id) SELECT s.id, d.id FROM sessions s JOIN devices d ON d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.id = $3 AND d.revoked_at IS NULL -AND (d.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id -)) +AND (d.environment_id IS NULL OR s.environment_id = d.environment_id) ON CONFLICT (session_id) DO UPDATE SET device_id = session_devices.device_id WHERE session_devices.device_id = EXCLUDED.device_id RETURNING device_id; @@ -36,9 +34,7 @@ JOIN sessions s ON s.id = b.session_id JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) -AND (d.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id -)); +AND (d.environment_id IS NULL OR s.environment_id = d.environment_id); -- name: GetSessionExecutionBinding :one SELECT d.id, d.name, b.native_session_id, d.environment_id, @@ -48,9 +44,12 @@ JOIN sessions s ON s.id = b.session_id JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) -AND (d.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id -)); +AND (d.environment_id IS NULL OR s.environment_id = d.environment_id); -- name: RememberNativeSession :execrows UPDATE session_devices SET native_session_id = $2 WHERE session_id = $1; + +-- name: GetEnvironmentDevice :one +SELECT d.id, d.name, d.environment_id FROM devices d +WHERE d.tenant_id = $1 AND d.environment_id = $2 AND d.revoked_at IS NULL +AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id); diff --git a/services/core/internal/db/queries/environment_connections.sql b/services/core/internal/db/queries/environment_connections.sql index 3f0d8a2c5..0b05037e7 100644 --- a/services/core/internal/db/queries/environment_connections.sql +++ b/services/core/internal/db/queries/environment_connections.sql @@ -14,22 +14,20 @@ UPDATE environments SET status = $2 WHERE id = $1; -- name: RecordEnvironmentFailure :one UPDATE environments e SET status = 'failed', failure_reason = sqlc.arg(failure_reason), failure_detail = sqlc.arg(failure_detail), failed_at = clock_timestamp() -WHERE e.id = sqlc.arg(id) AND e.session_id = sqlc.arg(session_id) AND e.status NOT IN ('failed', 'expired') -AND EXISTS (SELECT 1 FROM sessions s WHERE s.id = e.session_id AND s.tenant_id = sqlc.arg(tenant_id)) +WHERE e.id = sqlc.arg(id) AND e.tenant_id = sqlc.arg(tenant_id) AND e.status NOT IN ('failed', 'expired') RETURNING e.failed_at; --- name: ExpireSessionEnvironment :execrows +-- name: ExpireEnvironment :execrows UPDATE environments e SET status = 'expired' -WHERE e.id = sqlc.arg(id) AND e.session_id = sqlc.arg(session_id) -AND EXISTS (SELECT 1 FROM sessions s WHERE s.id = e.session_id AND s.tenant_id = sqlc.arg(tenant_id)); +WHERE e.id = sqlc.arg(id) AND e.tenant_id = sqlc.arg(tenant_id); -- name: DeleteEnvironmentConnection :exec DELETE FROM environment_connections WHERE environment_id = $1; -- name: ListEnvironmentConnections :many -SELECT e.id, e.session_id, s.tenant_id -FROM environments e JOIN sessions s ON s.id = e.session_id -WHERE e.id > $1 AND s.deleted_at IS NULL +SELECT e.id, e.tenant_id +FROM environments e +WHERE e.id > $1 AND (NOT e.session_owned OR EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL)) AND (e.status = 'connected' OR EXISTS ( SELECT 1 FROM environment_connections c WHERE c.environment_id = e.id )) diff --git a/services/core/internal/db/queries/environment_executor_credentials.sql b/services/core/internal/db/queries/environment_executor_credentials.sql index 241875d79..2319dffa3 100644 --- a/services/core/internal/db/queries/environment_executor_credentials.sql +++ b/services/core/internal/db/queries/environment_executor_credentials.sql @@ -14,7 +14,7 @@ FROM execution_project_scopes p CROSS JOIN issued WHERE p.tenant_id = sqlc.arg(tenant_id) AND p.organization_id = sqlc.arg(organization_id) AND p.project_id = sqlc.arg(project_id) AND (sqlc.narg(environment_id)::uuid IS NULL OR EXISTS ( - SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id + SELECT 1 FROM environments e JOIN sessions s ON s.environment_id = e.id WHERE e.id = sqlc.narg(environment_id) AND s.tenant_id = p.tenant_id AND s.deleted_at IS NULL AND s.creator_kind = sqlc.arg(subject_kind) AND s.creator_id = sqlc.arg(subject_id) )) @@ -46,7 +46,7 @@ SELECT s.tenant_id FROM environment_executor_credentials c JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id JOIN environments e ON e.id = sqlc.arg(environment_id) -JOIN sessions s ON s.id = e.session_id +JOIN sessions s ON s.environment_id = e.id WHERE c.token_sha256 = sqlc.arg(token_sha256) AND c.revoked_at IS NULL AND c.tenant_id = s.tenant_id AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id AND (c.environment_id IS NULL OR c.environment_id = e.id) AND s.deleted_at IS NULL; @@ -62,8 +62,8 @@ ORDER BY created_at, key_id; SELECT d.id AS device_id, d.executor_key_id, d.created_at AS enrolled_at, d.last_seen_at, a.credential_hash, e.status AS environment_status FROM environments e -JOIN sessions s ON s.id = e.session_id +JOIN sessions s ON s.environment_id = e.id LEFT JOIN devices d ON d.environment_id = e.id AND d.tenant_id = s.tenant_id LEFT JOIN runtime_device_authority a ON a.id = d.id WHERE e.id = sqlc.arg(environment_id) AND s.tenant_id = sqlc.arg(tenant_id) - AND s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'self_hosted'; + AND s.deleted_at IS NULL AND e.configuration->>'type' = 'self_hosted'; diff --git a/services/core/internal/db/queries/environment_file_writes.sql b/services/core/internal/db/queries/environment_file_writes.sql index aa410f249..17f3eae02 100644 --- a/services/core/internal/db/queries/environment_file_writes.sql +++ b/services/core/internal/db/queries/environment_file_writes.sql @@ -1,9 +1,8 @@ -- name: GetEnvironmentFileWrite :one -SELECT sqlc.embed(w), e.session_id +SELECT sqlc.embed(w) FROM environment_file_writes w JOIN environments e ON e.id = w.environment_id -JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = sqlc.arg(tenant_id) AND e.id = sqlc.arg(environment_id) AND w.id = sqlc.arg(id); +WHERE e.tenant_id = sqlc.arg(tenant_id) AND e.id = sqlc.arg(environment_id) AND w.id = sqlc.arg(id); -- name: CreateEnvironmentFileWrite :one INSERT INTO environment_file_writes(id, environment_id, device_id, request_sha256, audit_source) @@ -15,8 +14,8 @@ WHERE environment_id = $1 AND id = $2 AND state = 'pending' RETURNING *; -- name: EnvironmentFileWriteBlocksSession :one SELECT EXISTS ( - SELECT 1 FROM environments e JOIN environment_file_writes w ON w.environment_id = e.id - WHERE e.session_id = $1 AND w.state = 'pending' + SELECT 1 FROM sessions s JOIN environment_file_writes w ON w.environment_id = s.environment_id + WHERE s.id = $1 AND w.state = 'pending' )::boolean; -- name: EnvironmentFileWriteHasPendingInput :one diff --git a/services/core/internal/db/queries/environment_initialization.sql b/services/core/internal/db/queries/environment_initialization.sql index f3d671337..0580e3a58 100644 --- a/services/core/internal/db/queries/environment_initialization.sql +++ b/services/core/internal/db/queries/environment_initialization.sql @@ -1,8 +1,9 @@ -- name: ListEnvironmentInitializations :many -SELECT e.id, e.session_id, s.tenant_id, s.engine, e.initialization, b.device_id -FROM environments e JOIN sessions s ON s.id = e.session_id -LEFT JOIN session_devices b ON b.session_id = s.id -WHERE e.id > $1 AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') +SELECT e.id, e.tenant_id, e.initialization, d.id AS device_id +FROM environments e +LEFT JOIN devices d ON d.environment_id = e.id AND d.revoked_at IS NULL +WHERE e.id > $1 AND (NOT e.session_owned OR EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL)) + AND e.status NOT IN ('failed', 'expired') AND e.initialization IN ('pending', 'running') ORDER BY e.id LIMIT 32; diff --git a/services/core/internal/db/queries/environment_input_activity.sql b/services/core/internal/db/queries/environment_input_activity.sql index 013fb07b9..95538b939 100644 --- a/services/core/internal/db/queries/environment_input_activity.sql +++ b/services/core/internal/db/queries/environment_input_activity.sql @@ -1,15 +1,15 @@ -- name: GetEnvironmentInputActivity :one SELECT r.state, r.is_initial, r.created_at, r.settled_at, r.failure_code, e.id AS environment_id, e.status AS connection_status, - COALESCE(s.configuration->'environment'->>'type', '')::text AS environment_type -FROM environments e -JOIN sessions s ON s.id = e.session_id + COALESCE(e.configuration->>'type', '')::text AS environment_type +FROM sessions s +JOIN environments e ON e.id = s.environment_id JOIN LATERAL ( SELECT * FROM environment_input_reservations - WHERE session_id = e.session_id + WHERE session_id = s.id ORDER BY created_at DESC, id DESC LIMIT 1 ) r ON true -WHERE e.session_id = $1 AND r.state <> 'admitted' +WHERE s.id = $1 AND r.state <> 'admitted' AND NOT EXISTS ( - SELECT 1 FROM turns t WHERE t.session_id = e.session_id + SELECT 1 FROM turns t WHERE t.session_id = s.id AND (t.created_at >= r.created_at OR t.status IN ('queued', 'in_progress', 'waiting')) ); diff --git a/services/core/internal/db/queries/environment_setup.sql b/services/core/internal/db/queries/environment_setup.sql index cafee522c..555925470 100644 --- a/services/core/internal/db/queries/environment_setup.sql +++ b/services/core/internal/db/queries/environment_setup.sql @@ -1,16 +1,16 @@ -- name: CreateEnvironmentSetup :exec -INSERT INTO environment_setups (session_id, contents) VALUES ($1, $2); +INSERT INTO environment_setups (environment_id, contents) VALUES ($1, $2); -- name: GetEnvironmentSetup :one -SELECT f.contents FROM sessions s LEFT JOIN environment_setups f ON s.id = f.session_id -WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL; +SELECT f.contents, (SELECT s.id FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AS owner_session_id +FROM environments e LEFT JOIN environment_setups f ON f.environment_id = e.id +WHERE e.tenant_id = $1 AND e.id = $2; --- name: SetSessionSetupMetadata :exec -UPDATE sessions SET configuration = jsonb_set(configuration, '{environment}', - (configuration->'environment') || jsonb_build_object( +-- name: SetEnvironmentSetupMetadata :exec +UPDATE environments SET configuration = configuration || jsonb_build_object( 'packages', sqlc.arg(packages)::jsonb, 'skills', sqlc.arg(skills)::jsonb, 'plugins', sqlc.arg(plugins)::jsonb, 'capability_directories', sqlc.arg(capability_directories)::jsonb, - 'initialization', true)) + 'initialization', true) WHERE id = $1; diff --git a/services/core/internal/db/queries/environments.sql b/services/core/internal/db/queries/environments.sql index 1e843a5a2..8a43388dc 100644 --- a/services/core/internal/db/queries/environments.sql +++ b/services/core/internal/db/queries/environments.sql @@ -1,17 +1,27 @@ -- name: CreateEnvironment :exec -INSERT INTO environments (id, session_id, initialization) -VALUES ($1, $2, CASE WHEN (SELECT configuration->'environment'->>'type' FROM sessions WHERE id = $2) = 'openai_hosted' - OR EXISTS (SELECT 1 FROM initial_environment_files WHERE session_id = $2) - OR EXISTS (SELECT 1 FROM environment_setups WHERE session_id = $2) THEN 'pending' ELSE 'complete' END); +INSERT INTO environments (id, tenant_id, configuration, initialization) +VALUES (sqlc.arg(id), sqlc.arg(tenant_id), sqlc.arg(configuration), CASE WHEN sqlc.arg(prepared)::boolean THEN 'pending' ELSE 'complete' END); + +-- name: AttachSessionEnvironment :exec +UPDATE sessions SET environment_id = $2 WHERE id = $1; -- name: GetEnvironment :one -SELECT sqlc.embed(e), s.tenant_id, (s.configuration->'environment')::jsonb AS configuration, +SELECT sqlc.embed(e), s.id AS attached_session_id, EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id = e.id)::boolean AS external_workspace -FROM environments e JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND e.id = $2 AND s.deleted_at IS NULL; +FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.tenant_id = $1 AND e.id = $2 AND (s.id IS NOT NULL OR NOT e.session_owned); -- name: GetSessionEnvironment :one -SELECT sqlc.embed(e), s.tenant_id, (s.configuration->'environment')::jsonb AS configuration, +SELECT sqlc.embed(e), s.id AS attached_session_id, EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id = e.id)::boolean AS external_workspace -FROM environments e JOIN sessions s ON s.id = e.session_id +FROM sessions s JOIN environments e ON e.id = s.environment_id WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL; + +-- name: GetEnvironmentAttachment :one +SELECT e.session_owned, s.id AS attached_session_id +FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.tenant_id = $1 AND e.id = $2; + +-- name: LockEnvironment :one +SELECT e.session_owned, (SELECT s.id FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AS attached_session_id +FROM environments e WHERE e.tenant_id = $1 AND e.id = $2 FOR UPDATE; diff --git a/services/core/internal/db/queries/initial_environment_files.sql b/services/core/internal/db/queries/initial_environment_files.sql index 38b49cefa..fe78a21ed 100644 --- a/services/core/internal/db/queries/initial_environment_files.sql +++ b/services/core/internal/db/queries/initial_environment_files.sql @@ -1,15 +1,16 @@ -- name: CreateInitialEnvironmentFile :exec -INSERT INTO initial_environment_files (id, session_id, position, path, size_bytes, contents) +INSERT INTO initial_environment_files (id, environment_id, position, path, size_bytes, contents) VALUES ($1, $2, $3, $4, $5, $6); --- name: SetSessionInitialFileMetadata :exec -UPDATE sessions SET configuration = jsonb_set(configuration, '{environment,files}', $2::jsonb) +-- name: SetEnvironmentInitialFileMetadata :exec +UPDATE environments SET configuration = jsonb_set(configuration, '{files}', $2::jsonb) WHERE id = $1; -- name: GetInitialEnvironmentFile :one -SELECT f.* FROM initial_environment_files f JOIN sessions s ON s.id = f.session_id -WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.position = $3 AND s.deleted_at IS NULL; +SELECT f.*, (SELECT s.id FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AS owner_session_id +FROM initial_environment_files f JOIN environments e ON e.id = f.environment_id +WHERE e.tenant_id = $1 AND f.environment_id = $2 AND f.position = $3; -- name: GetSessionInitializationReady :one -SELECT NOT EXISTS (SELECT 1 FROM environments e WHERE e.session_id = s.id AND e.initialization <> 'complete') AS ready +SELECT NOT EXISTS (SELECT 1 FROM environments e WHERE e.id = s.environment_id AND e.initialization <> 'complete') AS ready FROM sessions s WHERE s.tenant_id = $1 AND s.id = $2; diff --git a/services/core/internal/db/queries/local_environment_devices.sql b/services/core/internal/db/queries/local_environment_devices.sql index d7c756b0a..59819d64a 100644 --- a/services/core/internal/db/queries/local_environment_devices.sql +++ b/services/core/internal/db/queries/local_environment_devices.sql @@ -1,9 +1,10 @@ -- name: CreateEnvironmentDevice :one INSERT INTO devices (id, tenant_id, name, credential_hash, environment_id) -SELECT sqlc.arg(id), s.tenant_id, sqlc.arg(name), sqlc.arg(credential_hash), e.id -FROM environments e JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = sqlc.arg(tenant_id) AND s.id = sqlc.arg(session_id) AND e.id = sqlc.arg(environment_id) -AND s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'openai_hosted' +SELECT sqlc.arg(id), e.tenant_id, sqlc.arg(name), sqlc.arg(credential_hash), e.id +FROM environments e +WHERE e.tenant_id = sqlc.arg(tenant_id) AND e.id = sqlc.arg(environment_id) +AND e.configuration->>'type' = 'openai_hosted' +AND (NOT e.session_owned OR EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL)) AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released') AND NOT EXISTS (SELECT 1 FROM devices previous WHERE previous.environment_id = e.id AND (previous.revoked_at IS NULL OR previous.executor_key_id IS NOT NULL)) ON CONFLICT (environment_id) WHERE revoked_at IS NULL OR executor_key_id IS NOT NULL DO NOTHING @@ -12,10 +13,10 @@ RETURNING id; -- name: BindHostedSessionDevice :one INSERT INTO session_devices(session_id, device_id) SELECT s.id, d.id FROM sessions s JOIN devices d ON d.tenant_id = s.tenant_id -JOIN environments e ON e.id = d.environment_id AND e.session_id = s.id +JOIN environments e ON e.id = d.environment_id AND s.environment_id = e.id WHERE s.tenant_id = $1 AND s.id = $2 AND d.id = $3 AND d.revoked_at IS NULL AND d.executor_key_id IS NULL AND s.deleted_at IS NULL - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' ON CONFLICT (session_id) DO UPDATE SET device_id = EXCLUDED.device_id WHERE session_devices.device_id = EXCLUDED.device_id OR ( EXISTS (SELECT 1 FROM devices previous diff --git a/services/core/internal/db/queries/runtime_allocations.sql b/services/core/internal/db/queries/runtime_allocations.sql index a33ccc532..6bc4d7956 100644 --- a/services/core/internal/db/queries/runtime_allocations.sql +++ b/services/core/internal/db/queries/runtime_allocations.sql @@ -3,29 +3,29 @@ INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, no VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: GetLatestRuntimeAllocation :one -SELECT sqlc.embed(a), e.session_id, s.tenant_id, s.deleted_at, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired +SELECT sqlc.embed(a), s.id AS attached_session_id, e.tenant_id, (e.session_owned AND s.id IS NULL)::boolean AS reclaimed, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id -JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND a.environment_id = $2 +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.tenant_id = $1 AND a.environment_id = $2 ORDER BY a.created_at DESC, a.id DESC LIMIT 1; -- name: ListRuntimeAllocations :many -SELECT sqlc.embed(a), e.session_id, s.tenant_id, s.deleted_at, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired +SELECT sqlc.embed(a), s.id AS attached_session_id, e.tenant_id, (e.session_owned AND s.id IS NULL)::boolean AS reclaimed, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id -JOIN sessions s ON s.id = e.session_id +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL WHERE a.id > $1 AND a.state <> 'released' ORDER BY a.id LIMIT 32; -- name: ListRuntimeObservationSessions :many SELECT s.id, s.tenant_id FROM sessions s -LEFT JOIN environments e ON e.session_id = s.id +JOIN environments e ON e.id = s.environment_id LEFT JOIN runtime_allocations a ON a.environment_id = e.id AND a.state <> 'released' WHERE s.id > $1 AND s.deleted_at IS NULL - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND (a.id IS NULL OR a.state <> 'released') ORDER BY s.id LIMIT $2; @@ -61,11 +61,11 @@ SELECT EXISTS ( SELECT 1 FROM runtime_allocations a JOIN devices d ON d.id = a.device_id AND d.environment_id = a.environment_id JOIN environments e ON e.id = a.environment_id - JOIN sessions s ON s.id = e.session_id + JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL JOIN environment_workspaces w ON w.environment_id = e.id AND w.state = 'ready' - WHERE a.id = $1 AND s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') + WHERE a.id = $1 AND e.status NOT IN ('failed','expired') AND e.initialization = 'complete' - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND d.supported_agent_kinds @> jsonb_build_array(jsonb_build_object( 'kind', s.engine, 'available', true, 'capabilities', jsonb_build_object('retained_native_history', true))) )::boolean; diff --git a/services/core/internal/db/queries/runtime_cancellation.sql b/services/core/internal/db/queries/runtime_cancellation.sql index 600b246b7..bbbafece6 100644 --- a/services/core/internal/db/queries/runtime_cancellation.sql +++ b/services/core/internal/db/queries/runtime_cancellation.sql @@ -3,14 +3,14 @@ SELECT t.id, t.cancel_requested_at FROM devices d JOIN runtime_allocations a ON a.device_id = d.id JOIN environments e ON e.id = a.environment_id AND e.id = d.environment_id -JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id +JOIN sessions s ON s.environment_id = e.id AND s.tenant_id = d.tenant_id JOIN turns t ON t.session_id = s.id AND t.id = d.archive_cancel_turn_id WHERE d.id = sqlc.arg(device_id) AND d.credential_hash = sqlc.arg(credential_hash) AND d.executor_key_id IS NULL AND d.revoked_at IS NOT NULL AND a.state = 'cleanup_pending' AND a.released_at IS NULL AND e.status = 'expired' AND s.deleted_at IS NULL - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND t.status IN ('in_progress', 'waiting') AND t.id::text = ANY(sqlc.arg(run_ids)::text[]) AND t.cancel_requested_at IS NOT NULL diff --git a/services/core/internal/db/queries/runtime_deployment.sql b/services/core/internal/db/queries/runtime_deployment.sql index 68f6da578..cc9f46221 100644 --- a/services/core/internal/db/queries/runtime_deployment.sql +++ b/services/core/internal/db/queries/runtime_deployment.sql @@ -4,11 +4,11 @@ SELECT * FROM runtime_deployment WHERE singleton = true FOR UPDATE; -- name: CountRuntimeDeploymentResources :one SELECT (SELECT count(*) FROM runtime_allocations WHERE state <> 'released')::bigint AS allocations, -((SELECT count(*) FROM environments e JOIN sessions s ON s.id = e.session_id - WHERE s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') +((SELECT count(*) FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL + WHERE (s.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') AND ((e.status = 'pending' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id)) OR EXISTS (SELECT 1 FROM runtime_placements p WHERE p.environment_id = e.id AND p.released_at IS NULL)) - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released'))::bigint + (SELECT count(*) FROM runtime_reset_retained_environments)::bigint)::bigint AS pending; @@ -17,8 +17,8 @@ SELECT (SELECT count(*) FROM runtime_nodes WHERE removed_at IS NULL)::bigint AS nodes, (SELECT count(*) FROM runtime_nodes WHERE removed_at IS NULL AND core_url <> sqlc.arg(public_url)::text)::bigint AS nodes_on_other_address, (SELECT count(*) FROM environment_executor_credentials c - LEFT JOIN environments e ON e.id = c.environment_id LEFT JOIN sessions s ON s.id = e.session_id - WHERE c.revoked_at IS NULL AND (c.environment_id IS NULL OR s.deleted_at IS NULL))::bigint AS self_hosted_executors; + LEFT JOIN environments e ON e.id = c.environment_id LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL + WHERE c.revoked_at IS NULL AND (c.environment_id IS NULL OR s.id IS NOT NULL))::bigint AS self_hosted_executors; -- name: UpdateSandboxIdleSeconds :exec UPDATE runtime_deployment SET idle_seconds = $1 WHERE singleton = true; diff --git a/services/core/internal/db/queries/runtime_enrollment.sql b/services/core/internal/db/queries/runtime_enrollment.sql index 4059c280d..9f912ea37 100644 --- a/services/core/internal/db/queries/runtime_enrollment.sql +++ b/services/core/internal/db/queries/runtime_enrollment.sql @@ -1,15 +1,15 @@ -- name: AuthorizeRuntimeEnrollment :one -SELECT c.key_id, e.session_id, COALESCE(s.configuration->'environment'->>'workspace_directory', '')::text AS workspace_directory +SELECT c.key_id, s.id AS session_id, COALESCE(e.configuration->>'workspace_directory', '')::text AS workspace_directory FROM environment_executor_credentials c JOIN environments e ON e.id = sqlc.arg(environment_id) -JOIN sessions s ON s.id = e.session_id +JOIN sessions s ON s.environment_id = e.id JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id WHERE c.token_sha256 = sqlc.arg(token_sha256) AND c.revoked_at IS NULL AND c.tenant_id = s.tenant_id AND s.tenant_id = sqlc.arg(tenant_id) AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id AND (c.environment_id IS NULL OR c.environment_id = e.id) AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') - AND s.configuration->'environment'->>'type' = 'self_hosted' + AND e.configuration->>'type' = 'self_hosted' FOR SHARE OF c; -- name: EnrollRuntimeDevice :one @@ -30,7 +30,7 @@ WHERE devices.id = sqlc.arg(id) AND EXISTS ( SELECT d.id AS device_id, d.tenant_id, e.id AS environment_id FROM devices d JOIN environments e ON e.id = d.environment_id -JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id +JOIN sessions s ON s.environment_id = e.id AND s.tenant_id = d.tenant_id WHERE d.executor_key_id IS NOT NULL AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') ORDER BY d.id; diff --git a/services/core/internal/db/queries/runtime_history.sql b/services/core/internal/db/queries/runtime_history.sql index 1d57f9ba0..8607a3cde 100644 --- a/services/core/internal/db/queries/runtime_history.sql +++ b/services/core/internal/db/queries/runtime_history.sql @@ -4,10 +4,10 @@ INSERT INTO runtime_history_samples ( status, observed_at_ns, started_at_ns, cpu_usage_seconds, cpu_capacity_cores, memory_usage_bytes, memory_limit_bytes, input_tokens, output_tokens, cpu_utilization_ratio ) -SELECT s.tenant_id, s.id, e.id, sqlc.arg(resolved_at_ns), sqlc.narg(allocation_id), sqlc.arg(provider_type), +SELECT e.tenant_id, s.id, e.id, sqlc.arg(resolved_at_ns), sqlc.narg(allocation_id), sqlc.arg(provider_type), sqlc.arg(status), sqlc.narg(observed_at_ns), sqlc.narg(started_at_ns), sqlc.narg(cpu_usage_seconds), sqlc.narg(cpu_capacity_cores), sqlc.narg(memory_usage_bytes), sqlc.narg(memory_limit_bytes), sqlc.narg(input_tokens), sqlc.narg(output_tokens), sqlc.narg(cpu_utilization_ratio) -FROM sessions s JOIN environments e ON e.session_id = s.id +FROM sessions s JOIN environments e ON e.id = s.environment_id WHERE s.tenant_id = sqlc.arg(tenant_id) AND s.id = sqlc.arg(session_id) AND e.id = sqlc.arg(environment_id) ON CONFLICT (tenant_id, session_id, environment_id, resolved_at_ns) DO NOTHING; diff --git a/services/core/internal/db/queries/runtime_lifecycle_nodes.sql b/services/core/internal/db/queries/runtime_lifecycle_nodes.sql index b5282a2d1..3640eb0be 100644 --- a/services/core/internal/db/queries/runtime_lifecycle_nodes.sql +++ b/services/core/internal/db/queries/runtime_lifecycle_nodes.sql @@ -6,24 +6,24 @@ SELECT NULL::uuid AS id FROM runtime_deployment WHERE mode = 'direct' ORDER BY id; -- name: ListRuntimeAllocationsForNode :many -SELECT sqlc.embed(a), e.session_id, s.tenant_id, s.deleted_at, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired +SELECT sqlc.embed(a), s.id AS attached_session_id, e.tenant_id, (e.session_owned AND s.id IS NULL)::boolean AS reclaimed, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id -JOIN sessions s ON s.id=e.session_id +LEFT JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL WHERE a.node_id IS NOT DISTINCT FROM sqlc.narg(node_id)::uuid AND a.id > sqlc.arg(after_id)::uuid AND a.state<>'released' ORDER BY a.id LIMIT 32; -- name: ListUnallocatedHostedEnvironmentsForNode :many -SELECT e.id, s.tenant_id -FROM environments e JOIN sessions s ON s.id=e.session_id +SELECT e.id, e.tenant_id +FROM environments e LEFT JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL LEFT JOIN runtime_placements p ON p.environment_id=e.id WHERE p.node_id IS NOT DISTINCT FROM sqlc.narg(node_id)::uuid AND (p.environment_id IS NOT NULL OR (SELECT mode = 'direct' FROM runtime_deployment)) AND p.released_at IS NULL AND (SELECT reset_clear IS NULL FROM runtime_deployment) - AND e.id > sqlc.arg(after_id)::uuid AND s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') - AND s.configuration->'environment'->>'type'='openai_hosted' + AND e.id > sqlc.arg(after_id)::uuid AND (s.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') + AND e.configuration->>'type'='openai_hosted' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id=e.id AND a.state<>'released') ORDER BY e.id LIMIT 32; @@ -32,23 +32,23 @@ SELECT d.provider_kind, d.mode, a.id AS allocation_id, a.node_id AS allocation_n p.node_id AS placement_node_id, p.deployment_generation AS placement_generation, p.released_at, CASE WHEN COALESCE(a.deployment_generation, p.deployment_generation, d.generation)=d.generation THEN d.specification ELSE g.specification END::jsonb AS specification -FROM environments e JOIN sessions s ON s.id=e.session_id +FROM environments e CROSS JOIN runtime_deployment d LEFT JOIN runtime_allocations a ON a.environment_id=e.id AND a.state<>'released' LEFT JOIN runtime_placements p ON p.environment_id=e.id LEFT JOIN runtime_deployment_generations g ON g.generation=COALESCE(a.deployment_generation, p.deployment_generation, d.generation) -WHERE s.tenant_id=$1 AND e.id=$2; +WHERE e.tenant_id=$1 AND e.id=$2; -- name: ListPlacementDemand :many WITH demand AS ( -SELECT e.id, s.tenant_id, s.engine, (latest.id IS NOT NULL OR EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id=e.id))::boolean AS retained, - GREATEST(p.released_at, CASE WHEN latest.id IS NULL THEN s.created_at +SELECT e.id, e.tenant_id, COALESCE(s.engine, '')::text AS engine, (latest.id IS NOT NULL OR EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id=e.id))::boolean AS retained, + GREATEST(p.released_at, CASE WHEN latest.id IS NULL THEN e.created_at ELSE LEAST((SELECT min(r.created_at) FROM environment_input_reservations r WHERE r.session_id = s.id AND r.state = 'pending' AND r.deadline > clock_timestamp()), CASE WHEN latest.compute_wake_requested THEN latest.compute_activity_at END) END)::timestamptz AS demanded_at -FROM environments e JOIN sessions s ON s.id = e.session_id +FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL LEFT JOIN runtime_placements p ON p.environment_id = e.id LEFT JOIN LATERAL (SELECT a.id, a.state, a.compute_wake_requested, a.compute_activity_at FROM runtime_allocations a WHERE a.environment_id = e.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1) latest ON true -WHERE s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') - AND s.configuration->'environment'->>'type' = 'openai_hosted' +WHERE (s.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') + AND e.configuration->>'type' = 'openai_hosted' AND (SELECT reset_clear IS NULL AND mode = 'nodes' FROM runtime_deployment) AND NOT EXISTS (SELECT 1 FROM runtime_placements p WHERE p.environment_id = e.id AND p.released_at IS NULL) AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released') diff --git a/services/core/internal/db/queries/runtime_nodes.sql b/services/core/internal/db/queries/runtime_nodes.sql index d8c48bc24..c658f1a84 100644 --- a/services/core/internal/db/queries/runtime_nodes.sql +++ b/services/core/internal/db/queries/runtime_nodes.sql @@ -70,17 +70,17 @@ UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp() -- name: ReleaseUnallocatedRuntimePlacement :exec UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp()) -WHERE environment_id IN(SELECT id FROM environments WHERE session_id=$1) +WHERE environment_id IN(SELECT s.environment_id FROM sessions s WHERE s.id=$1) AND NOT EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.environment_id=runtime_placements.environment_id AND a.state<>'released'); -- name: ListNodeRuntimeAllocations :many -SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,e.initialization,a.created_at,a.environment_id,e.session_id,s.tenant_id -FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id +SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,e.initialization,a.created_at,a.environment_id,s.id AS attached_session_id,e.tenant_id +FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id LEFT JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL WHERE a.node_id=$1 AND a.state<>'released' ORDER BY a.created_at,a.id LIMIT 1000; -- name: CreateSessionRuntimePlacement :exec INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) -SELECT id,$2,sqlc.arg(generation)::bigint FROM environments WHERE session_id=$1; +SELECT environment_id,$2,sqlc.arg(generation)::bigint FROM sessions WHERE id=$1; -- name: SetRuntimeObservation :exec UPDATE runtime_allocations SET observation_error=$4 WHERE id=$1 AND compute_revision=$2 AND state=$3 AND state<>'released'; diff --git a/services/core/internal/db/queries/runtime_suspension.sql b/services/core/internal/db/queries/runtime_suspension.sql index 1bd60f42d..8655d235e 100644 --- a/services/core/internal/db/queries/runtime_suspension.sql +++ b/services/core/internal/db/queries/runtime_suspension.sql @@ -12,9 +12,10 @@ RETURNING *; -- name: TouchRuntimeActivity :exec UPDATE runtime_allocations a SET compute_activity_at = CASE WHEN a.state = 'released' AND a.compute_wake_requested THEN a.compute_activity_at ELSE clock_timestamp() END, compute_wake_requested = true -FROM environments e JOIN sessions s ON s.id = e.session_id -WHERE a.environment_id = e.id AND s.tenant_id = sqlc.arg(tenant_id) - AND e.id = sqlc.arg(environment_id) AND s.deleted_at IS NULL +FROM environments e +WHERE a.environment_id = e.id AND e.tenant_id = sqlc.arg(tenant_id) + AND e.id = sqlc.arg(environment_id) + AND EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AND ((a.state = 'running' AND a.compute_phase <> 'disabled') OR a.id = sqlc.narg(retained_id)::uuid); -- name: ClearRuntimeWake :exec @@ -25,33 +26,34 @@ WHERE id = $1 AND compute_phase = 'running' AND compute_activity_at <= $2; -- name: GetRuntimeActivity :one SELECT clock_timestamp()::timestamptz AS observed_at, GREATEST(a.compute_activity_at, a.compute_phase_changed_at, - CASE WHEN a.node_id IS NULL THEN COALESCE((SELECT max(t.completed_at) FROM turns t WHERE t.session_id = e.session_id), a.created_at) END, - CASE WHEN a.node_id IS NULL THEN (SELECT max(t.completed_at) FROM subagent_turns t WHERE t.session_id = e.session_id) END, + CASE WHEN a.node_id IS NULL THEN COALESCE((SELECT max(t.completed_at) FROM turns t WHERE t.session_id = s.id), a.created_at) END, + CASE WHEN a.node_id IS NULL THEN (SELECT max(t.completed_at) FROM subagent_turns t WHERE t.session_id = s.id) END, (SELECT max(f.settled_at) FROM environment_file_writes f WHERE f.environment_id = e.id))::timestamptz AS last_activity, - (EXISTS (SELECT 1 FROM turns t WHERE t.session_id = e.session_id AND t.status IN ('queued','in_progress','waiting')) - OR EXISTS (SELECT 1 FROM subagent_turns t WHERE t.session_id = e.session_id AND t.status IN ('queued','in_progress','waiting')) - OR EXISTS (SELECT 1 FROM environment_input_reservations r WHERE r.session_id = e.session_id AND r.state = 'pending') + (EXISTS (SELECT 1 FROM turns t WHERE t.session_id = s.id AND t.status IN ('queued','in_progress','waiting')) + OR EXISTS (SELECT 1 FROM subagent_turns t WHERE t.session_id = s.id AND t.status IN ('queued','in_progress','waiting')) + OR EXISTS (SELECT 1 FROM environment_input_reservations r WHERE r.session_id = s.id AND r.state = 'pending') OR EXISTS (SELECT 1 FROM environment_file_writes f WHERE f.environment_id = e.id AND f.state = 'pending'))::boolean AS busy, a.compute_wake_requested FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL WHERE a.id = $1; -- name: RuntimeComputeBlocksAdmission :one SELECT EXISTS ( - SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - WHERE e.session_id = $1 AND a.state <> 'released' AND a.compute_phase NOT IN ('disabled', 'running') + SELECT 1 FROM runtime_allocations a JOIN sessions s ON s.environment_id = a.environment_id + WHERE s.id = $1 AND a.state <> 'released' AND a.compute_phase NOT IN ('disabled', 'running') )::boolean; -- name: RecordRuntimeTerminalActivity :exec UPDATE runtime_allocations a SET compute_activity_at = clock_timestamp() -FROM environments e -WHERE a.environment_id = e.id AND e.session_id = $1 +FROM sessions s +WHERE a.environment_id = s.environment_id AND s.id = $1 AND a.node_id IS NOT NULL AND a.state = 'running'; -- name: SessionHasRuntimeNode :one SELECT EXISTS ( - SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - WHERE e.session_id = $1 AND a.state <> 'released' AND a.node_id IS NOT NULL + SELECT 1 FROM runtime_allocations a JOIN sessions s ON s.environment_id = a.environment_id + WHERE s.id = $1 AND a.state <> 'released' AND a.node_id IS NOT NULL )::boolean; -- name: MoveSuspendedRuntimeCompute :one diff --git a/services/core/internal/db/queries/runtime_suspension_pressure.sql b/services/core/internal/db/queries/runtime_suspension_pressure.sql index e5006f892..fe4d0e768 100644 --- a/services/core/internal/db/queries/runtime_suspension_pressure.sql +++ b/services/core/internal/db/queries/runtime_suspension_pressure.sql @@ -15,9 +15,9 @@ WHERE p.released_at IS NULL AND (a.id IS NULL OR e.initialization IN ('pending', -- name: ListWaitingCheckpointRestores :many SELECT DISTINCT a.node_id, a.deployment_generation, (a.compute_state->'snapshot'->'Compatibility')::jsonb AS checkpoint -FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id +FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL WHERE a.node_id IS NOT NULL AND a.state='running' AND a.compute_phase='suspended' - AND a.compute_retained_until>clock_timestamp() AND s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') + AND a.compute_retained_until>clock_timestamp() AND e.status NOT IN ('failed','expired') AND a.compute_state->'snapshot'->'Compatibility' IS NOT NULL AND (a.compute_wake_requested OR EXISTS(SELECT 1 FROM environment_input_reservations r WHERE r.session_id=s.id AND r.state='pending' AND r.deadline>clock_timestamp())); @@ -26,7 +26,7 @@ SELECT a.id FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id WHERE a.node_id=$1 AND a.state='running' AND a.compute_phase='suspended' AND a.compute_retained_until>clock_timestamp() AND NOT a.compute_wake_requested - AND NOT EXISTS(SELECT 1 FROM environment_input_reservations r WHERE r.session_id=e.session_id AND r.state='pending') + AND NOT EXISTS(SELECT 1 FROM environment_input_reservations r JOIN sessions s ON s.id=r.session_id WHERE s.environment_id=e.id AND r.state='pending') AND NOT EXISTS(SELECT 1 FROM environment_file_writes f WHERE f.environment_id=e.id AND f.state='pending') ORDER BY a.compute_retained_until, a.id; diff --git a/services/core/internal/db/queries/sandbox_reset.sql b/services/core/internal/db/queries/sandbox_reset.sql index cbadae68d..efba74d27 100644 --- a/services/core/internal/db/queries/sandbox_reset.sql +++ b/services/core/internal/db/queries/sandbox_reset.sql @@ -32,14 +32,14 @@ WHERE singleton = true; SELECT ( EXISTS (SELECT 1 FROM turns t WHERE t.session_id = sqlc.arg(session_id) AND status IN ('in_progress', 'waiting')) OR EXISTS (SELECT 1 FROM subagent_turns t WHERE t.session_id = sqlc.arg(session_id) AND status IN ('in_progress', 'waiting')) - OR EXISTS (SELECT 1 FROM environment_file_writes f JOIN environments e ON e.id = f.environment_id - WHERE e.session_id = sqlc.arg(session_id) AND f.state = 'pending') + OR EXISTS (SELECT 1 FROM environment_file_writes f JOIN sessions s ON s.environment_id = f.environment_id + WHERE s.id = sqlc.arg(session_id) AND f.state = 'pending') )::boolean; -- name: ListSandboxResetSessions :many SELECT s.id, s.tenant_id -FROM sessions s JOIN environments e ON e.session_id = s.id -WHERE s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'openai_hosted' +FROM sessions s JOIN environments e ON e.id = s.environment_id +WHERE s.deleted_at IS NULL AND e.configuration->>'type' = 'openai_hosted' AND e.status NOT IN ('failed', 'expired') AND s.id > sqlc.arg(after_id)::uuid AND (EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state NOT IN ('released', 'cleanup_pending')) @@ -61,19 +61,19 @@ WITH deployment AS MATERIALIZED (SELECT * FROM runtime_deployment WHERE singleto observed AS MATERIALIZED (SELECT clock_timestamp() AS as_of), held AS ( SELECT a.deployment_generation, a.node_id, s.id AS session_id, e.id AS environment_id, false AS pending, - (a.state = 'cleanup_pending' OR s.deleted_at IS NOT NULL OR e.status IN ('failed', 'expired') + (a.state = 'cleanup_pending' OR (e.session_owned AND s.id IS NULL) OR e.status IN ('failed', 'expired') OR (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= observed.as_of)) AS cleanup FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - JOIN sessions s ON s.id = e.session_id CROSS JOIN observed + LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL CROSS JOIN observed WHERE a.state <> 'released' UNION ALL SELECT p.deployment_generation, p.node_id, s.id, e.id, true, false - FROM environments e JOIN sessions s ON s.id = e.session_id + FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL LEFT JOIN runtime_placements p ON p.environment_id = e.id AND p.released_at IS NULL - WHERE s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') + WHERE (s.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') AND ((e.status = 'pending' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id)) OR p.environment_id IS NOT NULL) - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released') UNION ALL SELECT r.deployment_generation, NULL::uuid, r.session_id, r.environment_id, true, false diff --git a/services/core/internal/db/queries/workspaces.sql b/services/core/internal/db/queries/workspaces.sql index 7a285939e..b8b42cdd9 100644 --- a/services/core/internal/db/queries/workspaces.sql +++ b/services/core/internal/db/queries/workspaces.sql @@ -30,8 +30,7 @@ SELECT COALESCE(SUM(w.capacity_mib), 0)::bigint AS committed_mib, COALESCE(bool_or(w.capacity_mib = 0), false)::boolean AS unbounded FROM environment_workspaces w JOIN environments e ON e.id = w.environment_id -JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND w.state <> 'deleted'; +WHERE e.tenant_id = $1 AND w.state <> 'deleted'; -- name: ClearActiveWorkspaceConfiguration :exec UPDATE workspace_fs_configurations SET active = false WHERE active; @@ -42,20 +41,19 @@ UPDATE workspace_fs_configurations SET active = true WHERE id = $1; -- name: GetActiveWorkspaceConfiguration :one SELECT * FROM workspace_fs_configurations WHERE active; --- name: LockWorkspaceSession :one -SELECT s.id FROM sessions s JOIN environments e ON e.session_id = s.id -WHERE s.tenant_id = $1 AND e.id = $2 FOR UPDATE OF s; +-- name: LockWorkspaceEnvironment :one +SELECT e.id FROM environments e WHERE e.tenant_id = $1 AND e.id = $2 FOR UPDATE; --- name: WorkspaceSessionDeleted :one -SELECT (deleted_at IS NOT NULL)::boolean AS deleted FROM sessions WHERE id = $1; +-- name: WorkspaceEnvironmentReclaimed :one +SELECT (e.session_owned AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL))::boolean AS reclaimed +FROM environments e WHERE e.id = $1; -- name: GetEnvironmentWorkspace :one -SELECT sqlc.embed(w), sqlc.embed(c), s.tenant_id +SELECT sqlc.embed(w), sqlc.embed(c), e.tenant_id FROM environment_workspaces w JOIN workspace_fs_configurations c ON c.id = w.configuration_id JOIN environments e ON e.id = w.environment_id -JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND e.id = $2; +WHERE e.tenant_id = $1 AND e.id = $2; -- name: InsertEnvironmentWorkspace :exec INSERT INTO environment_workspaces (object_id, environment_id, configuration_id, capacity_mib) @@ -79,13 +77,12 @@ UPDATE environment_workspaces SET state = 'deleted' WHERE object_id = $1 AND state = 'deleting'; -- name: ListWorkspaceDeletionCandidates :many -SELECT sqlc.embed(w), sqlc.embed(c), s.tenant_id +SELECT sqlc.embed(w), sqlc.embed(c), e.tenant_id FROM environment_workspaces w JOIN workspace_fs_configurations c ON c.id = w.configuration_id JOIN environments e ON e.id = w.environment_id -JOIN sessions s ON s.id = e.session_id WHERE w.object_id > $1 AND w.state <> 'deleted' - AND (s.deleted_at IS NOT NULL OR w.state = 'deleting') + AND ((e.session_owned AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL)) OR w.state = 'deleting') AND NOT EXISTS ( SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released' ) diff --git a/services/core/internal/db/sqlc/admin_session_archive.sql.go b/services/core/internal/db/sqlc/admin_session_archive.sql.go index f494fadfd..1a32e9d0e 100644 --- a/services/core/internal/db/sqlc/admin_session_archive.sql.go +++ b/services/core/internal/db/sqlc/admin_session_archive.sql.go @@ -13,7 +13,7 @@ import ( const getManagedSessionArchive = `-- name: GetManagedSessionArchive :one SELECT s.id AS session_id, e.id AS environment_id, - (s.configuration->'environment'->>'type')::text AS environment_type, + COALESCE(e.configuration->>'type', '')::text AS environment_type, CASE WHEN a.state = 'released' THEN 'released' WHEN a.state = 'cleanup_pending' THEN 'cleanup_pending' @@ -21,7 +21,7 @@ SELECT s.id AS session_id, e.id AS environment_id, CASE WHEN a.id IS NULL THEN 'released' ELSE 'cleanup_pending' END ELSE 'active' END::text AS state -FROM sessions s JOIN environments e ON e.session_id = s.id +FROM sessions s JOIN environments e ON e.id = s.environment_id LEFT JOIN runtime_allocations a ON a.environment_id = e.id AND a.state <> 'released' WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL ` diff --git a/services/core/internal/db/sqlc/admin_summary.sql.go b/services/core/internal/db/sqlc/admin_summary.sql.go index 97924d37a..ace569a03 100644 --- a/services/core/internal/db/sqlc/admin_summary.sql.go +++ b/services/core/internal/db/sqlc/admin_summary.sql.go @@ -115,7 +115,7 @@ func (q *Queries) AdminRuntimeTargets(ctx context.Context, arg AdminRuntimeTarge } const adminSummarySessions = `-- name: AdminSummarySessions :many -SELECT s.id, s.tenant_id, s.engine, s.metadata, s.idempotency_key, s.request_hash, s.created_at, s.configuration, s.event_sequence, s.creation_request_hash, s.deleted_at, s.creator_kind, s.creator_id, a.key_id AS creation_key_id FROM sessions s +SELECT s.id, s.tenant_id, s.engine, s.metadata, s.idempotency_key, s.request_hash, s.created_at, s.configuration, s.event_sequence, s.creation_request_hash, s.deleted_at, s.creator_kind, s.creator_id, s.environment_id, a.key_id AS creation_key_id FROM sessions s LEFT JOIN write_audit_owners o ON o.tenant_id=s.tenant_id AND o.resource_type='session' AND o.resource_id=s.id::text LEFT JOIN write_audit_operations a ON a.tenant_id=o.tenant_id AND a.id=o.operation_id WHERE s.tenant_id=$1 AND s.deleted_at IS NULL @@ -165,6 +165,7 @@ func (q *Queries) AdminSummarySessions(ctx context.Context, arg AdminSummarySess &i.Session.DeletedAt, &i.Session.CreatorKind, &i.Session.CreatorID, + &i.Session.EnvironmentID, &i.CreationKeyID, ); err != nil { return nil, err diff --git a/services/core/internal/db/sqlc/devices.sql.go b/services/core/internal/db/sqlc/devices.sql.go index fbe8cbd2c..f12a11eea 100644 --- a/services/core/internal/db/sqlc/devices.sql.go +++ b/services/core/internal/db/sqlc/devices.sql.go @@ -15,9 +15,7 @@ const bindSessionDevice = `-- name: BindSessionDevice :one INSERT INTO session_devices (session_id, device_id) SELECT s.id, d.id FROM sessions s JOIN devices d ON d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.id = $3 AND d.revoked_at IS NULL -AND (d.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id -)) +AND (d.environment_id IS NULL OR s.environment_id = d.environment_id) ON CONFLICT (session_id) DO UPDATE SET device_id = session_devices.device_id WHERE session_devices.device_id = EXCLUDED.device_id RETURNING device_id @@ -109,15 +107,37 @@ func (q *Queries) GetDeviceCredential(ctx context.Context, id pgtype.UUID) (GetD return i, err } +const getEnvironmentDevice = `-- name: GetEnvironmentDevice :one +SELECT d.id, d.name, d.environment_id FROM devices d +WHERE d.tenant_id = $1 AND d.environment_id = $2 AND d.revoked_at IS NULL +AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) +` + +type GetEnvironmentDeviceParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +type GetEnvironmentDeviceRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +func (q *Queries) GetEnvironmentDevice(ctx context.Context, arg GetEnvironmentDeviceParams) (GetEnvironmentDeviceRow, error) { + row := q.db.QueryRow(ctx, getEnvironmentDevice, arg.TenantID, arg.EnvironmentID) + var i GetEnvironmentDeviceRow + err := row.Scan(&i.ID, &i.Name, &i.EnvironmentID) + return i, err +} + const getSessionDevice = `-- name: GetSessionDevice :one SELECT d.id, d.name, d.environment_id FROM session_devices b JOIN sessions s ON s.id = b.session_id JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) -AND (d.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id -)) +AND (d.environment_id IS NULL OR s.environment_id = d.environment_id) ` type GetSessionDeviceParams struct { @@ -146,9 +166,7 @@ JOIN sessions s ON s.id = b.session_id JOIN devices d ON d.id = b.device_id AND d.tenant_id = s.tenant_id WHERE s.tenant_id = $1 AND s.id = $2 AND d.revoked_at IS NULL AND EXISTS (SELECT 1 FROM runtime_device_authority a WHERE a.id = d.id) -AND (d.environment_id IS NULL OR EXISTS ( - SELECT 1 FROM environments e WHERE e.id = d.environment_id AND e.session_id = s.id -)) +AND (d.environment_id IS NULL OR s.environment_id = d.environment_id) ` type GetSessionExecutionBindingParams struct { diff --git a/services/core/internal/db/sqlc/environment_connections.sql.go b/services/core/internal/db/sqlc/environment_connections.sql.go index 96d60f5a8..37bb17080 100644 --- a/services/core/internal/db/sqlc/environment_connections.sql.go +++ b/services/core/internal/db/sqlc/environment_connections.sql.go @@ -34,20 +34,18 @@ func (q *Queries) DeleteEnvironmentConnection(ctx context.Context, environmentID return err } -const expireSessionEnvironment = `-- name: ExpireSessionEnvironment :execrows +const expireEnvironment = `-- name: ExpireEnvironment :execrows UPDATE environments e SET status = 'expired' -WHERE e.id = $1 AND e.session_id = $2 -AND EXISTS (SELECT 1 FROM sessions s WHERE s.id = e.session_id AND s.tenant_id = $3) +WHERE e.id = $1 AND e.tenant_id = $2 ` -type ExpireSessionEnvironmentParams struct { - ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` - TenantID pgtype.UUID `json:"tenant_id"` +type ExpireEnvironmentParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` } -func (q *Queries) ExpireSessionEnvironment(ctx context.Context, arg ExpireSessionEnvironmentParams) (int64, error) { - result, err := q.db.Exec(ctx, expireSessionEnvironment, arg.ID, arg.SessionID, arg.TenantID) +func (q *Queries) ExpireEnvironment(ctx context.Context, arg ExpireEnvironmentParams) (int64, error) { + result, err := q.db.Exec(ctx, expireEnvironment, arg.ID, arg.TenantID) if err != nil { return 0, err } @@ -66,9 +64,9 @@ func (q *Queries) GetEnvironmentConnection(ctx context.Context, environmentID pg } const listEnvironmentConnections = `-- name: ListEnvironmentConnections :many -SELECT e.id, e.session_id, s.tenant_id -FROM environments e JOIN sessions s ON s.id = e.session_id -WHERE e.id > $1 AND s.deleted_at IS NULL +SELECT e.id, e.tenant_id +FROM environments e +WHERE e.id > $1 AND (NOT e.session_owned OR EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL)) AND (e.status = 'connected' OR EXISTS ( SELECT 1 FROM environment_connections c WHERE c.environment_id = e.id )) @@ -76,9 +74,8 @@ ORDER BY e.id LIMIT 32 ` type ListEnvironmentConnectionsRow struct { - ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` - TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` } func (q *Queries) ListEnvironmentConnections(ctx context.Context, id pgtype.UUID) ([]ListEnvironmentConnectionsRow, error) { @@ -90,7 +87,7 @@ func (q *Queries) ListEnvironmentConnections(ctx context.Context, id pgtype.UUID items := []ListEnvironmentConnectionsRow{} for rows.Next() { var i ListEnvironmentConnectionsRow - if err := rows.Scan(&i.ID, &i.SessionID, &i.TenantID); err != nil { + if err := rows.Scan(&i.ID, &i.TenantID); err != nil { return nil, err } items = append(items, i) @@ -103,8 +100,7 @@ func (q *Queries) ListEnvironmentConnections(ctx context.Context, id pgtype.UUID const recordEnvironmentFailure = `-- name: RecordEnvironmentFailure :one UPDATE environments e SET status = 'failed', failure_reason = $1, failure_detail = $2, failed_at = clock_timestamp() -WHERE e.id = $3 AND e.session_id = $4 AND e.status NOT IN ('failed', 'expired') -AND EXISTS (SELECT 1 FROM sessions s WHERE s.id = e.session_id AND s.tenant_id = $5) +WHERE e.id = $3 AND e.tenant_id = $4 AND e.status NOT IN ('failed', 'expired') RETURNING e.failed_at ` @@ -112,7 +108,6 @@ type RecordEnvironmentFailureParams struct { FailureReason pgtype.Text `json:"failure_reason"` FailureDetail []byte `json:"failure_detail"` ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` TenantID pgtype.UUID `json:"tenant_id"` } @@ -121,7 +116,6 @@ func (q *Queries) RecordEnvironmentFailure(ctx context.Context, arg RecordEnviro arg.FailureReason, arg.FailureDetail, arg.ID, - arg.SessionID, arg.TenantID, ) var failed_at pgtype.Timestamptz diff --git a/services/core/internal/db/sqlc/environment_executor_credentials.sql.go b/services/core/internal/db/sqlc/environment_executor_credentials.sql.go index 54db31689..6fb984062 100644 --- a/services/core/internal/db/sqlc/environment_executor_credentials.sql.go +++ b/services/core/internal/db/sqlc/environment_executor_credentials.sql.go @@ -16,7 +16,7 @@ SELECT s.tenant_id FROM environment_executor_credentials c JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id JOIN environments e ON e.id = $1 -JOIN sessions s ON s.id = e.session_id +JOIN sessions s ON s.environment_id = e.id WHERE c.token_sha256 = $2 AND c.revoked_at IS NULL AND c.tenant_id = s.tenant_id AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id AND (c.environment_id IS NULL OR c.environment_id = e.id) AND s.deleted_at IS NULL @@ -58,11 +58,11 @@ const getEnvironmentExecutorConnection = `-- name: GetEnvironmentExecutorConnect SELECT d.id AS device_id, d.executor_key_id, d.created_at AS enrolled_at, d.last_seen_at, a.credential_hash, e.status AS environment_status FROM environments e -JOIN sessions s ON s.id = e.session_id +JOIN sessions s ON s.environment_id = e.id LEFT JOIN devices d ON d.environment_id = e.id AND d.tenant_id = s.tenant_id LEFT JOIN runtime_device_authority a ON a.id = d.id WHERE e.id = $1 AND s.tenant_id = $2 - AND s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'self_hosted' + AND s.deleted_at IS NULL AND e.configuration->>'type' = 'self_hosted' ` type GetEnvironmentExecutorConnectionParams struct { @@ -135,7 +135,7 @@ FROM execution_project_scopes p CROSS JOIN issued WHERE p.tenant_id = $6 AND p.organization_id = $7 AND p.project_id = $8 AND ($4::uuid IS NULL OR EXISTS ( - SELECT 1 FROM environments e JOIN sessions s ON s.id = e.session_id + SELECT 1 FROM environments e JOIN sessions s ON s.environment_id = e.id WHERE e.id = $4 AND s.tenant_id = p.tenant_id AND s.deleted_at IS NULL AND s.creator_kind = $2 AND s.creator_id = $3 )) diff --git a/services/core/internal/db/sqlc/environment_file_writes.sql.go b/services/core/internal/db/sqlc/environment_file_writes.sql.go index 847fa0061..911eea304 100644 --- a/services/core/internal/db/sqlc/environment_file_writes.sql.go +++ b/services/core/internal/db/sqlc/environment_file_writes.sql.go @@ -48,13 +48,13 @@ func (q *Queries) CreateEnvironmentFileWrite(ctx context.Context, arg CreateEnvi const environmentFileWriteBlocksSession = `-- name: EnvironmentFileWriteBlocksSession :one SELECT EXISTS ( - SELECT 1 FROM environments e JOIN environment_file_writes w ON w.environment_id = e.id - WHERE e.session_id = $1 AND w.state = 'pending' + SELECT 1 FROM sessions s JOIN environment_file_writes w ON w.environment_id = s.environment_id + WHERE s.id = $1 AND w.state = 'pending' )::boolean ` -func (q *Queries) EnvironmentFileWriteBlocksSession(ctx context.Context, sessionID pgtype.UUID) (bool, error) { - row := q.db.QueryRow(ctx, environmentFileWriteBlocksSession, sessionID) +func (q *Queries) EnvironmentFileWriteBlocksSession(ctx context.Context, id pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, environmentFileWriteBlocksSession, id) var column_1 bool err := row.Scan(&column_1) return column_1, err @@ -74,11 +74,10 @@ func (q *Queries) EnvironmentFileWriteHasPendingInput(ctx context.Context, sessi } const getEnvironmentFileWrite = `-- name: GetEnvironmentFileWrite :one -SELECT w.id, w.environment_id, w.device_id, w.request_sha256, w.state, w.created_at, w.settled_at, w.audit_source, e.session_id +SELECT w.id, w.environment_id, w.device_id, w.request_sha256, w.state, w.created_at, w.settled_at, w.audit_source FROM environment_file_writes w JOIN environments e ON e.id = w.environment_id -JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND e.id = $2 AND w.id = $3 +WHERE e.tenant_id = $1 AND e.id = $2 AND w.id = $3 ` type GetEnvironmentFileWriteParams struct { @@ -89,7 +88,6 @@ type GetEnvironmentFileWriteParams struct { type GetEnvironmentFileWriteRow struct { EnvironmentFileWrite EnvironmentFileWrite `json:"environment_file_write"` - SessionID pgtype.UUID `json:"session_id"` } func (q *Queries) GetEnvironmentFileWrite(ctx context.Context, arg GetEnvironmentFileWriteParams) (GetEnvironmentFileWriteRow, error) { @@ -104,7 +102,6 @@ func (q *Queries) GetEnvironmentFileWrite(ctx context.Context, arg GetEnvironmen &i.EnvironmentFileWrite.CreatedAt, &i.EnvironmentFileWrite.SettledAt, &i.EnvironmentFileWrite.AuditSource, - &i.SessionID, ) return i, err } diff --git a/services/core/internal/db/sqlc/environment_initialization.sql.go b/services/core/internal/db/sqlc/environment_initialization.sql.go index a3c8c5bbf..3bf079bfc 100644 --- a/services/core/internal/db/sqlc/environment_initialization.sql.go +++ b/services/core/internal/db/sqlc/environment_initialization.sql.go @@ -47,19 +47,18 @@ func (q *Queries) FailEnvironmentInitialization(ctx context.Context, id pgtype.U } const listEnvironmentInitializations = `-- name: ListEnvironmentInitializations :many -SELECT e.id, e.session_id, s.tenant_id, s.engine, e.initialization, b.device_id -FROM environments e JOIN sessions s ON s.id = e.session_id -LEFT JOIN session_devices b ON b.session_id = s.id -WHERE e.id > $1 AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') +SELECT e.id, e.tenant_id, e.initialization, d.id AS device_id +FROM environments e +LEFT JOIN devices d ON d.environment_id = e.id AND d.revoked_at IS NULL +WHERE e.id > $1 AND (NOT e.session_owned OR EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL)) + AND e.status NOT IN ('failed', 'expired') AND e.initialization IN ('pending', 'running') ORDER BY e.id LIMIT 32 ` type ListEnvironmentInitializationsRow struct { ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` TenantID pgtype.UUID `json:"tenant_id"` - Engine string `json:"engine"` Initialization string `json:"initialization"` DeviceID pgtype.UUID `json:"device_id"` } @@ -75,9 +74,7 @@ func (q *Queries) ListEnvironmentInitializations(ctx context.Context, id pgtype. var i ListEnvironmentInitializationsRow if err := rows.Scan( &i.ID, - &i.SessionID, &i.TenantID, - &i.Engine, &i.Initialization, &i.DeviceID, ); err != nil { diff --git a/services/core/internal/db/sqlc/environment_input_activity.sql.go b/services/core/internal/db/sqlc/environment_input_activity.sql.go index b313e556a..2cd2c396a 100644 --- a/services/core/internal/db/sqlc/environment_input_activity.sql.go +++ b/services/core/internal/db/sqlc/environment_input_activity.sql.go @@ -13,17 +13,17 @@ import ( const getEnvironmentInputActivity = `-- name: GetEnvironmentInputActivity :one SELECT r.state, r.is_initial, r.created_at, r.settled_at, r.failure_code, e.id AS environment_id, e.status AS connection_status, - COALESCE(s.configuration->'environment'->>'type', '')::text AS environment_type -FROM environments e -JOIN sessions s ON s.id = e.session_id + COALESCE(e.configuration->>'type', '')::text AS environment_type +FROM sessions s +JOIN environments e ON e.id = s.environment_id JOIN LATERAL ( SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code, settings FROM environment_input_reservations - WHERE session_id = e.session_id + WHERE session_id = s.id ORDER BY created_at DESC, id DESC LIMIT 1 ) r ON true -WHERE e.session_id = $1 AND r.state <> 'admitted' +WHERE s.id = $1 AND r.state <> 'admitted' AND NOT EXISTS ( - SELECT 1 FROM turns t WHERE t.session_id = e.session_id + SELECT 1 FROM turns t WHERE t.session_id = s.id AND (t.created_at >= r.created_at OR t.status IN ('queued', 'in_progress', 'waiting')) ) ` @@ -39,8 +39,8 @@ type GetEnvironmentInputActivityRow struct { EnvironmentType string `json:"environment_type"` } -func (q *Queries) GetEnvironmentInputActivity(ctx context.Context, sessionID pgtype.UUID) (GetEnvironmentInputActivityRow, error) { - row := q.db.QueryRow(ctx, getEnvironmentInputActivity, sessionID) +func (q *Queries) GetEnvironmentInputActivity(ctx context.Context, id pgtype.UUID) (GetEnvironmentInputActivityRow, error) { + row := q.db.QueryRow(ctx, getEnvironmentInputActivity, id) var i GetEnvironmentInputActivityRow err := row.Scan( &i.State, diff --git a/services/core/internal/db/sqlc/environment_setup.sql.go b/services/core/internal/db/sqlc/environment_setup.sql.go index 3085adcc9..1444af2ec 100644 --- a/services/core/internal/db/sqlc/environment_setup.sql.go +++ b/services/core/internal/db/sqlc/environment_setup.sql.go @@ -12,22 +12,23 @@ import ( ) const createEnvironmentSetup = `-- name: CreateEnvironmentSetup :exec -INSERT INTO environment_setups (session_id, contents) VALUES ($1, $2) +INSERT INTO environment_setups (environment_id, contents) VALUES ($1, $2) ` type CreateEnvironmentSetupParams struct { - SessionID pgtype.UUID `json:"session_id"` - Contents []byte `json:"contents"` + EnvironmentID pgtype.UUID `json:"environment_id"` + Contents []byte `json:"contents"` } func (q *Queries) CreateEnvironmentSetup(ctx context.Context, arg CreateEnvironmentSetupParams) error { - _, err := q.db.Exec(ctx, createEnvironmentSetup, arg.SessionID, arg.Contents) + _, err := q.db.Exec(ctx, createEnvironmentSetup, arg.EnvironmentID, arg.Contents) return err } const getEnvironmentSetup = `-- name: GetEnvironmentSetup :one -SELECT f.contents FROM sessions s LEFT JOIN environment_setups f ON s.id = f.session_id -WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL +SELECT f.contents, (SELECT s.id FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AS owner_session_id +FROM environments e LEFT JOIN environment_setups f ON f.environment_id = e.id +WHERE e.tenant_id = $1 AND e.id = $2 ` type GetEnvironmentSetupParams struct { @@ -35,25 +36,29 @@ type GetEnvironmentSetupParams struct { ID pgtype.UUID `json:"id"` } -func (q *Queries) GetEnvironmentSetup(ctx context.Context, arg GetEnvironmentSetupParams) ([]byte, error) { +type GetEnvironmentSetupRow struct { + Contents []byte `json:"contents"` + OwnerSessionID pgtype.UUID `json:"owner_session_id"` +} + +func (q *Queries) GetEnvironmentSetup(ctx context.Context, arg GetEnvironmentSetupParams) (GetEnvironmentSetupRow, error) { row := q.db.QueryRow(ctx, getEnvironmentSetup, arg.TenantID, arg.ID) - var contents []byte - err := row.Scan(&contents) - return contents, err + var i GetEnvironmentSetupRow + err := row.Scan(&i.Contents, &i.OwnerSessionID) + return i, err } -const setSessionSetupMetadata = `-- name: SetSessionSetupMetadata :exec -UPDATE sessions SET configuration = jsonb_set(configuration, '{environment}', - (configuration->'environment') || jsonb_build_object( +const setEnvironmentSetupMetadata = `-- name: SetEnvironmentSetupMetadata :exec +UPDATE environments SET configuration = configuration || jsonb_build_object( 'packages', $2::jsonb, 'skills', $3::jsonb, 'plugins', $4::jsonb, 'capability_directories', $5::jsonb, - 'initialization', true)) + 'initialization', true) WHERE id = $1 ` -type SetSessionSetupMetadataParams struct { +type SetEnvironmentSetupMetadataParams struct { ID pgtype.UUID `json:"id"` Packages []byte `json:"packages"` Skills []byte `json:"skills"` @@ -61,8 +66,8 @@ type SetSessionSetupMetadataParams struct { CapabilityDirectories []byte `json:"capability_directories"` } -func (q *Queries) SetSessionSetupMetadata(ctx context.Context, arg SetSessionSetupMetadataParams) error { - _, err := q.db.Exec(ctx, setSessionSetupMetadata, +func (q *Queries) SetEnvironmentSetupMetadata(ctx context.Context, arg SetEnvironmentSetupMetadataParams) error { + _, err := q.db.Exec(ctx, setEnvironmentSetupMetadata, arg.ID, arg.Packages, arg.Skills, diff --git a/services/core/internal/db/sqlc/environments.sql.go b/services/core/internal/db/sqlc/environments.sql.go index acfb01961..1206f20a1 100644 --- a/services/core/internal/db/sqlc/environments.sql.go +++ b/services/core/internal/db/sqlc/environments.sql.go @@ -11,28 +11,47 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const attachSessionEnvironment = `-- name: AttachSessionEnvironment :exec +UPDATE sessions SET environment_id = $2 WHERE id = $1 +` + +type AttachSessionEnvironmentParams struct { + ID pgtype.UUID `json:"id"` + EnvironmentID pgtype.UUID `json:"environment_id"` +} + +func (q *Queries) AttachSessionEnvironment(ctx context.Context, arg AttachSessionEnvironmentParams) error { + _, err := q.db.Exec(ctx, attachSessionEnvironment, arg.ID, arg.EnvironmentID) + return err +} + const createEnvironment = `-- name: CreateEnvironment :exec -INSERT INTO environments (id, session_id, initialization) -VALUES ($1, $2, CASE WHEN (SELECT configuration->'environment'->>'type' FROM sessions WHERE id = $2) = 'openai_hosted' - OR EXISTS (SELECT 1 FROM initial_environment_files WHERE session_id = $2) - OR EXISTS (SELECT 1 FROM environment_setups WHERE session_id = $2) THEN 'pending' ELSE 'complete' END) +INSERT INTO environments (id, tenant_id, configuration, initialization) +VALUES ($1, $2, $3, CASE WHEN $4::boolean THEN 'pending' ELSE 'complete' END) ` type CreateEnvironmentParams struct { - ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Configuration []byte `json:"configuration"` + Prepared bool `json:"prepared"` } func (q *Queries) CreateEnvironment(ctx context.Context, arg CreateEnvironmentParams) error { - _, err := q.db.Exec(ctx, createEnvironment, arg.ID, arg.SessionID) + _, err := q.db.Exec(ctx, createEnvironment, + arg.ID, + arg.TenantID, + arg.Configuration, + arg.Prepared, + ) return err } const getEnvironment = `-- name: GetEnvironment :one -SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, e.initialization_steps, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration, +SELECT e.id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, e.initialization_steps, e.tenant_id, e.configuration, e.session_owned, e.idempotency_key, e.request_hash, e.creator_kind, e.creator_id, s.id AS attached_session_id, EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id = e.id)::boolean AS external_workspace -FROM environments e JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND e.id = $2 AND s.deleted_at IS NULL +FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.tenant_id = $1 AND e.id = $2 AND (s.id IS NOT NULL OR NOT e.session_owned) ` type GetEnvironmentParams struct { @@ -42,8 +61,7 @@ type GetEnvironmentParams struct { type GetEnvironmentRow struct { Environment Environment `json:"environment"` - TenantID pgtype.UUID `json:"tenant_id"` - Configuration []byte `json:"configuration"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` ExternalWorkspace bool `json:"external_workspace"` } @@ -52,7 +70,6 @@ func (q *Queries) GetEnvironment(ctx context.Context, arg GetEnvironmentParams) var i GetEnvironmentRow err := row.Scan( &i.Environment.ID, - &i.Environment.SessionID, &i.Environment.Status, &i.Environment.CreatedAt, &i.Environment.FailureReason, @@ -60,17 +77,46 @@ func (q *Queries) GetEnvironment(ctx context.Context, arg GetEnvironmentParams) &i.Environment.FailureDetail, &i.Environment.Initialization, &i.Environment.InitializationSteps, - &i.TenantID, - &i.Configuration, + &i.Environment.TenantID, + &i.Environment.Configuration, + &i.Environment.SessionOwned, + &i.Environment.IdempotencyKey, + &i.Environment.RequestHash, + &i.Environment.CreatorKind, + &i.Environment.CreatorID, + &i.AttachedSessionID, &i.ExternalWorkspace, ) return i, err } +const getEnvironmentAttachment = `-- name: GetEnvironmentAttachment :one +SELECT e.session_owned, s.id AS attached_session_id +FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.tenant_id = $1 AND e.id = $2 +` + +type GetEnvironmentAttachmentParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetEnvironmentAttachmentRow struct { + SessionOwned bool `json:"session_owned"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` +} + +func (q *Queries) GetEnvironmentAttachment(ctx context.Context, arg GetEnvironmentAttachmentParams) (GetEnvironmentAttachmentRow, error) { + row := q.db.QueryRow(ctx, getEnvironmentAttachment, arg.TenantID, arg.ID) + var i GetEnvironmentAttachmentRow + err := row.Scan(&i.SessionOwned, &i.AttachedSessionID) + return i, err +} + const getSessionEnvironment = `-- name: GetSessionEnvironment :one -SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, e.initialization_steps, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration, +SELECT e.id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, e.initialization_steps, e.tenant_id, e.configuration, e.session_owned, e.idempotency_key, e.request_hash, e.creator_kind, e.creator_id, s.id AS attached_session_id, EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id = e.id)::boolean AS external_workspace -FROM environments e JOIN sessions s ON s.id = e.session_id +FROM sessions s JOIN environments e ON e.id = s.environment_id WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL ` @@ -81,8 +127,7 @@ type GetSessionEnvironmentParams struct { type GetSessionEnvironmentRow struct { Environment Environment `json:"environment"` - TenantID pgtype.UUID `json:"tenant_id"` - Configuration []byte `json:"configuration"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` ExternalWorkspace bool `json:"external_workspace"` } @@ -91,7 +136,6 @@ func (q *Queries) GetSessionEnvironment(ctx context.Context, arg GetSessionEnvir var i GetSessionEnvironmentRow err := row.Scan( &i.Environment.ID, - &i.Environment.SessionID, &i.Environment.Status, &i.Environment.CreatedAt, &i.Environment.FailureReason, @@ -99,9 +143,37 @@ func (q *Queries) GetSessionEnvironment(ctx context.Context, arg GetSessionEnvir &i.Environment.FailureDetail, &i.Environment.Initialization, &i.Environment.InitializationSteps, - &i.TenantID, - &i.Configuration, + &i.Environment.TenantID, + &i.Environment.Configuration, + &i.Environment.SessionOwned, + &i.Environment.IdempotencyKey, + &i.Environment.RequestHash, + &i.Environment.CreatorKind, + &i.Environment.CreatorID, + &i.AttachedSessionID, &i.ExternalWorkspace, ) return i, err } + +const lockEnvironment = `-- name: LockEnvironment :one +SELECT e.session_owned, (SELECT s.id FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AS attached_session_id +FROM environments e WHERE e.tenant_id = $1 AND e.id = $2 FOR UPDATE +` + +type LockEnvironmentParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type LockEnvironmentRow struct { + SessionOwned bool `json:"session_owned"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` +} + +func (q *Queries) LockEnvironment(ctx context.Context, arg LockEnvironmentParams) (LockEnvironmentRow, error) { + row := q.db.QueryRow(ctx, lockEnvironment, arg.TenantID, arg.ID) + var i LockEnvironmentRow + err := row.Scan(&i.SessionOwned, &i.AttachedSessionID) + return i, err +} diff --git a/services/core/internal/db/sqlc/initial_environment_files.sql.go b/services/core/internal/db/sqlc/initial_environment_files.sql.go index 4a9245d68..84498fab0 100644 --- a/services/core/internal/db/sqlc/initial_environment_files.sql.go +++ b/services/core/internal/db/sqlc/initial_environment_files.sql.go @@ -12,23 +12,23 @@ import ( ) const createInitialEnvironmentFile = `-- name: CreateInitialEnvironmentFile :exec -INSERT INTO initial_environment_files (id, session_id, position, path, size_bytes, contents) +INSERT INTO initial_environment_files (id, environment_id, position, path, size_bytes, contents) VALUES ($1, $2, $3, $4, $5, $6) ` type CreateInitialEnvironmentFileParams struct { - ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` - Position int32 `json:"position"` - Path string `json:"path"` - SizeBytes int64 `json:"size_bytes"` - Contents []byte `json:"contents"` + ID pgtype.UUID `json:"id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + Position int32 `json:"position"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Contents []byte `json:"contents"` } func (q *Queries) CreateInitialEnvironmentFile(ctx context.Context, arg CreateInitialEnvironmentFileParams) error { _, err := q.db.Exec(ctx, createInitialEnvironmentFile, arg.ID, - arg.SessionID, + arg.EnvironmentID, arg.Position, arg.Path, arg.SizeBytes, @@ -38,32 +38,44 @@ func (q *Queries) CreateInitialEnvironmentFile(ctx context.Context, arg CreateIn } const getInitialEnvironmentFile = `-- name: GetInitialEnvironmentFile :one -SELECT f.id, f.session_id, f.position, f.path, f.size_bytes, f.contents FROM initial_environment_files f JOIN sessions s ON s.id = f.session_id -WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.position = $3 AND s.deleted_at IS NULL +SELECT f.id, f.environment_id, f.position, f.path, f.size_bytes, f.contents, (SELECT s.id FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AS owner_session_id +FROM initial_environment_files f JOIN environments e ON e.id = f.environment_id +WHERE e.tenant_id = $1 AND f.environment_id = $2 AND f.position = $3 ` type GetInitialEnvironmentFileParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - SessionID pgtype.UUID `json:"session_id"` - Position int32 `json:"position"` + TenantID pgtype.UUID `json:"tenant_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + Position int32 `json:"position"` } -func (q *Queries) GetInitialEnvironmentFile(ctx context.Context, arg GetInitialEnvironmentFileParams) (InitialEnvironmentFile, error) { - row := q.db.QueryRow(ctx, getInitialEnvironmentFile, arg.TenantID, arg.SessionID, arg.Position) - var i InitialEnvironmentFile +type GetInitialEnvironmentFileRow struct { + ID pgtype.UUID `json:"id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + Position int32 `json:"position"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Contents []byte `json:"contents"` + OwnerSessionID pgtype.UUID `json:"owner_session_id"` +} + +func (q *Queries) GetInitialEnvironmentFile(ctx context.Context, arg GetInitialEnvironmentFileParams) (GetInitialEnvironmentFileRow, error) { + row := q.db.QueryRow(ctx, getInitialEnvironmentFile, arg.TenantID, arg.EnvironmentID, arg.Position) + var i GetInitialEnvironmentFileRow err := row.Scan( &i.ID, - &i.SessionID, + &i.EnvironmentID, &i.Position, &i.Path, &i.SizeBytes, &i.Contents, + &i.OwnerSessionID, ) return i, err } const getSessionInitializationReady = `-- name: GetSessionInitializationReady :one -SELECT NOT EXISTS (SELECT 1 FROM environments e WHERE e.session_id = s.id AND e.initialization <> 'complete') AS ready +SELECT NOT EXISTS (SELECT 1 FROM environments e WHERE e.id = s.environment_id AND e.initialization <> 'complete') AS ready FROM sessions s WHERE s.tenant_id = $1 AND s.id = $2 ` @@ -79,17 +91,17 @@ func (q *Queries) GetSessionInitializationReady(ctx context.Context, arg GetSess return ready, err } -const setSessionInitialFileMetadata = `-- name: SetSessionInitialFileMetadata :exec -UPDATE sessions SET configuration = jsonb_set(configuration, '{environment,files}', $2::jsonb) +const setEnvironmentInitialFileMetadata = `-- name: SetEnvironmentInitialFileMetadata :exec +UPDATE environments SET configuration = jsonb_set(configuration, '{files}', $2::jsonb) WHERE id = $1 ` -type SetSessionInitialFileMetadataParams struct { +type SetEnvironmentInitialFileMetadataParams struct { ID pgtype.UUID `json:"id"` Column2 []byte `json:"column_2"` } -func (q *Queries) SetSessionInitialFileMetadata(ctx context.Context, arg SetSessionInitialFileMetadataParams) error { - _, err := q.db.Exec(ctx, setSessionInitialFileMetadata, arg.ID, arg.Column2) +func (q *Queries) SetEnvironmentInitialFileMetadata(ctx context.Context, arg SetEnvironmentInitialFileMetadataParams) error { + _, err := q.db.Exec(ctx, setEnvironmentInitialFileMetadata, arg.ID, arg.Column2) return err } diff --git a/services/core/internal/db/sqlc/local_environment_devices.sql.go b/services/core/internal/db/sqlc/local_environment_devices.sql.go index 09774b54d..b8062357d 100644 --- a/services/core/internal/db/sqlc/local_environment_devices.sql.go +++ b/services/core/internal/db/sqlc/local_environment_devices.sql.go @@ -14,10 +14,10 @@ import ( const bindHostedSessionDevice = `-- name: BindHostedSessionDevice :one INSERT INTO session_devices(session_id, device_id) SELECT s.id, d.id FROM sessions s JOIN devices d ON d.tenant_id = s.tenant_id -JOIN environments e ON e.id = d.environment_id AND e.session_id = s.id +JOIN environments e ON e.id = d.environment_id AND s.environment_id = e.id WHERE s.tenant_id = $1 AND s.id = $2 AND d.id = $3 AND d.revoked_at IS NULL AND d.executor_key_id IS NULL AND s.deleted_at IS NULL - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' ON CONFLICT (session_id) DO UPDATE SET device_id = EXCLUDED.device_id WHERE session_devices.device_id = EXCLUDED.device_id OR ( EXISTS (SELECT 1 FROM devices previous @@ -47,10 +47,11 @@ func (q *Queries) BindHostedSessionDevice(ctx context.Context, arg BindHostedSes const createEnvironmentDevice = `-- name: CreateEnvironmentDevice :one INSERT INTO devices (id, tenant_id, name, credential_hash, environment_id) -SELECT $1, s.tenant_id, $2, $3, e.id -FROM environments e JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $4 AND s.id = $5 AND e.id = $6 -AND s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'openai_hosted' +SELECT $1, e.tenant_id, $2, $3, e.id +FROM environments e +WHERE e.tenant_id = $4 AND e.id = $5 +AND e.configuration->>'type' = 'openai_hosted' +AND (NOT e.session_owned OR EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL)) AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released') AND NOT EXISTS (SELECT 1 FROM devices previous WHERE previous.environment_id = e.id AND (previous.revoked_at IS NULL OR previous.executor_key_id IS NOT NULL)) ON CONFLICT (environment_id) WHERE revoked_at IS NULL OR executor_key_id IS NOT NULL DO NOTHING @@ -62,7 +63,6 @@ type CreateEnvironmentDeviceParams struct { Name string `json:"name"` CredentialHash pgtype.Text `json:"credential_hash"` TenantID pgtype.UUID `json:"tenant_id"` - SessionID pgtype.UUID `json:"session_id"` EnvironmentID pgtype.UUID `json:"environment_id"` } @@ -72,7 +72,6 @@ func (q *Queries) CreateEnvironmentDevice(ctx context.Context, arg CreateEnviron arg.Name, arg.CredentialHash, arg.TenantID, - arg.SessionID, arg.EnvironmentID, ) var id pgtype.UUID diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index 3dee63fc7..0e318904c 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -100,7 +100,6 @@ type Device struct { type Environment struct { ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` FailureReason pgtype.Text `json:"failure_reason"` @@ -108,6 +107,13 @@ type Environment struct { FailureDetail []byte `json:"failure_detail"` Initialization string `json:"initialization"` InitializationSteps []byte `json:"initialization_steps"` + TenantID pgtype.UUID `json:"tenant_id"` + Configuration []byte `json:"configuration"` + SessionOwned bool `json:"session_owned"` + IdempotencyKey pgtype.Text `json:"idempotency_key"` + RequestHash pgtype.Text `json:"request_hash"` + CreatorKind pgtype.Text `json:"creator_kind"` + CreatorID pgtype.Text `json:"creator_id"` } type EnvironmentConnection struct { @@ -154,8 +160,8 @@ type EnvironmentInputReservation struct { } type EnvironmentSetup struct { - SessionID pgtype.UUID `json:"session_id"` - Contents []byte `json:"contents"` + EnvironmentID pgtype.UUID `json:"environment_id"` + Contents []byte `json:"contents"` } type EnvironmentTemplate struct { @@ -207,12 +213,12 @@ type FunctionCall struct { } type InitialEnvironmentFile struct { - ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` - Position int32 `json:"position"` - Path string `json:"path"` - SizeBytes int64 `json:"size_bytes"` - Contents []byte `json:"contents"` + ID pgtype.UUID `json:"id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + Position int32 `json:"position"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Contents []byte `json:"contents"` } type NodeHostHistorySample struct { @@ -394,6 +400,7 @@ type Session struct { DeletedAt pgtype.Timestamptz `json:"deleted_at"` CreatorKind pgtype.Text `json:"creator_kind"` CreatorID pgtype.Text `json:"creator_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` } type SessionArtifact struct { diff --git a/services/core/internal/db/sqlc/runtime_allocations.sql.go b/services/core/internal/db/sqlc/runtime_allocations.sql.go index 3c85fc718..9d66e996a 100644 --- a/services/core/internal/db/sqlc/runtime_allocations.sql.go +++ b/services/core/internal/db/sqlc/runtime_allocations.sql.go @@ -33,11 +33,11 @@ SELECT EXISTS ( SELECT 1 FROM runtime_allocations a JOIN devices d ON d.id = a.device_id AND d.environment_id = a.environment_id JOIN environments e ON e.id = a.environment_id - JOIN sessions s ON s.id = e.session_id + JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL JOIN environment_workspaces w ON w.environment_id = e.id AND w.state = 'ready' - WHERE a.id = $1 AND s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') + WHERE a.id = $1 AND e.status NOT IN ('failed','expired') AND e.initialization = 'complete' - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND d.supported_agent_kinds @> jsonb_build_array(jsonb_build_object( 'kind', s.engine, 'available', true, 'capabilities', jsonb_build_object('retained_native_history', true))) )::boolean @@ -98,11 +98,11 @@ func (q *Queries) CreateRuntimeAllocation(ctx context.Context, arg CreateRuntime } const getLatestRuntimeAllocation = `-- name: GetLatestRuntimeAllocation :one -SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired +SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, s.id AS attached_session_id, e.tenant_id, (e.session_owned AND s.id IS NULL)::boolean AS reclaimed, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id -JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND a.environment_id = $2 +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.tenant_id = $1 AND a.environment_id = $2 ORDER BY a.created_at DESC, a.id DESC LIMIT 1 ` @@ -112,11 +112,11 @@ type GetLatestRuntimeAllocationParams struct { } type GetLatestRuntimeAllocationRow struct { - RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` - SessionID pgtype.UUID `json:"session_id"` - TenantID pgtype.UUID `json:"tenant_id"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` - Expired bool `json:"expired"` + RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` + TenantID pgtype.UUID `json:"tenant_id"` + Reclaimed bool `json:"reclaimed"` + Expired bool `json:"expired"` } func (q *Queries) GetLatestRuntimeAllocation(ctx context.Context, arg GetLatestRuntimeAllocationParams) (GetLatestRuntimeAllocationRow, error) { @@ -141,29 +141,29 @@ func (q *Queries) GetLatestRuntimeAllocation(ctx context.Context, arg GetLatestR &i.RuntimeAllocation.ObservationError, &i.RuntimeAllocation.ComputePhaseChangedAt, &i.RuntimeAllocation.DeploymentGeneration, - &i.SessionID, + &i.AttachedSessionID, &i.TenantID, - &i.DeletedAt, + &i.Reclaimed, &i.Expired, ) return i, err } const listRuntimeAllocations = `-- name: ListRuntimeAllocations :many -SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired +SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, s.id AS attached_session_id, e.tenant_id, (e.session_owned AND s.id IS NULL)::boolean AS reclaimed, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id -JOIN sessions s ON s.id = e.session_id +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL WHERE a.id > $1 AND a.state <> 'released' ORDER BY a.id LIMIT 32 ` type ListRuntimeAllocationsRow struct { - RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` - SessionID pgtype.UUID `json:"session_id"` - TenantID pgtype.UUID `json:"tenant_id"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` - Expired bool `json:"expired"` + RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` + TenantID pgtype.UUID `json:"tenant_id"` + Reclaimed bool `json:"reclaimed"` + Expired bool `json:"expired"` } func (q *Queries) ListRuntimeAllocations(ctx context.Context, id pgtype.UUID) ([]ListRuntimeAllocationsRow, error) { @@ -194,9 +194,9 @@ func (q *Queries) ListRuntimeAllocations(ctx context.Context, id pgtype.UUID) ([ &i.RuntimeAllocation.ObservationError, &i.RuntimeAllocation.ComputePhaseChangedAt, &i.RuntimeAllocation.DeploymentGeneration, - &i.SessionID, + &i.AttachedSessionID, &i.TenantID, - &i.DeletedAt, + &i.Reclaimed, &i.Expired, ); err != nil { return nil, err @@ -212,11 +212,11 @@ func (q *Queries) ListRuntimeAllocations(ctx context.Context, id pgtype.UUID) ([ const listRuntimeObservationSessions = `-- name: ListRuntimeObservationSessions :many SELECT s.id, s.tenant_id FROM sessions s -LEFT JOIN environments e ON e.session_id = s.id +JOIN environments e ON e.id = s.environment_id LEFT JOIN runtime_allocations a ON a.environment_id = e.id AND a.state <> 'released' WHERE s.id > $1 AND s.deleted_at IS NULL - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND (a.id IS NULL OR a.state <> 'released') ORDER BY s.id LIMIT $2 diff --git a/services/core/internal/db/sqlc/runtime_cancellation.sql.go b/services/core/internal/db/sqlc/runtime_cancellation.sql.go index dbe9b5ba2..b5e66b925 100644 --- a/services/core/internal/db/sqlc/runtime_cancellation.sql.go +++ b/services/core/internal/db/sqlc/runtime_cancellation.sql.go @@ -16,14 +16,14 @@ SELECT t.id, t.cancel_requested_at FROM devices d JOIN runtime_allocations a ON a.device_id = d.id JOIN environments e ON e.id = a.environment_id AND e.id = d.environment_id -JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id +JOIN sessions s ON s.environment_id = e.id AND s.tenant_id = d.tenant_id JOIN turns t ON t.session_id = s.id AND t.id = d.archive_cancel_turn_id WHERE d.id = $1 AND d.credential_hash = $2 AND d.executor_key_id IS NULL AND d.revoked_at IS NOT NULL AND a.state = 'cleanup_pending' AND a.released_at IS NULL AND e.status = 'expired' AND s.deleted_at IS NULL - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND t.status IN ('in_progress', 'waiting') AND t.id::text = ANY($3::text[]) AND t.cancel_requested_at IS NOT NULL diff --git a/services/core/internal/db/sqlc/runtime_deployment.sql.go b/services/core/internal/db/sqlc/runtime_deployment.sql.go index 9bc5ad9b3..cc26a23a8 100644 --- a/services/core/internal/db/sqlc/runtime_deployment.sql.go +++ b/services/core/internal/db/sqlc/runtime_deployment.sql.go @@ -14,8 +14,8 @@ SELECT (SELECT count(*) FROM runtime_nodes WHERE removed_at IS NULL)::bigint AS nodes, (SELECT count(*) FROM runtime_nodes WHERE removed_at IS NULL AND core_url <> $1::text)::bigint AS nodes_on_other_address, (SELECT count(*) FROM environment_executor_credentials c - LEFT JOIN environments e ON e.id = c.environment_id LEFT JOIN sessions s ON s.id = e.session_id - WHERE c.revoked_at IS NULL AND (c.environment_id IS NULL OR s.deleted_at IS NULL))::bigint AS self_hosted_executors + LEFT JOIN environments e ON e.id = c.environment_id LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL + WHERE c.revoked_at IS NULL AND (c.environment_id IS NULL OR s.id IS NOT NULL))::bigint AS self_hosted_executors ` type CountAddressBindingsRow struct { @@ -34,11 +34,11 @@ func (q *Queries) CountAddressBindings(ctx context.Context, publicUrl string) (C const countRuntimeDeploymentResources = `-- name: CountRuntimeDeploymentResources :one SELECT (SELECT count(*) FROM runtime_allocations WHERE state <> 'released')::bigint AS allocations, -((SELECT count(*) FROM environments e JOIN sessions s ON s.id = e.session_id - WHERE s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') +((SELECT count(*) FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL + WHERE (s.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') AND ((e.status = 'pending' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id)) OR EXISTS (SELECT 1 FROM runtime_placements p WHERE p.environment_id = e.id AND p.released_at IS NULL)) - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released'))::bigint + (SELECT count(*) FROM runtime_reset_retained_environments)::bigint)::bigint AS pending ` diff --git a/services/core/internal/db/sqlc/runtime_enrollment.sql.go b/services/core/internal/db/sqlc/runtime_enrollment.sql.go index 60c482126..328fa6095 100644 --- a/services/core/internal/db/sqlc/runtime_enrollment.sql.go +++ b/services/core/internal/db/sqlc/runtime_enrollment.sql.go @@ -12,17 +12,17 @@ import ( ) const authorizeRuntimeEnrollment = `-- name: AuthorizeRuntimeEnrollment :one -SELECT c.key_id, e.session_id, COALESCE(s.configuration->'environment'->>'workspace_directory', '')::text AS workspace_directory +SELECT c.key_id, s.id AS session_id, COALESCE(e.configuration->>'workspace_directory', '')::text AS workspace_directory FROM environment_executor_credentials c JOIN environments e ON e.id = $1 -JOIN sessions s ON s.id = e.session_id +JOIN sessions s ON s.environment_id = e.id JOIN execution_project_scopes p ON p.tenant_id = c.tenant_id WHERE c.token_sha256 = $2 AND c.revoked_at IS NULL AND c.tenant_id = s.tenant_id AND s.tenant_id = $3 AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id AND (c.environment_id IS NULL OR c.environment_id = e.id) AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') - AND s.configuration->'environment'->>'type' = 'self_hosted' + AND e.configuration->>'type' = 'self_hosted' FOR SHARE OF c ` @@ -82,7 +82,7 @@ const listEnrolledRuntimeBindings = `-- name: ListEnrolledRuntimeBindings :many SELECT d.id AS device_id, d.tenant_id, e.id AS environment_id FROM devices d JOIN environments e ON e.id = d.environment_id -JOIN sessions s ON s.id = e.session_id AND s.tenant_id = d.tenant_id +JOIN sessions s ON s.environment_id = e.id AND s.tenant_id = d.tenant_id WHERE d.executor_key_id IS NOT NULL AND s.deleted_at IS NULL AND e.status NOT IN ('failed', 'expired') ORDER BY d.id diff --git a/services/core/internal/db/sqlc/runtime_history.sql.go b/services/core/internal/db/sqlc/runtime_history.sql.go index c2033ae75..ffb1cc5e5 100644 --- a/services/core/internal/db/sqlc/runtime_history.sql.go +++ b/services/core/internal/db/sqlc/runtime_history.sql.go @@ -17,10 +17,10 @@ INSERT INTO runtime_history_samples ( status, observed_at_ns, started_at_ns, cpu_usage_seconds, cpu_capacity_cores, memory_usage_bytes, memory_limit_bytes, input_tokens, output_tokens, cpu_utilization_ratio ) -SELECT s.tenant_id, s.id, e.id, $1, $2, $3, +SELECT e.tenant_id, s.id, e.id, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 -FROM sessions s JOIN environments e ON e.session_id = s.id +FROM sessions s JOIN environments e ON e.id = s.environment_id WHERE s.tenant_id = $14 AND s.id = $15 AND e.id = $16 ON CONFLICT (tenant_id, session_id, environment_id, resolved_at_ns) DO NOTHING ` diff --git a/services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go b/services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go index bbbb9f352..22224e793 100644 --- a/services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go +++ b/services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go @@ -16,12 +16,12 @@ SELECT d.provider_kind, d.mode, a.id AS allocation_id, a.node_id AS allocation_n p.node_id AS placement_node_id, p.deployment_generation AS placement_generation, p.released_at, CASE WHEN COALESCE(a.deployment_generation, p.deployment_generation, d.generation)=d.generation THEN d.specification ELSE g.specification END::jsonb AS specification -FROM environments e JOIN sessions s ON s.id=e.session_id +FROM environments e CROSS JOIN runtime_deployment d LEFT JOIN runtime_allocations a ON a.environment_id=e.id AND a.state<>'released' LEFT JOIN runtime_placements p ON p.environment_id=e.id LEFT JOIN runtime_deployment_generations g ON g.generation=COALESCE(a.deployment_generation, p.deployment_generation, d.generation) -WHERE s.tenant_id=$1 AND e.id=$2 +WHERE e.tenant_id=$1 AND e.id=$2 ` type GetRuntimeLifecyclePlacementParams struct { @@ -58,14 +58,14 @@ func (q *Queries) GetRuntimeLifecyclePlacement(ctx context.Context, arg GetRunti const listPlacementDemand = `-- name: ListPlacementDemand :many WITH demand AS ( -SELECT e.id, s.tenant_id, s.engine, (latest.id IS NOT NULL OR EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id=e.id))::boolean AS retained, - GREATEST(p.released_at, CASE WHEN latest.id IS NULL THEN s.created_at +SELECT e.id, e.tenant_id, COALESCE(s.engine, '')::text AS engine, (latest.id IS NOT NULL OR EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id=e.id))::boolean AS retained, + GREATEST(p.released_at, CASE WHEN latest.id IS NULL THEN e.created_at ELSE LEAST((SELECT min(r.created_at) FROM environment_input_reservations r WHERE r.session_id = s.id AND r.state = 'pending' AND r.deadline > clock_timestamp()), CASE WHEN latest.compute_wake_requested THEN latest.compute_activity_at END) END)::timestamptz AS demanded_at -FROM environments e JOIN sessions s ON s.id = e.session_id +FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL LEFT JOIN runtime_placements p ON p.environment_id = e.id LEFT JOIN LATERAL (SELECT a.id, a.state, a.compute_wake_requested, a.compute_activity_at FROM runtime_allocations a WHERE a.environment_id = e.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1) latest ON true -WHERE s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') - AND s.configuration->'environment'->>'type' = 'openai_hosted' +WHERE (s.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') + AND e.configuration->>'type' = 'openai_hosted' AND (SELECT reset_clear IS NULL AND mode = 'nodes' FROM runtime_deployment) AND NOT EXISTS (SELECT 1 FROM runtime_placements p WHERE p.environment_id = e.id AND p.released_at IS NULL) AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released') @@ -126,10 +126,10 @@ func (q *Queries) ListPlacementDemand(ctx context.Context, arg ListPlacementDema } const listRuntimeAllocationsForNode = `-- name: ListRuntimeAllocationsForNode :many -SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, e.session_id, s.tenant_id, s.deleted_at, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired +SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, s.id AS attached_session_id, e.tenant_id, (e.session_owned AND s.id IS NULL)::boolean AS reclaimed, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id -JOIN sessions s ON s.id=e.session_id +LEFT JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL WHERE a.node_id IS NOT DISTINCT FROM $1::uuid AND a.id > $2::uuid AND a.state<>'released' ORDER BY a.id LIMIT 32 @@ -141,11 +141,11 @@ type ListRuntimeAllocationsForNodeParams struct { } type ListRuntimeAllocationsForNodeRow struct { - RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` - SessionID pgtype.UUID `json:"session_id"` - TenantID pgtype.UUID `json:"tenant_id"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` - Expired bool `json:"expired"` + RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` + TenantID pgtype.UUID `json:"tenant_id"` + Reclaimed bool `json:"reclaimed"` + Expired bool `json:"expired"` } func (q *Queries) ListRuntimeAllocationsForNode(ctx context.Context, arg ListRuntimeAllocationsForNodeParams) ([]ListRuntimeAllocationsForNodeRow, error) { @@ -176,9 +176,9 @@ func (q *Queries) ListRuntimeAllocationsForNode(ctx context.Context, arg ListRun &i.RuntimeAllocation.ObservationError, &i.RuntimeAllocation.ComputePhaseChangedAt, &i.RuntimeAllocation.DeploymentGeneration, - &i.SessionID, + &i.AttachedSessionID, &i.TenantID, - &i.DeletedAt, + &i.Reclaimed, &i.Expired, ); err != nil { return nil, err @@ -220,15 +220,15 @@ func (q *Queries) ListRuntimeLifecycleNodes(ctx context.Context) ([]pgtype.UUID, } const listUnallocatedHostedEnvironmentsForNode = `-- name: ListUnallocatedHostedEnvironmentsForNode :many -SELECT e.id, s.tenant_id -FROM environments e JOIN sessions s ON s.id=e.session_id +SELECT e.id, e.tenant_id +FROM environments e LEFT JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL LEFT JOIN runtime_placements p ON p.environment_id=e.id WHERE p.node_id IS NOT DISTINCT FROM $1::uuid AND (p.environment_id IS NOT NULL OR (SELECT mode = 'direct' FROM runtime_deployment)) AND p.released_at IS NULL AND (SELECT reset_clear IS NULL FROM runtime_deployment) - AND e.id > $2::uuid AND s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') - AND s.configuration->'environment'->>'type'='openai_hosted' + AND e.id > $2::uuid AND (s.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') + AND e.configuration->>'type'='openai_hosted' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id=e.id AND a.state<>'released') ORDER BY e.id LIMIT 32 ` diff --git a/services/core/internal/db/sqlc/runtime_nodes.sql.go b/services/core/internal/db/sqlc/runtime_nodes.sql.go index b22bfd8a0..9bae328da 100644 --- a/services/core/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/core/internal/db/sqlc/runtime_nodes.sql.go @@ -89,17 +89,17 @@ func (q *Queries) CreateRuntimePlacement(ctx context.Context, arg CreateRuntimeP const createSessionRuntimePlacement = `-- name: CreateSessionRuntimePlacement :exec INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) -SELECT id,$2,$3::bigint FROM environments WHERE session_id=$1 +SELECT environment_id,$2,$3::bigint FROM sessions WHERE id=$1 ` type CreateSessionRuntimePlacementParams struct { - SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` NodeID pgtype.UUID `json:"node_id"` Generation int64 `json:"generation"` } func (q *Queries) CreateSessionRuntimePlacement(ctx context.Context, arg CreateSessionRuntimePlacementParams) error { - _, err := q.db.Exec(ctx, createSessionRuntimePlacement, arg.SessionID, arg.NodeID, arg.Generation) + _, err := q.db.Exec(ctx, createSessionRuntimePlacement, arg.ID, arg.NodeID, arg.Generation) return err } @@ -319,8 +319,8 @@ func (q *Queries) InsertRuntimeNode(ctx context.Context, arg InsertRuntimeNodePa } const listNodeRuntimeAllocations = `-- name: ListNodeRuntimeAllocations :many -SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,e.initialization,a.created_at,a.environment_id,e.session_id,s.tenant_id -FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id +SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,e.initialization,a.created_at,a.environment_id,s.id AS attached_session_id,e.tenant_id +FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id LEFT JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL WHERE a.node_id=$1 AND a.state<>'released' ORDER BY a.created_at,a.id LIMIT 1000 ` @@ -335,7 +335,7 @@ type ListNodeRuntimeAllocationsRow struct { Initialization string `json:"initialization"` CreatedAt pgtype.Timestamptz `json:"created_at"` EnvironmentID pgtype.UUID `json:"environment_id"` - SessionID pgtype.UUID `json:"session_id"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` TenantID pgtype.UUID `json:"tenant_id"` } @@ -359,7 +359,7 @@ func (q *Queries) ListNodeRuntimeAllocations(ctx context.Context, nodeID pgtype. &i.Initialization, &i.CreatedAt, &i.EnvironmentID, - &i.SessionID, + &i.AttachedSessionID, &i.TenantID, ); err != nil { return nil, err @@ -509,12 +509,12 @@ func (q *Queries) ReleaseRuntimePlacement(ctx context.Context, environmentID pgt const releaseUnallocatedRuntimePlacement = `-- name: ReleaseUnallocatedRuntimePlacement :exec UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp()) -WHERE environment_id IN(SELECT id FROM environments WHERE session_id=$1) +WHERE environment_id IN(SELECT s.environment_id FROM sessions s WHERE s.id=$1) AND NOT EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.environment_id=runtime_placements.environment_id AND a.state<>'released') ` -func (q *Queries) ReleaseUnallocatedRuntimePlacement(ctx context.Context, sessionID pgtype.UUID) error { - _, err := q.db.Exec(ctx, releaseUnallocatedRuntimePlacement, sessionID) +func (q *Queries) ReleaseUnallocatedRuntimePlacement(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, releaseUnallocatedRuntimePlacement, id) return err } diff --git a/services/core/internal/db/sqlc/runtime_suspension.sql.go b/services/core/internal/db/sqlc/runtime_suspension.sql.go index 9596b297e..c638b83e2 100644 --- a/services/core/internal/db/sqlc/runtime_suspension.sql.go +++ b/services/core/internal/db/sqlc/runtime_suspension.sql.go @@ -30,15 +30,16 @@ func (q *Queries) ClearRuntimeWake(ctx context.Context, arg ClearRuntimeWakePara const getRuntimeActivity = `-- name: GetRuntimeActivity :one SELECT clock_timestamp()::timestamptz AS observed_at, GREATEST(a.compute_activity_at, a.compute_phase_changed_at, - CASE WHEN a.node_id IS NULL THEN COALESCE((SELECT max(t.completed_at) FROM turns t WHERE t.session_id = e.session_id), a.created_at) END, - CASE WHEN a.node_id IS NULL THEN (SELECT max(t.completed_at) FROM subagent_turns t WHERE t.session_id = e.session_id) END, + CASE WHEN a.node_id IS NULL THEN COALESCE((SELECT max(t.completed_at) FROM turns t WHERE t.session_id = s.id), a.created_at) END, + CASE WHEN a.node_id IS NULL THEN (SELECT max(t.completed_at) FROM subagent_turns t WHERE t.session_id = s.id) END, (SELECT max(f.settled_at) FROM environment_file_writes f WHERE f.environment_id = e.id))::timestamptz AS last_activity, - (EXISTS (SELECT 1 FROM turns t WHERE t.session_id = e.session_id AND t.status IN ('queued','in_progress','waiting')) - OR EXISTS (SELECT 1 FROM subagent_turns t WHERE t.session_id = e.session_id AND t.status IN ('queued','in_progress','waiting')) - OR EXISTS (SELECT 1 FROM environment_input_reservations r WHERE r.session_id = e.session_id AND r.state = 'pending') + (EXISTS (SELECT 1 FROM turns t WHERE t.session_id = s.id AND t.status IN ('queued','in_progress','waiting')) + OR EXISTS (SELECT 1 FROM subagent_turns t WHERE t.session_id = s.id AND t.status IN ('queued','in_progress','waiting')) + OR EXISTS (SELECT 1 FROM environment_input_reservations r WHERE r.session_id = s.id AND r.state = 'pending') OR EXISTS (SELECT 1 FROM environment_file_writes f WHERE f.environment_id = e.id AND f.state = 'pending'))::boolean AS busy, a.compute_wake_requested FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL WHERE a.id = $1 ` @@ -127,25 +128,25 @@ func (q *Queries) MoveSuspendedRuntimeCompute(ctx context.Context, arg MoveSuspe const recordRuntimeTerminalActivity = `-- name: RecordRuntimeTerminalActivity :exec UPDATE runtime_allocations a SET compute_activity_at = clock_timestamp() -FROM environments e -WHERE a.environment_id = e.id AND e.session_id = $1 +FROM sessions s +WHERE a.environment_id = s.environment_id AND s.id = $1 AND a.node_id IS NOT NULL AND a.state = 'running' ` -func (q *Queries) RecordRuntimeTerminalActivity(ctx context.Context, sessionID pgtype.UUID) error { - _, err := q.db.Exec(ctx, recordRuntimeTerminalActivity, sessionID) +func (q *Queries) RecordRuntimeTerminalActivity(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, recordRuntimeTerminalActivity, id) return err } const runtimeComputeBlocksAdmission = `-- name: RuntimeComputeBlocksAdmission :one SELECT EXISTS ( - SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - WHERE e.session_id = $1 AND a.state <> 'released' AND a.compute_phase NOT IN ('disabled', 'running') + SELECT 1 FROM runtime_allocations a JOIN sessions s ON s.environment_id = a.environment_id + WHERE s.id = $1 AND a.state <> 'released' AND a.compute_phase NOT IN ('disabled', 'running') )::boolean ` -func (q *Queries) RuntimeComputeBlocksAdmission(ctx context.Context, sessionID pgtype.UUID) (bool, error) { - row := q.db.QueryRow(ctx, runtimeComputeBlocksAdmission, sessionID) +func (q *Queries) RuntimeComputeBlocksAdmission(ctx context.Context, id pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, runtimeComputeBlocksAdmission, id) var column_1 bool err := row.Scan(&column_1) return column_1, err @@ -153,13 +154,13 @@ func (q *Queries) RuntimeComputeBlocksAdmission(ctx context.Context, sessionID p const sessionHasRuntimeNode = `-- name: SessionHasRuntimeNode :one SELECT EXISTS ( - SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - WHERE e.session_id = $1 AND a.state <> 'released' AND a.node_id IS NOT NULL + SELECT 1 FROM runtime_allocations a JOIN sessions s ON s.environment_id = a.environment_id + WHERE s.id = $1 AND a.state <> 'released' AND a.node_id IS NOT NULL )::boolean ` -func (q *Queries) SessionHasRuntimeNode(ctx context.Context, sessionID pgtype.UUID) (bool, error) { - row := q.db.QueryRow(ctx, sessionHasRuntimeNode, sessionID) +func (q *Queries) SessionHasRuntimeNode(ctx context.Context, id pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, sessionHasRuntimeNode, id) var column_1 bool err := row.Scan(&column_1) return column_1, err @@ -220,9 +221,10 @@ func (q *Queries) SetRuntimeCompute(ctx context.Context, arg SetRuntimeComputePa const touchRuntimeActivity = `-- name: TouchRuntimeActivity :exec UPDATE runtime_allocations a SET compute_activity_at = CASE WHEN a.state = 'released' AND a.compute_wake_requested THEN a.compute_activity_at ELSE clock_timestamp() END, compute_wake_requested = true -FROM environments e JOIN sessions s ON s.id = e.session_id -WHERE a.environment_id = e.id AND s.tenant_id = $1 - AND e.id = $2 AND s.deleted_at IS NULL +FROM environments e +WHERE a.environment_id = e.id AND e.tenant_id = $1 + AND e.id = $2 + AND EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AND ((a.state = 'running' AND a.compute_phase <> 'disabled') OR a.id = $3::uuid) ` diff --git a/services/core/internal/db/sqlc/runtime_suspension_pressure.sql.go b/services/core/internal/db/sqlc/runtime_suspension_pressure.sql.go index 2d851f997..c907ea605 100644 --- a/services/core/internal/db/sqlc/runtime_suspension_pressure.sql.go +++ b/services/core/internal/db/sqlc/runtime_suspension_pressure.sql.go @@ -54,7 +54,7 @@ SELECT a.id FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id WHERE a.node_id=$1 AND a.state='running' AND a.compute_phase='suspended' AND a.compute_retained_until>clock_timestamp() AND NOT a.compute_wake_requested - AND NOT EXISTS(SELECT 1 FROM environment_input_reservations r WHERE r.session_id=e.session_id AND r.state='pending') + AND NOT EXISTS(SELECT 1 FROM environment_input_reservations r JOIN sessions s ON s.id=r.session_id WHERE s.environment_id=e.id AND r.state='pending') AND NOT EXISTS(SELECT 1 FROM environment_file_writes f WHERE f.environment_id=e.id AND f.state='pending') ORDER BY a.compute_retained_until, a.id ` @@ -138,9 +138,9 @@ func (q *Queries) ListRuntimeSuspensionInFlightNodes(ctx context.Context) ([]pgt const listWaitingCheckpointRestores = `-- name: ListWaitingCheckpointRestores :many SELECT DISTINCT a.node_id, a.deployment_generation, (a.compute_state->'snapshot'->'Compatibility')::jsonb AS checkpoint -FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id +FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL WHERE a.node_id IS NOT NULL AND a.state='running' AND a.compute_phase='suspended' - AND a.compute_retained_until>clock_timestamp() AND s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') + AND a.compute_retained_until>clock_timestamp() AND e.status NOT IN ('failed','expired') AND a.compute_state->'snapshot'->'Compatibility' IS NOT NULL AND (a.compute_wake_requested OR EXISTS(SELECT 1 FROM environment_input_reservations r WHERE r.session_id=s.id AND r.state='pending' AND r.deadline>clock_timestamp())) ` diff --git a/services/core/internal/db/sqlc/sandbox_reset.sql.go b/services/core/internal/db/sqlc/sandbox_reset.sql.go index 4412e8214..4d2d805ce 100644 --- a/services/core/internal/db/sqlc/sandbox_reset.sql.go +++ b/services/core/internal/db/sqlc/sandbox_reset.sql.go @@ -54,19 +54,19 @@ WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fing observed AS MATERIALIZED (SELECT clock_timestamp() AS as_of), held AS ( SELECT a.deployment_generation, a.node_id, s.id AS session_id, e.id AS environment_id, false AS pending, - (a.state = 'cleanup_pending' OR s.deleted_at IS NOT NULL OR e.status IN ('failed', 'expired') + (a.state = 'cleanup_pending' OR (e.session_owned AND s.id IS NULL) OR e.status IN ('failed', 'expired') OR (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= observed.as_of)) AS cleanup FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id - JOIN sessions s ON s.id = e.session_id CROSS JOIN observed + LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL CROSS JOIN observed WHERE a.state <> 'released' UNION ALL SELECT p.deployment_generation, p.node_id, s.id, e.id, true, false - FROM environments e JOIN sessions s ON s.id = e.session_id + FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL LEFT JOIN runtime_placements p ON p.environment_id = e.id AND p.released_at IS NULL - WHERE s.deleted_at IS NULL AND e.status NOT IN ('failed','expired') + WHERE (s.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') AND ((e.status = 'pending' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id)) OR p.environment_id IS NOT NULL) - AND s.configuration->'environment'->>'type' = 'openai_hosted' + AND e.configuration->>'type' = 'openai_hosted' AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released') UNION ALL SELECT r.deployment_generation, NULL::uuid, r.session_id, r.environment_id, true, false @@ -167,8 +167,8 @@ func (q *Queries) GetSandboxResetProject(ctx context.Context, tenantID pgtype.UU const listSandboxResetSessions = `-- name: ListSandboxResetSessions :many SELECT s.id, s.tenant_id -FROM sessions s JOIN environments e ON e.session_id = s.id -WHERE s.deleted_at IS NULL AND s.configuration->'environment'->>'type' = 'openai_hosted' +FROM sessions s JOIN environments e ON e.id = s.environment_id +WHERE s.deleted_at IS NULL AND e.configuration->>'type' = 'openai_hosted' AND e.status NOT IN ('failed', 'expired') AND s.id > $1::uuid AND (EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state NOT IN ('released', 'cleanup_pending')) @@ -217,8 +217,8 @@ const sessionBlocksAutoReset = `-- name: SessionBlocksAutoReset :one SELECT ( EXISTS (SELECT 1 FROM turns t WHERE t.session_id = $1 AND status IN ('in_progress', 'waiting')) OR EXISTS (SELECT 1 FROM subagent_turns t WHERE t.session_id = $1 AND status IN ('in_progress', 'waiting')) - OR EXISTS (SELECT 1 FROM environment_file_writes f JOIN environments e ON e.id = f.environment_id - WHERE e.session_id = $1 AND f.state = 'pending') + OR EXISTS (SELECT 1 FROM environment_file_writes f JOIN sessions s ON s.environment_id = f.environment_id + WHERE s.id = $1 AND f.state = 'pending') )::boolean ` diff --git a/services/core/internal/db/sqlc/sessions.sql.go b/services/core/internal/db/sqlc/sessions.sql.go index 7403eb0a6..4c62b4c1a 100644 --- a/services/core/internal/db/sqlc/sessions.sql.go +++ b/services/core/internal/db/sqlc/sessions.sql.go @@ -21,7 +21,7 @@ WHERE sessions.deleted_at IS NULL AND CASE WHEN sessions.creation_request_hash IS NULL THEN sessions.request_hash = EXCLUDED.request_hash ELSE sessions.creation_request_hash = EXCLUDED.creation_request_hash END -RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id +RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id, environment_id ` type CreateSessionParams struct { @@ -65,12 +65,13 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S &i.DeletedAt, &i.CreatorKind, &i.CreatorID, + &i.EnvironmentID, ) return i, err } const findSessionCreation = `-- name: FindSessionCreation :one -SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions +SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id, environment_id FROM sessions WHERE tenant_id = $1 AND idempotency_key = $2 ` @@ -96,12 +97,13 @@ func (q *Queries) FindSessionCreation(ctx context.Context, arg FindSessionCreati &i.DeletedAt, &i.CreatorKind, &i.CreatorID, + &i.EnvironmentID, ) return i, err } const getSession = `-- name: GetSession :one -SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL +SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id, environment_id FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL ` type GetSessionParams struct { @@ -126,6 +128,7 @@ func (q *Queries) GetSession(ctx context.Context, arg GetSessionParams) (Session &i.DeletedAt, &i.CreatorKind, &i.CreatorID, + &i.EnvironmentID, ) return i, err } @@ -163,7 +166,7 @@ func (q *Queries) GetSessionCursor(ctx context.Context, arg GetSessionCursorPara } const listSessions = `-- name: ListSessions :many -SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions +SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id, environment_id FROM sessions WHERE tenant_id = $1 AND deleted_at IS NULL AND ($2::text IS NULL OR configuration #>> '{agent,id}' = $2::text) AND ($3::jsonb IS NULL OR metadata @> $3::jsonb) @@ -219,6 +222,7 @@ func (q *Queries) ListSessions(ctx context.Context, arg ListSessionsParams) ([]S &i.DeletedAt, &i.CreatorKind, &i.CreatorID, + &i.EnvironmentID, ); err != nil { return nil, err } @@ -231,7 +235,7 @@ func (q *Queries) ListSessions(ctx context.Context, arg ListSessionsParams) ([]S } const lockVisibleSession = `-- name: LockVisibleSession :one -SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL FOR UPDATE +SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id, environment_id FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL FOR UPDATE ` type LockVisibleSessionParams struct { @@ -256,6 +260,7 @@ func (q *Queries) LockVisibleSession(ctx context.Context, arg LockVisibleSession &i.DeletedAt, &i.CreatorKind, &i.CreatorID, + &i.EnvironmentID, ) return i, err } @@ -270,7 +275,7 @@ func (q *Queries) MarkSessionDeleted(ctx context.Context, id pgtype.UUID) error } const updateSession = `-- name: UpdateSession :one -UPDATE sessions SET configuration = $3, metadata = $4 WHERE tenant_id = $1 AND id = $2 RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id +UPDATE sessions SET configuration = $3, metadata = $4 WHERE tenant_id = $1 AND id = $2 RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id, environment_id ` type UpdateSessionParams struct { @@ -302,6 +307,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S &i.DeletedAt, &i.CreatorKind, &i.CreatorID, + &i.EnvironmentID, ) return i, err } diff --git a/services/core/internal/db/sqlc/workspaces.sql.go b/services/core/internal/db/sqlc/workspaces.sql.go index 90048ef68..6b832ad31 100644 --- a/services/core/internal/db/sqlc/workspaces.sql.go +++ b/services/core/internal/db/sqlc/workspaces.sql.go @@ -60,12 +60,11 @@ func (q *Queries) GetActiveWorkspaceConfiguration(ctx context.Context) (Workspac } const getEnvironmentWorkspace = `-- name: GetEnvironmentWorkspace :one -SELECT w.object_id, w.environment_id, w.configuration_id, w.state, w.attachment, w.capacity_mib, c.id, c.adapter, c.parameters, c.active, c.credential, s.tenant_id +SELECT w.object_id, w.environment_id, w.configuration_id, w.state, w.attachment, w.capacity_mib, c.id, c.adapter, c.parameters, c.active, c.credential, e.tenant_id FROM environment_workspaces w JOIN workspace_fs_configurations c ON c.id = w.configuration_id JOIN environments e ON e.id = w.environment_id -JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND e.id = $2 +WHERE e.tenant_id = $1 AND e.id = $2 ` type GetEnvironmentWorkspaceParams struct { @@ -185,13 +184,12 @@ func (q *Queries) InsertWorkspaceConfiguration(ctx context.Context, arg InsertWo } const listWorkspaceDeletionCandidates = `-- name: ListWorkspaceDeletionCandidates :many -SELECT w.object_id, w.environment_id, w.configuration_id, w.state, w.attachment, w.capacity_mib, c.id, c.adapter, c.parameters, c.active, c.credential, s.tenant_id +SELECT w.object_id, w.environment_id, w.configuration_id, w.state, w.attachment, w.capacity_mib, c.id, c.adapter, c.parameters, c.active, c.credential, e.tenant_id FROM environment_workspaces w JOIN workspace_fs_configurations c ON c.id = w.configuration_id JOIN environments e ON e.id = w.environment_id -JOIN sessions s ON s.id = e.session_id WHERE w.object_id > $1 AND w.state <> 'deleted' - AND (s.deleted_at IS NOT NULL OR w.state = 'deleting') + AND ((e.session_owned AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL)) OR w.state = 'deleting') AND NOT EXISTS ( SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state <> 'released' ) @@ -257,18 +255,17 @@ func (q *Queries) LockWorkspaceConfigurations(ctx context.Context) error { return err } -const lockWorkspaceSession = `-- name: LockWorkspaceSession :one -SELECT s.id FROM sessions s JOIN environments e ON e.session_id = s.id -WHERE s.tenant_id = $1 AND e.id = $2 FOR UPDATE OF s +const lockWorkspaceEnvironment = `-- name: LockWorkspaceEnvironment :one +SELECT e.id FROM environments e WHERE e.tenant_id = $1 AND e.id = $2 FOR UPDATE ` -type LockWorkspaceSessionParams struct { +type LockWorkspaceEnvironmentParams struct { TenantID pgtype.UUID `json:"tenant_id"` ID pgtype.UUID `json:"id"` } -func (q *Queries) LockWorkspaceSession(ctx context.Context, arg LockWorkspaceSessionParams) (pgtype.UUID, error) { - row := q.db.QueryRow(ctx, lockWorkspaceSession, arg.TenantID, arg.ID) +func (q *Queries) LockWorkspaceEnvironment(ctx context.Context, arg LockWorkspaceEnvironmentParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, lockWorkspaceEnvironment, arg.TenantID, arg.ID) var id pgtype.UUID err := row.Scan(&id) return id, err @@ -351,8 +348,7 @@ SELECT COALESCE(SUM(w.capacity_mib), 0)::bigint AS committed_mib, COALESCE(bool_or(w.capacity_mib = 0), false)::boolean AS unbounded FROM environment_workspaces w JOIN environments e ON e.id = w.environment_id -JOIN sessions s ON s.id = e.session_id -WHERE s.tenant_id = $1 AND w.state <> 'deleted' +WHERE e.tenant_id = $1 AND w.state <> 'deleted' ` type TenantWorkspaceCapacityRow struct { @@ -380,13 +376,14 @@ func (q *Queries) WorkspaceComputeReleased(ctx context.Context, environmentID pg return released, err } -const workspaceSessionDeleted = `-- name: WorkspaceSessionDeleted :one -SELECT (deleted_at IS NOT NULL)::boolean AS deleted FROM sessions WHERE id = $1 +const workspaceEnvironmentReclaimed = `-- name: WorkspaceEnvironmentReclaimed :one +SELECT (e.session_owned AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL))::boolean AS reclaimed +FROM environments e WHERE e.id = $1 ` -func (q *Queries) WorkspaceSessionDeleted(ctx context.Context, id pgtype.UUID) (bool, error) { - row := q.db.QueryRow(ctx, workspaceSessionDeleted, id) - var deleted bool - err := row.Scan(&deleted) - return deleted, err +func (q *Queries) WorkspaceEnvironmentReclaimed(ctx context.Context, id pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, workspaceEnvironmentReclaimed, id) + var reclaimed bool + err := row.Scan(&reclaimed) + return reclaimed, err } diff --git a/services/core/migrations/000014_first_class_environments.sql b/services/core/migrations/000014_first_class_environments.sql new file mode 100644 index 000000000..e43c8bdb6 --- /dev/null +++ b/services/core/migrations/000014_first_class_environments.sql @@ -0,0 +1,91 @@ +-- +goose Up +-- Both views join environments.session_id; they are recreated at the end. +DROP VIEW runtime_device_authority; +DROP VIEW runtime_reset_retained_environments; + +-- The Environment owns its Project, frozen configuration and, for a standalone Environment, its creation identity. +ALTER TABLE environments + ADD COLUMN tenant_id uuid, + ADD COLUMN configuration jsonb, + ADD COLUMN session_owned boolean NOT NULL DEFAULT true, + ADD COLUMN idempotency_key text, + ADD COLUMN request_hash text, + ADD COLUMN creator_kind text, + ADD COLUMN creator_id text; +UPDATE environments e SET tenant_id = s.tenant_id, configuration = s.configuration->'environment' +FROM sessions s WHERE s.id = e.session_id; + +-- A Session names its Environment; at most one live Session is attached to one. +ALTER TABLE sessions ADD COLUMN environment_id uuid REFERENCES environments(id); +UPDATE sessions s SET environment_id = e.id FROM environments e WHERE e.session_id = s.id; +UPDATE sessions SET configuration = configuration - 'environment'; +CREATE UNIQUE INDEX sessions_attached_environment ON sessions (environment_id) WHERE deleted_at IS NULL; + +-- Preparation input is keyed by Environment. +ALTER TABLE environment_setups DROP CONSTRAINT environment_setups_session_id_fkey; +UPDATE environment_setups f SET session_id = e.id FROM environments e WHERE e.session_id = f.session_id; +ALTER TABLE environment_setups RENAME COLUMN session_id TO environment_id; +ALTER TABLE environment_setups ADD FOREIGN KEY (environment_id) REFERENCES environments(id); +ALTER TABLE initial_environment_files DROP CONSTRAINT initial_environment_files_session_id_fkey; +UPDATE initial_environment_files f SET session_id = e.id FROM environments e WHERE e.session_id = f.session_id; +ALTER TABLE initial_environment_files RENAME COLUMN session_id TO environment_id; +ALTER TABLE initial_environment_files RENAME CONSTRAINT initial_environment_files_session_id_path_key TO initial_environment_files_environment_id_path_key; +ALTER TABLE initial_environment_files RENAME CONSTRAINT initial_environment_files_session_id_position_key TO initial_environment_files_environment_id_position_key; +ALTER TABLE initial_environment_files ADD FOREIGN KEY (environment_id) REFERENCES environments(id); + +-- Waiting input belongs to the Session. +ALTER TABLE environment_input_reservations DROP CONSTRAINT environment_input_reservations_session_id_fkey, + ADD FOREIGN KEY (session_id) REFERENCES sessions(id); + +ALTER TABLE environments DROP COLUMN session_id; +ALTER TABLE environments ALTER COLUMN tenant_id SET NOT NULL, ALTER COLUMN configuration SET NOT NULL, ALTER COLUMN session_owned DROP DEFAULT, + ADD CONSTRAINT environments_configuration_check CHECK (jsonb_typeof(configuration) = 'object' AND configuration->>'type' IN ('openai_hosted', 'self_hosted')), + -- A standalone Environment is hosted and has a complete creation identity; a Session-owned one has none. + ADD CONSTRAINT environments_creation_identity CHECK ( + session_owned = (idempotency_key IS NULL) + AND (idempotency_key IS NULL) = (request_hash IS NULL) + AND (idempotency_key IS NULL) = (creator_kind IS NULL) + AND (idempotency_key IS NULL) = (creator_id IS NULL) + AND (session_owned OR configuration->>'type' = 'openai_hosted') + AND (request_hash IS NULL OR request_hash ~ '^[0-9a-f]{64}$') + AND (creator_kind IS NULL OR creator_kind IN ('user', 'service_account')) + AND (creator_id IS NULL OR creator_id <> '')), + ADD CONSTRAINT environments_tenant_id_idempotency_key_key UNIQUE (tenant_id, idempotency_key); +CREATE INDEX environments_standalone_list ON environments (tenant_id, created_at, id) WHERE NOT session_owned; + +-- A hosted device stays authorized while its Environment is not reclaimed. +CREATE VIEW runtime_device_authority AS +SELECT d.id, d.tenant_id, d.name, d.environment_id, COALESCE(c.token_sha256, d.credential_hash) AS credential_hash +FROM devices d +LEFT JOIN environments e ON e.id = d.environment_id AND e.tenant_id = d.tenant_id +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +LEFT JOIN environment_executor_credentials c ON c.key_id = d.executor_key_id +WHERE d.revoked_at IS NULL + AND (d.environment_id IS NULL OR (e.id IS NOT NULL AND (s.id IS NOT NULL OR NOT e.session_owned))) + AND (d.executor_key_id IS NULL OR (c.revoked_at IS NULL AND c.tenant_id = s.tenant_id + AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id + AND (c.environment_id IS NULL OR c.environment_id = e.id) + AND e.configuration->>'type' = 'self_hosted' AND e.status NOT IN ('failed', 'expired') + AND EXISTS (SELECT 1 FROM execution_project_scopes p WHERE p.tenant_id = c.tenant_id))); + +-- session_id is the attached live Session, null when none is attached. +CREATE VIEW runtime_reset_retained_environments AS +SELECT e.id AS environment_id, s.id AS session_id, e.tenant_id, a.deployment_generation +FROM environments e +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +JOIN environment_workspaces w ON w.environment_id = e.id AND w.state = 'ready' +JOIN LATERAL ( + SELECT allocation.state, allocation.provider_key, allocation.created_at, allocation.deployment_generation + FROM runtime_allocations allocation + WHERE allocation.environment_id = e.id + ORDER BY allocation.created_at DESC, allocation.id DESC + LIMIT 1) a ON true +CROSS JOIN runtime_deployment d +WHERE d.reset_clear IS NOT NULL AND d.reset_requested_at IS NOT NULL + AND (s.id IS NOT NULL OR NOT e.session_owned) + AND e.created_at <= d.reset_requested_at + AND e.status NOT IN ('failed', 'expired') AND e.initialization = 'complete' + AND e.configuration->>'type' = 'openai_hosted' + AND a.state = 'released' AND a.provider_key = d.installation_id + AND a.deployment_generation <= d.generation AND a.created_at <= d.reset_requested_at + AND NOT EXISTS (SELECT 1 FROM runtime_placements p WHERE p.environment_id = e.id AND p.released_at IS NULL); From f77b90a1b17a9ea26a48ab524724da77c73e8e0f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 10:26:15 +0000 Subject: [PATCH 2/9] Run Environment work under the Environment lock and read its own record --- .../internal/api/environment_creation_test.go | 2 +- .../internal/api/environment_files_test.go | 2 +- .../internal/api/environment_installation.go | 2 +- .../core/internal/api/environments_test.go | 2 +- .../internal/api/hosted_environment_test.go | 2 +- .../internal/api/session_environment_test.go | 4 +- .../core/internal/api/session_response.go | 2 +- .../core/internal/db/queries/environments.sql | 4 +- .../internal/db/queries/runtime_nodes.sql | 7 +- .../internal/db/sqlc/runtime_nodes.sql.go | 22 +---- .../core/internal/deployment/allocation.go | 13 ++- .../core/internal/deployment/allocations.go | 6 +- .../internal/deployment/allocations_test.go | 54 +++++------ .../core/internal/deployment/observation.go | 6 +- .../internal/deployment/observation_test.go | 18 ++-- services/core/internal/deployment/storage.go | 6 +- .../execution/directory_preparation_test.go | 2 +- .../environment_capabilities_test.go | 4 +- .../execution/environment_directory.go | 6 +- .../execution/environment_file_write.go | 4 +- .../execution/environment_placement.go | 2 +- .../execution/environment_placement_test.go | 6 +- .../core/internal/execution/network_egress.go | 28 +++--- .../internal/execution/network_egress_test.go | 8 +- .../internal/execution/runtime_compute.go | 2 +- .../execution/runtime_compute_wake.go | 6 +- .../execution/runtime_compute_wake_test.go | 8 +- .../internal/execution/runtime_connections.go | 4 +- .../execution/runtime_initialization.go | 34 ++----- .../internal/execution/runtime_lifecycle.go | 6 +- .../execution/runtime_pending_test.go | 2 +- .../execution/runtime_preparation_cache.go | 2 +- .../internal/execution/runtime_wake_hint.go | 2 +- services/core/internal/execution/worker.go | 14 +-- .../core/internal/execution/worker_device.go | 26 +++++ .../internal/execution/worker_schedule.go | 14 +++ .../postgres/deploymentpg/allocations.go | 94 ++++++------------- .../postgres/deploymentpg/replacement_test.go | 6 +- .../postgres/placementpg/placementpg.go | 6 +- .../postgres/sessionpg/artifacts.go | 2 +- .../persistence/postgres/sessionpg/binding.go | 16 ++-- .../postgres/sessionpg/creation.go | 39 +++++--- .../persistence/postgres/sessionpg/devices.go | 11 +-- .../postgres/sessionpg/environment.go | 8 +- .../postgres/sessionpg/environment_lock.go | 67 +++++++++++++ .../sessionpg/execution_environment.go | 69 ++++++++------ .../sessionpg/execution_file_writes.go | 73 ++++---------- .../sessionpg/executor_credentials.go | 13 +-- .../persistence/postgres/sessionpg/frozen.go | 30 +++--- .../persistence/postgres/sessionpg/rows.go | 8 +- .../postgres/sessionpg/session_reads.go | 27 +++++- .../postgres/sessionpg/session_writes.go | 11 ++- .../persistence/postgres/workspacepg/store.go | 12 +-- .../internal/runtimeenrollment/connection.go | 2 +- .../runtimeenrollment/connection_test.go | 4 +- .../core/internal/runtimehistory/service.go | 2 +- .../internal/runtimehistory/service_test.go | 14 +-- services/core/internal/sessions/creation.go | 45 +++++---- .../core/internal/sessions/creation_test.go | 4 +- .../core/internal/sessions/environment.go | 67 +++++++++++-- .../sessions/environment_termination.go | 3 + .../sessions/environment_termination_test.go | 14 ++- .../sessions/execution_environment.go | 55 ++++++----- .../sessions/execution_environment_test.go | 38 ++++---- .../sessions/execution_file_writes.go | 20 ++-- .../sessions/execution_file_writes_test.go | 10 +- .../sessions/executor_credentials_test.go | 2 +- .../core/internal/sessions/provisioning.go | 5 +- .../internal/sessions/transaction_test.go | 2 +- .../integration/admin_session_archive_test.go | 2 +- .../integration/environment_directory_test.go | 2 +- .../environment_initialization_test.go | 2 +- .../environment_retrieve_public_test.go | 2 +- .../tests/integration/environments_test.go | 2 +- .../integration/runtime_allocations_test.go | 2 +- .../runtime_compute_lifecycle_test.go | 6 +- .../runtime_file_admission_test.go | 2 +- .../integration/runtime_idle_clock_test.go | 20 ++-- .../runtime_node_lifecycle_test.go | 2 +- .../runtime_suspension_concurrency_test.go | 36 +++---- .../integration/runtime_suspension_test.go | 26 ++--- .../integration/session_diagnostics_test.go | 20 ++-- .../integration/session_reads_fixture_test.go | 2 +- 83 files changed, 681 insertions(+), 556 deletions(-) create mode 100644 services/core/internal/persistence/postgres/sessionpg/environment_lock.go diff --git a/services/core/internal/api/environment_creation_test.go b/services/core/internal/api/environment_creation_test.go index 13cc1db50..376cffbfd 100644 --- a/services/core/internal/api/environment_creation_test.go +++ b/services/core/internal/api/environment_creation_test.go @@ -39,7 +39,7 @@ func (f *environmentCreationFixture) CreateSession(_ context.Context, tenant str return sessions.Creation{}, err } f.session.Environment = &sessions.Environment{ - ID: uuid.NewString(), SessionID: f.session.ID, TenantID: tenant, Status: "pending", Configuration: snapshot.Environment, + ID: uuid.NewString(), AttachedSessionID: f.session.ID, TenantID: tenant, Status: "pending", Configuration: snapshot.Environment, } return sessions.Creation{Session: f.session, Created: true}, nil } diff --git a/services/core/internal/api/environment_files_test.go b/services/core/internal/api/environment_files_test.go index eab54eb9f..b660ba5ce 100644 --- a/services/core/internal/api/environment_files_test.go +++ b/services/core/internal/api/environment_files_test.go @@ -56,7 +56,7 @@ func (f *environmentFilesFixture) ReadEnvironmentDirectory(ctx context.Context, func newEnvironmentFilesFixture() *environmentFilesFixture { return &environmentFilesFixture{ - environment: sessions.Environment{ID: uuid.NewString(), TenantID: uuid.NewString(), SessionID: uuid.NewString(), Status: "connected", + environment: sessions.Environment{ID: uuid.NewString(), TenantID: uuid.NewString(), AttachedSessionID: uuid.NewString(), Status: "connected", Configuration: json.RawMessage(`{"type":"self_hosted","workspace_directory":"/workspace"}`)}, result: proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}}, } diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go index ffd42766c..dd1ef3ac6 100644 --- a/services/core/internal/api/environment_installation.go +++ b/services/core/internal/api/environment_installation.go @@ -111,7 +111,7 @@ func (h *Handler) prepareNativeInstallation(w http.ResponseWriter, r *http.Reque writeSessionsError(w, r, err) return } - session, err := h.SessionsReader.GetSession(r.Context(), claim.Principal.TenantID, environment.SessionID) + session, err := h.SessionsReader.GetSession(r.Context(), claim.Principal.TenantID, environment.AttachedSessionID) if err != nil { writeSessionsError(w, r, err) return diff --git a/services/core/internal/api/environments_test.go b/services/core/internal/api/environments_test.go index 22d1cc59e..cb8d8085e 100644 --- a/services/core/internal/api/environments_test.go +++ b/services/core/internal/api/environments_test.go @@ -31,7 +31,7 @@ func (f *environmentResourceFixture) GetEnvironment(_ context.Context, tenant, i func environmentResourceHandler(t *testing.T) (http.Handler, *environmentResourceFixture) { t.Helper() f := &environmentResourceFixture{environment: sessions.Environment{ - ID: uuid.NewString(), TenantID: uuid.NewString(), SessionID: uuid.NewString(), Status: "pending", + ID: uuid.NewString(), TenantID: uuid.NewString(), AttachedSessionID: uuid.NewString(), Status: "pending", Configuration: json.RawMessage(`{"type":"self_hosted","workspace_directory":"/private/workspace"}`), }} deps, fakes := testDependencies(t) diff --git a/services/core/internal/api/hosted_environment_test.go b/services/core/internal/api/hosted_environment_test.go index 64144816c..e43ffcc60 100644 --- a/services/core/internal/api/hosted_environment_test.go +++ b/services/core/internal/api/hosted_environment_test.go @@ -88,7 +88,7 @@ func TestHostedEnvironmentResponseHasPinnedShapeAndNoConnectionAction(t *testing } for _, mutate := range []func(*sessions.Environment){ func(e *sessions.Environment) { e.TenantID = "foreign" }, - func(e *sessions.Environment) { e.SessionID = "other" }, + func(e *sessions.Environment) { e.AttachedSessionID = "other" }, func(e *sessions.Environment) { e.Configuration = json.RawMessage(`{"type":"self_hosted","workspace_directory":"/workspace"}`) }, diff --git a/services/core/internal/api/session_environment_test.go b/services/core/internal/api/session_environment_test.go index a148bf4ac..093a80bac 100644 --- a/services/core/internal/api/session_environment_test.go +++ b/services/core/internal/api/session_environment_test.go @@ -17,7 +17,7 @@ func environmentSession() sessions.Session { ID: "session", TenantID: "tenant", CreatedAt: time.Unix(1700000000, 0), Metadata: map[string]string{}, Configuration: json.RawMessage(`{"agent":{"id":"agent_test","model":"model","tools":[]},"environment":{"type":"self_hosted"}}`), Environment: &sessions.Environment{ - ID: "environment", SessionID: "session", TenantID: "tenant", Status: "pending", + ID: "environment", AttachedSessionID: "session", TenantID: "tenant", Status: "pending", Configuration: json.RawMessage(`{"type":"self_hosted","workspace_directory":"/remote/workspace","capability_directories":["/remote/capabilities"],"id":"forged","remote_url":"https://secret@private","env":{"SECRET":"private"},"setup_commands":["private"]}`), }, } @@ -52,7 +52,7 @@ func TestSessionEnvironmentUsesSafeStoredAssociation(t *testing.T) { func(s *sessions.Session) { s.Environment = nil }, func(s *sessions.Session) { s.Environment.ID = "" }, func(s *sessions.Session) { s.Environment.TenantID = "foreign" }, - func(s *sessions.Session) { s.Environment.SessionID = "other" }, + func(s *sessions.Session) { s.Environment.AttachedSessionID = "other" }, func(s *sessions.Session) { s.Environment.Configuration = json.RawMessage(`{"type":"self_hosted"}`) }, func(s *sessions.Session) { s.Configuration = json.RawMessage(`{"agent":{"id":"agent","model":"model"},"environment":{"type":"openai_hosted"}}`) diff --git a/services/core/internal/api/session_response.go b/services/core/internal/api/session_response.go index c718aa0df..aa4f6824e 100644 --- a/services/core/internal/api/session_response.go +++ b/services/core/internal/api/session_response.go @@ -120,7 +120,7 @@ func sessionEnvironment(session sessions.Session, kind, executorURL string) (v1. return v1.SessionEnvironment{Type: "none"}, nil } environment := session.Environment - if (kind != "self_hosted" && kind != "openai_hosted") || environment == nil || environment.ID == "" || environment.SessionID != session.ID || environment.TenantID != session.TenantID { + if (kind != "self_hosted" && kind != "openai_hosted") || environment == nil || environment.ID == "" || environment.AttachedSessionID != session.ID || environment.TenantID != session.TenantID { return v1.SessionEnvironment{}, errors.New("unsupported stored session environment") } if kind == "openai_hosted" { diff --git a/services/core/internal/db/queries/environments.sql b/services/core/internal/db/queries/environments.sql index 8a43388dc..312ad9264 100644 --- a/services/core/internal/db/queries/environments.sql +++ b/services/core/internal/db/queries/environments.sql @@ -1,6 +1,6 @@ -- name: CreateEnvironment :exec -INSERT INTO environments (id, tenant_id, configuration, initialization) -VALUES (sqlc.arg(id), sqlc.arg(tenant_id), sqlc.arg(configuration), CASE WHEN sqlc.arg(prepared)::boolean THEN 'pending' ELSE 'complete' END); +INSERT INTO environments (id, tenant_id, configuration, session_owned, initialization) +VALUES (sqlc.arg(id), sqlc.arg(tenant_id), sqlc.arg(configuration), true, CASE WHEN sqlc.arg(prepared)::boolean THEN 'pending' ELSE 'complete' END); -- name: AttachSessionEnvironment :exec UPDATE sessions SET environment_id = $2 WHERE id = $1; diff --git a/services/core/internal/db/queries/runtime_nodes.sql b/services/core/internal/db/queries/runtime_nodes.sql index c658f1a84..909ad727e 100644 --- a/services/core/internal/db/queries/runtime_nodes.sql +++ b/services/core/internal/db/queries/runtime_nodes.sql @@ -56,9 +56,6 @@ AND installation_id=(SELECT installation_id FROM runtime_deployment WHERE single -- name: GetRuntimeEnrollment :one SELECT * FROM runtime_node_enrollments WHERE token_sha256=$1; --- name: CreateRuntimePlacement :exec -INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) VALUES($1,$2,(SELECT generation FROM runtime_deployment)); - -- name: GetRuntimePlacement :one SELECT p.*, n.name, (EXISTS(SELECT 1 FROM runtime_node_generation_status g WHERE g.node_id=n.id AND g.generation=p.deployment_generation AND g.connection_id=n.connection_id AND g.owner_epoch=d.owner_epoch AND g.state='ready') AND n.connection_id IS NOT NULL AND n.connected_epoch=d.owner_epoch AND n.last_seen_at>clock_timestamp()-interval '45 seconds' AND n.removed_at IS NULL)::boolean AS available, COALESCE(a.observation_error,'')::text AS observation_error, COALESCE(a.state,'reserved')::text AS state, COALESCE(a.compute_phase,'disabled')::text AS compute_phase @@ -78,9 +75,9 @@ SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.comp FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id LEFT JOIN sessions s ON s.environment_id=e.id AND s.deleted_at IS NULL WHERE a.node_id=$1 AND a.state<>'released' ORDER BY a.created_at,a.id LIMIT 1000; --- name: CreateSessionRuntimePlacement :exec +-- name: CreateRuntimePlacement :exec INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) -SELECT environment_id,$2,sqlc.arg(generation)::bigint FROM sessions WHERE id=$1; +VALUES (sqlc.arg(environment_id)::uuid,sqlc.arg(node_id)::uuid,sqlc.arg(generation)::bigint); -- name: SetRuntimeObservation :exec UPDATE runtime_allocations SET observation_error=$4 WHERE id=$1 AND compute_revision=$2 AND state=$3 AND state<>'released'; diff --git a/services/core/internal/db/sqlc/runtime_nodes.sql.go b/services/core/internal/db/sqlc/runtime_nodes.sql.go index 9bae328da..c4e582d12 100644 --- a/services/core/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/core/internal/db/sqlc/runtime_nodes.sql.go @@ -74,32 +74,18 @@ func (q *Queries) CreateRuntimeEnrollment(ctx context.Context, arg CreateRuntime } const createRuntimePlacement = `-- name: CreateRuntimePlacement :exec -INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) VALUES($1,$2,(SELECT generation FROM runtime_deployment)) +INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) +VALUES ($1::uuid,$2::uuid,$3::bigint) ` type CreateRuntimePlacementParams struct { EnvironmentID pgtype.UUID `json:"environment_id"` NodeID pgtype.UUID `json:"node_id"` + Generation int64 `json:"generation"` } func (q *Queries) CreateRuntimePlacement(ctx context.Context, arg CreateRuntimePlacementParams) error { - _, err := q.db.Exec(ctx, createRuntimePlacement, arg.EnvironmentID, arg.NodeID) - return err -} - -const createSessionRuntimePlacement = `-- name: CreateSessionRuntimePlacement :exec -INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) -SELECT environment_id,$2,$3::bigint FROM sessions WHERE id=$1 -` - -type CreateSessionRuntimePlacementParams struct { - ID pgtype.UUID `json:"id"` - NodeID pgtype.UUID `json:"node_id"` - Generation int64 `json:"generation"` -} - -func (q *Queries) CreateSessionRuntimePlacement(ctx context.Context, arg CreateSessionRuntimePlacementParams) error { - _, err := q.db.Exec(ctx, createSessionRuntimePlacement, arg.ID, arg.NodeID, arg.Generation) + _, err := q.db.Exec(ctx, createRuntimePlacement, arg.EnvironmentID, arg.NodeID, arg.Generation) return err } diff --git a/services/core/internal/deployment/allocation.go b/services/core/internal/deployment/allocation.go index 5abde50d6..d1d262651 100644 --- a/services/core/internal/deployment/allocation.go +++ b/services/core/internal/deployment/allocation.go @@ -20,7 +20,8 @@ type Allocation struct { ComputeWakeRequested bool ComputeRetainedUntil *time.Time ID, EnvironmentID string - SessionID, TenantID string + AttachedSessionID string + TenantID string DeviceID string // ProviderKey is the installation the allocation was provisioned for. ProviderKey string @@ -28,8 +29,10 @@ type Allocation struct { // CreateSettled records evidence that the original Create can no longer // change resources. CreateSettled bool - // SessionDeleted reports that the owning Session was publicly deleted. - SessionDeleted bool + // Reclaimed reports that the Environment is Session-owned and its Session + // was publicly deleted. AttachedSessionID is the live Session using the + // Environment, empty when none is attached. + Reclaimed bool // Replayed reports that ReserveAllocation returned an existing allocation, // which never authorizes another Create. Replayed bool @@ -70,8 +73,8 @@ type NewAllocation struct { Generation uint64 } -// SessionDevice is the Runtime device a Session is bound to. -type SessionDevice struct{ ID, EnvironmentID string } +// EnvironmentDevice is the Runtime device of an Environment. +type EnvironmentDevice struct{ ID, EnvironmentID string } // Activity separates real work from the connection keepalive. ObservedAt is // the database clock when it was read. diff --git a/services/core/internal/deployment/allocations.go b/services/core/internal/deployment/allocations.go index b1aa86d77..a7e6b3a76 100644 --- a/services/core/internal/deployment/allocations.go +++ b/services/core/internal/deployment/allocations.go @@ -206,7 +206,7 @@ func (e *ExecutionOperations) cleanup(ctx context.Context, owner Allocation, evi if err != nil { return err } - if revoked.SessionDeleted { + if revoked.Reclaimed { err = sessions.CancelWork(ctx, tx) } else { retain := false @@ -442,10 +442,10 @@ func checkAllocation(tx AllocationTx, owner Allocation, live bool) (Allocation, if !live { return current, nil } - if current.SessionDeleted { + if current.Reclaimed { return Allocation{}, sessions.ErrNotFound } - device, bound, err := tx.LoadSessionDevice() + device, bound, err := tx.LoadEnvironmentDevice() if err != nil { return Allocation{}, err } diff --git a/services/core/internal/deployment/allocations_test.go b/services/core/internal/deployment/allocations_test.go index 426d6f950..509b461d9 100644 --- a/services/core/internal/deployment/allocations_test.go +++ b/services/core/internal/deployment/allocations_test.go @@ -101,7 +101,7 @@ type fakeAllocationTx struct { loadSuspensionDemand func(Allocation) (SuspensionDemand, error) setCompute func(Allocation, ComputeChange) (Allocation, error) loadAllocation func() (Allocation, error) - loadSessionDevice func() (SessionDevice, bool, error) + loadEnvironmentDevice func() (EnvironmentDevice, bool, error) settleCreation func(Allocation) (Allocation, error) release func(Allocation) (Allocation, error) } @@ -113,11 +113,11 @@ func (f *fakeAllocationTx) LoadAllocation() (Allocation, error) { return f.loadAllocation() } -func (f *fakeAllocationTx) LoadSessionDevice() (SessionDevice, bool, error) { - if f.loadSessionDevice == nil { - unexpected(f.t, "LoadSessionDevice") +func (f *fakeAllocationTx) LoadEnvironmentDevice() (EnvironmentDevice, bool, error) { + if f.loadEnvironmentDevice == nil { + unexpected(f.t, "LoadEnvironmentDevice") } - return f.loadSessionDevice() + return f.loadEnvironmentDevice() } func (f *fakeAllocationTx) LoadActivity(current Allocation) (Activity, error) { @@ -437,7 +437,7 @@ func TestAllocationChangesCheckTheOwner(t *testing.T) { t.Fatal("a replaced allocation settled", err) } deleted := owner - deleted.SessionDeleted = true + deleted.Reclaimed = true if _, err := allocationOperations(t, nil, sessions.LockedSession{}, &fakeAllocationTx{t: t, loadAllocation: stored(deleted)}).CheckRunning(t.Context(), owner); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("a deleted Session kept its allocation", err) } @@ -448,21 +448,21 @@ func TestAllocationChangesCheckTheOwner(t *testing.T) { if result, err := allocationOperations(t, nil, sessions.LockedSession{}, settled).SettleCreation(t.Context(), owner); err != nil || !result.CreateSettled { t.Fatal("a deleted Session's creation did not settle", result, err) } - unbound := &fakeAllocationTx{t: t, loadAllocation: stored(owner), loadSessionDevice: func() (SessionDevice, bool, error) { - return SessionDevice{ID: uuid.NewString(), EnvironmentID: owner.EnvironmentID}, true, nil + unbound := &fakeAllocationTx{t: t, loadAllocation: stored(owner), loadEnvironmentDevice: func() (EnvironmentDevice, bool, error) { + return EnvironmentDevice{ID: uuid.NewString(), EnvironmentID: owner.EnvironmentID}, true, nil }} if _, err := allocationOperations(t, nil, sessions.LockedSession{}, unbound).CheckRunning(t.Context(), owner); !errors.Is(err, ErrAllocationConflict) { t.Fatal("a Session bound to another device kept the allocation", err) } - bound := func() (SessionDevice, bool, error) { - return SessionDevice{ID: owner.DeviceID, EnvironmentID: owner.EnvironmentID}, true, nil + bound := func() (EnvironmentDevice, bool, error) { + return EnvironmentDevice{ID: owner.DeviceID, EnvironmentID: owner.EnvironmentID}, true, nil } cleanup := owner cleanup.State = "cleanup_pending" - if _, err := allocationOperations(t, nil, sessions.LockedSession{}, &fakeAllocationTx{t: t, loadAllocation: stored(cleanup), loadSessionDevice: bound}).CheckRunning(t.Context(), owner); !errors.Is(err, ErrAllocationConflict) { + if _, err := allocationOperations(t, nil, sessions.LockedSession{}, &fakeAllocationTx{t: t, loadAllocation: stored(cleanup), loadEnvironmentDevice: bound}).CheckRunning(t.Context(), owner); !errors.Is(err, ErrAllocationConflict) { t.Fatal("cleanup kept the allocation running", err) } - if current, err := allocationOperations(t, nil, sessions.LockedSession{}, &fakeAllocationTx{t: t, loadAllocation: stored(owner), loadSessionDevice: bound}).CheckRunning(t.Context(), owner); err != nil || current.ID != owner.ID { + if current, err := allocationOperations(t, nil, sessions.LockedSession{}, &fakeAllocationTx{t: t, loadAllocation: stored(owner), loadEnvironmentDevice: bound}).CheckRunning(t.Context(), owner); err != nil || current.ID != owner.ID { t.Fatal("the owner's running allocation", current, err) } } @@ -517,7 +517,7 @@ func TestCleanupRevokesSettlesTheSessionThenReleases(t *testing.T) { t.Run(test.name, func(t *testing.T) { var calls []string stored := owner - stored.SessionDeleted, stored.Expired = test.deleted, !test.deleted + stored.Reclaimed, stored.Expired = test.deleted, !test.deleted change := func(name string, apply func(*Allocation)) func(Allocation) (Allocation, error) { return func(current Allocation) (Allocation, error) { calls = append(calls, name) @@ -575,7 +575,7 @@ func TestExpiredQualifiedFilesystemCleanupPreservesSessionWork(t *testing.T) { for _, deleted := range []bool{false, true} { t.Run(fmt.Sprint("deleted=", deleted), func(t *testing.T) { current := owner - current.SessionDeleted = deleted + current.Reclaimed = deleted checked, cancelled, revoked := false, false, false tx := &fakeCleanupTx{fakeAllocationTx: &fakeAllocationTx{t: t, loadAllocation: func() (Allocation, error) { return current, nil }}, canRetainEnvironment: func(Allocation) (bool, error) { @@ -613,12 +613,12 @@ func TestReleaseLostComputeRetainsOrChangesNothing(t *testing.T) { want []string err error }{ - {"retained", func(*Allocation) {}, true, []string{"LoadAllocation", "LoadSessionDevice", "RevokeDevice", "LoadAllocation", "CanRetainEnvironment", "RequestCleanup", "SettleCreation", "Release"}, nil}, - {"not retainable", func(*Allocation) {}, false, []string{"LoadAllocation", "LoadSessionDevice", "RevokeDevice", "LoadAllocation", "CanRetainEnvironment"}, ErrAllocationConflict}, - {"newer revision", func(a *Allocation) { a.ComputeRevision++ }, true, []string{"LoadAllocation", "LoadSessionDevice"}, ErrAllocationConflict}, - {"quiescing", func(a *Allocation) { a.ComputePhase = "quiescing" }, true, []string{"LoadAllocation", "LoadSessionDevice"}, ErrAllocationConflict}, - {"unknown creation", func(a *Allocation) { a.CreateSettled = false }, true, []string{"LoadAllocation", "LoadSessionDevice"}, ErrAllocationConflict}, - {"released", func(a *Allocation) { a.State = "released" }, true, []string{"LoadAllocation", "LoadSessionDevice"}, ErrAllocationConflict}, + {"retained", func(*Allocation) {}, true, []string{"LoadAllocation", "LoadEnvironmentDevice", "RevokeDevice", "LoadAllocation", "CanRetainEnvironment", "RequestCleanup", "SettleCreation", "Release"}, nil}, + {"not retainable", func(*Allocation) {}, false, []string{"LoadAllocation", "LoadEnvironmentDevice", "RevokeDevice", "LoadAllocation", "CanRetainEnvironment"}, ErrAllocationConflict}, + {"newer revision", func(a *Allocation) { a.ComputeRevision++ }, true, []string{"LoadAllocation", "LoadEnvironmentDevice"}, ErrAllocationConflict}, + {"quiescing", func(a *Allocation) { a.ComputePhase = "quiescing" }, true, []string{"LoadAllocation", "LoadEnvironmentDevice"}, ErrAllocationConflict}, + {"unknown creation", func(a *Allocation) { a.CreateSettled = false }, true, []string{"LoadAllocation", "LoadEnvironmentDevice"}, ErrAllocationConflict}, + {"released", func(a *Allocation) { a.State = "released" }, true, []string{"LoadAllocation", "LoadEnvironmentDevice"}, ErrAllocationConflict}, } { t.Run(test.name, func(t *testing.T) { var calls []string @@ -634,9 +634,9 @@ func TestReleaseLostComputeRetainsOrChangesNothing(t *testing.T) { tx := &fakeCleanupTx{ fakeAllocationTx: &fakeAllocationTx{t: t, loadAllocation: func() (Allocation, error) { calls = append(calls, "LoadAllocation"); return stored, nil }, - loadSessionDevice: func() (SessionDevice, bool, error) { - calls = append(calls, "LoadSessionDevice") - return SessionDevice{ID: owner.DeviceID, EnvironmentID: owner.EnvironmentID}, true, nil + loadEnvironmentDevice: func() (EnvironmentDevice, bool, error) { + calls = append(calls, "LoadEnvironmentDevice") + return EnvironmentDevice{ID: owner.DeviceID, EnvironmentID: owner.EnvironmentID}, true, nil }, settleCreation: change("SettleCreation", func(*Allocation) {}), release: change("Release", func(a *Allocation) { a.State = "released" }), @@ -694,8 +694,8 @@ func TestSetComputeRechecksIdlePressureAndActivity(t *testing.T) { pressureChecks, writes := 0, 0 tx := &fakeAllocationTx{t: t, idleSeconds: 300, loadAllocation: func() (Allocation, error) { return current, nil }, - loadSessionDevice: func() (SessionDevice, bool, error) { - return SessionDevice{ID: owner.DeviceID, EnvironmentID: owner.EnvironmentID}, true, nil + loadEnvironmentDevice: func() (EnvironmentDevice, bool, error) { + return EnvironmentDevice{ID: owner.DeviceID, EnvironmentID: owner.EnvironmentID}, true, nil }, loadActivity: func(Allocation) (Activity, error) { return Activity{ObservedAt: now, LastActivity: now.Add(-test.idle), Busy: test.busy, WakeRequested: test.wake}, nil @@ -743,8 +743,8 @@ func (f *fakeAllocationTx) CanRetainEnvironment(current Allocation) (bool, error func TestBeginRestoreDirectKeepsAllocationAndRetention(t *testing.T) { until := time.Now().Add(time.Hour) owner := Allocation{ID: uuid.NewString(), DeviceID: uuid.NewString(), TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), ProviderKey: uuid.NewString(), State: "running", ComputePhase: "suspended", ComputeRetainedUntil: &until} - tx := &fakeAllocationTx{t: t, loadAllocation: func() (Allocation, error) { return owner, nil }, loadSessionDevice: func() (SessionDevice, bool, error) { - return SessionDevice{ID: owner.DeviceID, EnvironmentID: owner.EnvironmentID}, true, nil + tx := &fakeAllocationTx{t: t, loadAllocation: func() (Allocation, error) { return owner, nil }, loadEnvironmentDevice: func() (EnvironmentDevice, bool, error) { + return EnvironmentDevice{ID: owner.DeviceID, EnvironmentID: owner.EnvironmentID}, true, nil }, loadActivity: func(Allocation) (Activity, error) { return Activity{WakeRequested: true}, nil }, setCompute: func(current Allocation, change ComputeChange) (Allocation, error) { if change.Phase != "restoring" || change.RetainedUntil != owner.ComputeRetainedUntil { t.Fatal("direct restore lost intent", change) diff --git a/services/core/internal/deployment/observation.go b/services/core/internal/deployment/observation.go index b4871624e..957acb6e3 100644 --- a/services/core/internal/deployment/observation.go +++ b/services/core/internal/deployment/observation.go @@ -61,7 +61,7 @@ func (r *ObservationResolver) Resolve(ctx context.Context, tenantID, sessionID s if session.Environment == nil { return runtimeobs.Target{}, errors.New("self-hosted Session is missing its Environment") } - if session.Environment.TenantID != session.TenantID || session.Environment.SessionID != session.ID { + if session.Environment.TenantID != session.TenantID || session.Environment.AttachedSessionID != session.ID { return runtimeobs.Target{}, errors.New("self-hosted Environment does not match resolved ownership") } target.EnvironmentID = session.Environment.ID @@ -70,7 +70,7 @@ func (r *ObservationResolver) Resolve(ctx context.Context, tenantID, sessionID s if session.Environment == nil { return runtimeobs.Target{}, errors.New("managed Session is missing its Environment") } - if session.Environment.TenantID != session.TenantID || session.Environment.SessionID != session.ID { + if session.Environment.TenantID != session.TenantID || session.Environment.AttachedSessionID != session.ID { return runtimeobs.Target{}, errors.New("managed Environment does not match resolved ownership") } target.EnvironmentID = session.Environment.ID @@ -81,7 +81,7 @@ func (r *ObservationResolver) Resolve(ctx context.Context, tenantID, sessionID s if err != nil { return runtimeobs.Target{}, fmt.Errorf("resolve Runtime allocation: %w", err) } - if allocation.TenantID != tenantID || allocation.SessionID != session.ID || allocation.EnvironmentID != target.EnvironmentID { + if allocation.TenantID != tenantID || allocation.AttachedSessionID != session.ID || allocation.EnvironmentID != target.EnvironmentID { return runtimeobs.Target{}, errors.New("Runtime allocation does not match resolved ownership") } target.Instance = runtimeobs.Instance{ diff --git a/services/core/internal/deployment/observation_test.go b/services/core/internal/deployment/observation_test.go index b84bc1c57..a1530e8d1 100644 --- a/services/core/internal/deployment/observation_test.go +++ b/services/core/internal/deployment/observation_test.go @@ -52,10 +52,10 @@ func TestObservationResolverListsOnlyProviderNeutralSessionIdentity(t *testing.T func TestObservationResolverBindsManagedSessionEnvironmentAndAllocation(t *testing.T) { r, err := newTestResolver(t, resolverStore{ - session: sessions.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), Environment: &sessions.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, + session: sessions.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), Environment: &sessions.Environment{ID: "environment", TenantID: "tenant", AttachedSessionID: "session"}}, measured: []byte(`{"input_tokens":120,"input_tokens_details":{"cached_tokens":20},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":10},"total_tokens":150}`), allocation: Allocation{ - ID: "allocation", TenantID: "tenant", SessionID: "session", EnvironmentID: "environment", + ID: "allocation", TenantID: "tenant", AttachedSessionID: "session", EnvironmentID: "environment", ProviderKey: "provider", DeviceID: "device", ComputePhase: "running", ComputeState: []byte(`{"current":{"name":"sandbox"}}`), }, }) @@ -143,7 +143,7 @@ func TestObservationResolverKeepsUnsupportedModesDistinct(t *testing.T) { environment *sessions.Environment }{ {mode: "none"}, - {mode: "self_hosted", environment: &sessions.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, + {mode: "self_hosted", environment: &sessions.Environment{ID: "environment", TenantID: "tenant", AttachedSessionID: "session"}}, } { r, err := newTestResolver(t, resolverStore{session: sessions.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"` + tc.mode + `"}}`), Environment: tc.environment}}) if err != nil { @@ -158,7 +158,7 @@ func TestObservationResolverKeepsUnsupportedModesDistinct(t *testing.T) { func TestObservationResolverReportsManagedAllocationAsUnavailable(t *testing.T) { r, err := newTestResolver(t, resolverStore{ - session: sessions.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), Environment: &sessions.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}}, + session: sessions.Session{ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), Environment: &sessions.Environment{ID: "environment", TenantID: "tenant", AttachedSessionID: "session"}}, allocationErr: ErrNotFound, }) if err != nil { @@ -173,8 +173,8 @@ func TestObservationResolverReportsManagedAllocationAsUnavailable(t *testing.T) func TestObservationResolverRejectsMismatchedEnvironmentOwnership(t *testing.T) { for _, mode := range []string{"self_hosted", "openai_hosted"} { for _, environment := range []sessions.Environment{ - {ID: "environment", TenantID: "other", SessionID: "session"}, - {ID: "environment", TenantID: "tenant", SessionID: "other"}, + {ID: "environment", TenantID: "other", AttachedSessionID: "session"}, + {ID: "environment", TenantID: "tenant", AttachedSessionID: "other"}, } { resolver, err := newTestResolver(t, resolverStore{session: sessions.Session{ ID: "session", TenantID: "tenant", @@ -192,12 +192,12 @@ func TestObservationResolverRejectsMismatchedEnvironmentOwnership(t *testing.T) func TestObservationResolverRejectsMismatchedAllocationOwnership(t *testing.T) { base := Allocation{ - ID: "allocation", TenantID: "tenant", SessionID: "session", EnvironmentID: "environment", + ID: "allocation", TenantID: "tenant", AttachedSessionID: "session", EnvironmentID: "environment", ProviderKey: "provider", DeviceID: "device", } for _, mutate := range []func(*Allocation){ func(value *Allocation) { value.TenantID = "other" }, - func(value *Allocation) { value.SessionID = "other" }, + func(value *Allocation) { value.AttachedSessionID = "other" }, func(value *Allocation) { value.EnvironmentID = "other" }, } { allocation := base @@ -205,7 +205,7 @@ func TestObservationResolverRejectsMismatchedAllocationOwnership(t *testing.T) { resolver, err := newTestResolver(t, resolverStore{ session: sessions.Session{ ID: "session", TenantID: "tenant", Configuration: []byte(`{"environment":{"type":"openai_hosted"}}`), - Environment: &sessions.Environment{ID: "environment", TenantID: "tenant", SessionID: "session"}, + Environment: &sessions.Environment{ID: "environment", TenantID: "tenant", AttachedSessionID: "session"}, }, allocation: allocation, }) diff --git a/services/core/internal/deployment/storage.go b/services/core/internal/deployment/storage.go index 851461108..fd939a803 100644 --- a/services/core/internal/deployment/storage.go +++ b/services/core/internal/deployment/storage.go @@ -116,9 +116,9 @@ type AllocationTx interface { // LoadAllocation returns the Environment's allocation. LoadAllocation() (Allocation, error) - // LoadSessionDevice returns the device the Session is bound to and - // whether it is bound to one. - LoadSessionDevice() (SessionDevice, bool, error) + // LoadEnvironmentDevice returns the Environment's device and whether it + // has one. + LoadEnvironmentDevice() (EnvironmentDevice, bool, error) // LoadActivity returns the allocation's activity. LoadActivity(current Allocation) (Activity, error) // LoadSuspensionDemand locks the deployment and reads demand and capacity. diff --git a/services/core/internal/execution/directory_preparation_test.go b/services/core/internal/execution/directory_preparation_test.go index e6388203b..3ba376244 100644 --- a/services/core/internal/execution/directory_preparation_test.go +++ b/services/core/internal/execution/directory_preparation_test.go @@ -11,7 +11,7 @@ import ( func TestDirectoryPreparationRejectsForeignBindingBeforeTransport(t *testing.T) { result := (&Dispatcher{}).readPreparedDirectory(t.Context(), nil, sessions.Session{ID: "session", TenantID: "tenant"}, - sessions.Environment{ID: "environment", SessionID: "session", TenantID: "tenant", Configuration: []byte(`{"type":"self_hosted","workspace_directory":"/workspace"}`)}, + sessions.Environment{ID: "environment", AttachedSessionID: "session", TenantID: "tenant", Configuration: []byte(`{"type":"self_hosted","workspace_directory":"/workspace"}`)}, sessions.ExecutionDevice{EnvironmentID: "other"}, proto.WorkspaceReadPayload{}) if !errors.Is(result.err, ErrExecutionUnavailable) { t.Fatal("foreign binding reached transport", result.err) diff --git a/services/core/internal/execution/environment_capabilities_test.go b/services/core/internal/execution/environment_capabilities_test.go index 3529872e5..6c2ae2d95 100644 --- a/services/core/internal/execution/environment_capabilities_test.go +++ b/services/core/internal/execution/environment_capabilities_test.go @@ -11,7 +11,7 @@ import ( func TestSelfHostedCapabilitySourcesAreFrozenAndStrict(t *testing.T) { session := sessions.Session{ID: "session", TenantID: "tenant"} - environment := sessions.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, + environment := sessions.Environment{ID: "environment", AttachedSessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"self_hosted","workspace_directory":"/home/user/project","capability_directories":["/home/user/skills","/opt/plugins"]}`)} var request proto.PromptRequestPayload if err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, sessions.ExecutionDevice{EnvironmentID: environment.ID}, &request); err != nil { @@ -37,7 +37,7 @@ func TestSelfHostedPreparedPathsArePlatformNeutral(t *testing.T) { for _, directory := range []string{`C:\work`, `D:/skills`, `\\server\share\project`, `/Users/user/work`} { raw, _ := json.Marshal(map[string]any{"type": "self_hosted", "workspace_directory": directory, "capability_directories": []string{directory}}) session := sessions.Session{ID: "session", TenantID: "tenant"} - environment := sessions.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: raw} + environment := sessions.Environment{ID: "environment", AttachedSessionID: session.ID, TenantID: session.TenantID, Configuration: raw} var request proto.PromptRequestPayload err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, sessions.ExecutionDevice{EnvironmentID: environment.ID}, &request) if err != nil || request.LocalEnvironment.WorkspaceDirectory != directory || request.LocalEnvironment.CapabilitySources.Directories[0] != directory { diff --git a/services/core/internal/execution/environment_directory.go b/services/core/internal/execution/environment_directory.go index 5be347d7b..1e14ea052 100644 --- a/services/core/internal/execution/environment_directory.go +++ b/services/core/internal/execution/environment_directory.go @@ -31,7 +31,7 @@ func (w *Worker) ReadEnvironmentDirectory(ctx context.Context, environment sessi if err != nil { return proto.WorkspaceDirectoryResult{}, err } - if current.SessionID != environment.SessionID { + if current.AttachedSessionID != environment.AttachedSessionID { return proto.WorkspaceDirectoryResult{}, sessions.ErrNotFound } if err := w.waitRuntimeAwake(ctx, current); err != nil { @@ -74,7 +74,7 @@ func (w *Worker) runDirectoryRead(owner context.Context, request directoryReadRe result.err = err return } - if environment.SessionID != request.environment.SessionID { + if environment.AttachedSessionID != request.environment.AttachedSessionID { result.err = sessions.ErrNotFound return } @@ -82,7 +82,7 @@ func (w *Worker) runDirectoryRead(owner context.Context, request directoryReadRe if err != nil { return } - session, err := w.dispatcher.SessionsReader.GetSession(check, environment.TenantID, environment.SessionID) + session, err := w.dispatcher.SessionsReader.GetSession(check, environment.TenantID, environment.AttachedSessionID) if err != nil { result.err = err return diff --git a/services/core/internal/execution/environment_file_write.go b/services/core/internal/execution/environment_file_write.go index 9ce518987..67933941e 100644 --- a/services/core/internal/execution/environment_file_write.go +++ b/services/core/internal/execution/environment_file_write.go @@ -82,14 +82,14 @@ func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) f if err != nil { return fileWriteResult{err: err} } - if environment.SessionID != request.environment.SessionID { + if environment.AttachedSessionID != request.environment.AttachedSessionID { return fileWriteResult{err: sessions.ErrNotFound} } placement, err := parseEnvironmentPlacement(environment.Configuration) if err != nil || (placement.Type != "openai_hosted" && placement.Type != "self_hosted") { return unavailable } - session, err := w.dispatcher.SessionsReader.GetSession(ctx, environment.TenantID, environment.SessionID) + session, err := w.dispatcher.SessionsReader.GetSession(ctx, environment.TenantID, environment.AttachedSessionID) if err != nil { return fileWriteResult{err: err} } diff --git a/services/core/internal/execution/environment_placement.go b/services/core/internal/execution/environment_placement.go index 82a4c8d95..36f0a828d 100644 --- a/services/core/internal/execution/environment_placement.go +++ b/services/core/internal/execution/environment_placement.go @@ -67,7 +67,7 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem } func environmentDeviceMatches(session sessions.Session, environment sessions.Environment, bound sessions.ExecutionDevice) bool { - if environment.SessionID != session.ID || environment.TenantID != session.TenantID { + if environment.AttachedSessionID != session.ID || environment.TenantID != session.TenantID { return false } return bound.EnvironmentID == environment.ID diff --git a/services/core/internal/execution/environment_placement_test.go b/services/core/internal/execution/environment_placement_test.go index 2c7504418..1ce04013c 100644 --- a/services/core/internal/execution/environment_placement_test.go +++ b/services/core/internal/execution/environment_placement_test.go @@ -11,7 +11,7 @@ import ( func TestSkillReferenceIdentityStopsAtCoreBoundary(t *testing.T) { session := sessions.Session{ID: "session", TenantID: "tenant"} - environment := sessions.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, + environment := sessions.Environment{ID: "environment", AttachedSessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","initialization":true,"skills":[{"type":"skill_reference","skill_id":"skill-private","version":"1","name":"proof","description":"A proof."}]}`)} var request proto.PromptRequestPayload err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, sessions.ExecutionDevice{EnvironmentID: environment.ID}, &request) @@ -41,7 +41,7 @@ func TestLocalEnvironmentRequiresQualifiedProfileAndExactAuthority(t *testing.T) } } session := sessions.Session{ID: "session", TenantID: "tenant"} - environment := sessions.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","network":{"access":"disabled"}}`)} + environment := sessions.Environment{ID: "environment", AttachedSessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","network":{"access":"disabled"}}`)} d := &Dispatcher{} for _, scope := range []string{"", "other", environment.ID} { var req proto.PromptRequestPayload @@ -58,7 +58,7 @@ func TestLocalEnvironmentRequiresQualifiedProfileAndExactAuthority(t *testing.T) func TestToolEnvironmentRemainsInExecutionBinding(t *testing.T) { session := sessions.Session{ID: "session", TenantID: "tenant"} - environment := sessions.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","initialization":true,"packages":{"npm":["is-number"]}}`)} + environment := sessions.Environment{ID: "environment", AttachedSessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","initialization":true,"packages":{"npm":["is-number"]}}`)} var req proto.PromptRequestPayload err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, sessions.ExecutionDevice{EnvironmentID: environment.ID}, &req) if err != nil || req.LocalEnvironment == nil || !req.LocalEnvironment.ToolEnvironment { diff --git a/services/core/internal/execution/network_egress.go b/services/core/internal/execution/network_egress.go index 8899ef704..6e6dc8b47 100644 --- a/services/core/internal/execution/network_egress.go +++ b/services/core/internal/execution/network_egress.go @@ -18,28 +18,30 @@ import ( ) // allocationEgress derives what the allocation may reach from its immutable -// inputs only: the deployment generation's network cap, the Session's frozen -// Environment network policy and the model provider the Session froze at -// creation. Create and Resume send the same value. A policy that the cap no +// inputs only: the deployment generation's network cap, the Environment's +// frozen network policy and, for a Session-owned Environment, the model +// provider its Session froze at creation. Create and Resume send the same value. A policy that the cap no // longer covers fails the attempt with the admission error. func (r *runtimeLifecycle) allocationEgress(ctx context.Context, owner deployment.Allocation, network sandbox.Network) (sandbox.Egress, error) { - session, err := r.sessions.GetSession(ctx, owner.TenantID, owner.SessionID) + environment, err := r.sessions.GetEnvironment(ctx, owner.TenantID, owner.EnvironmentID) if err != nil { return sandbox.Egress{}, err } - var snapshot Snapshot - if json.Unmarshal(session.Configuration, &snapshot) != nil { + var configuration v1.Environment + if json.Unmarshal(environment.Configuration, &configuration) != nil { return sandbox.Egress{}, sandbox.ErrInvalid } - provider, err := r.sessions.SessionModelExecution(ctx, owner.TenantID, owner.SessionID) baseURL := "" - switch { - case err == nil: - baseURL = provider.BaseURL - case !errors.Is(err, sessions.ErrNotFound): - return sandbox.Egress{}, err + if environment.SessionOwned { + provider, err := r.sessions.SessionModelExecution(ctx, owner.TenantID, environment.AttachedSessionID) + switch { + case err == nil: + baseURL = provider.BaseURL + case !errors.Is(err, sessions.ErrNotFound): + return sandbox.Egress{}, err + } } - return egressFor(network, snapshot.Environment.Network, baseURL) + return egressFor(network, configuration.Network, baseURL) } // admitNetwork checks an Environment network policy at Session creation: diff --git a/services/core/internal/execution/network_egress_test.go b/services/core/internal/execution/network_egress_test.go index 7b319b44b..8a0c8cd7f 100644 --- a/services/core/internal/execution/network_egress_test.go +++ b/services/core/internal/execution/network_egress_test.go @@ -38,8 +38,8 @@ func TestEgressLimitsTheModelProviderToItsPort(t *testing.T) { } } -// A Session whose record is missing fails the attempt instead of falling back to the full cap. -func TestAllocationEgressFailsClosedWithoutSession(t *testing.T) { +// An Environment whose record is missing fails the attempt instead of falling back to the full cap. +func TestAllocationEgressFailsClosedWithoutEnvironment(t *testing.T) { r := &runtimeLifecycle{sessions: missingSession{}} if _, err := r.allocationEgress(context.Background(), deployment.Allocation{}, sandbox.DefaultNetwork()); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("err %v", err) @@ -48,8 +48,8 @@ func TestAllocationEgressFailsClosedWithoutSession(t *testing.T) { type missingSession struct{ sessions.Reader } -func (missingSession) GetSession(context.Context, string, string) (sessions.Session, error) { - return sessions.Session{}, sessions.ErrNotFound +func (missingSession) GetEnvironment(context.Context, string, string) (sessions.Environment, error) { + return sessions.Environment{}, sessions.ErrNotFound } func TestEnvironmentPolicyNarrowsTheCap(t *testing.T) { diff --git a/services/core/internal/execution/runtime_compute.go b/services/core/internal/execution/runtime_compute.go index a76034294..2ce05c5f1 100644 --- a/services/core/internal/execution/runtime_compute.go +++ b/services/core/internal/execution/runtime_compute.go @@ -88,7 +88,7 @@ func (r *runtimeLifecycle) observeCompute(ctx context.Context, owner deployment. if json.Unmarshal(owner.ComputeState, &state) != nil || state.Current.ID == "" { return sandbox.ErrOwnership } - if owner.SessionDeleted || owner.Expired || owner.State == "cleanup_pending" { + if owner.Reclaimed || owner.Expired || owner.State == "cleanup_pending" { return r.cleanupCompute(ctx, p, owner, state) } if err := r.lease.CheckOwnership(ctx); err != nil { diff --git a/services/core/internal/execution/runtime_compute_wake.go b/services/core/internal/execution/runtime_compute_wake.go index 125d5ede8..7d9087d14 100644 --- a/services/core/internal/execution/runtime_compute_wake.go +++ b/services/core/internal/execution/runtime_compute_wake.go @@ -159,7 +159,7 @@ func (w *Worker) waitRuntimeAwake(ctx context.Context, environment sessions.Envi if err != nil { return err } - if owner.SessionDeleted { + if owner.Reclaimed { return ErrExecutionUnavailable } if owner.State == "released" || owner.State == "cleanup_pending" || owner.Expired { @@ -199,7 +199,7 @@ func (w *Worker) waitRuntimeAwake(ctx context.Context, environment sessions.Envi // capability declaration. File work must wait for both receipts. peer, peerErr := w.dispatcher.authorizedPeer(ctx, owner.DeviceID) if peerErr == nil { - session, err := w.dispatcher.SessionsReader.GetSession(ctx, environment.TenantID, environment.SessionID) + session, err := w.dispatcher.SessionsReader.GetSession(ctx, environment.TenantID, environment.AttachedSessionID) if err != nil { return err } @@ -246,7 +246,7 @@ func (w *Worker) waitEnvironmentInitialization(ctx context.Context, environment if err != nil { return err } - if current.SessionID != environment.SessionID { + if current.AttachedSessionID != environment.AttachedSessionID { return sessions.ErrNotFound } switch current.Initialization { diff --git a/services/core/internal/execution/runtime_compute_wake_test.go b/services/core/internal/execution/runtime_compute_wake_test.go index 84a77d9d0..276b41a26 100644 --- a/services/core/internal/execution/runtime_compute_wake_test.go +++ b/services/core/internal/execution/runtime_compute_wake_test.go @@ -78,10 +78,10 @@ func TestFileReadinessWaitsForInitialization(t *testing.T) { if reads > 1 { initialization = "complete" } - return sessions.Environment{SessionID: "session", Initialization: initialization}, nil + return sessions.Environment{AttachedSessionID: "session", Initialization: initialization}, nil }}, }} - if err := worker.waitRuntimeAwake(t.Context(), sessions.Environment{SessionID: "session"}); err != nil || reads != 2 { + if err := worker.waitRuntimeAwake(t.Context(), sessions.Environment{AttachedSessionID: "session"}); err != nil || reads != 2 { t.Fatalf("Files proceeded before initialization: reads=%d err=%v", reads, err) } }) @@ -102,9 +102,9 @@ func TestFileReadinessRejectsFailedInitializationAndLostOwnership(t *testing.T) } { t.Run(test.name, func(t *testing.T) { worker := &Worker{dispatcher: &Dispatcher{SessionsReader: initializationReadinessReader{read: func(context.Context) (sessions.Environment, error) { - return sessions.Environment{SessionID: test.session, Initialization: test.state}, test.readError + return sessions.Environment{AttachedSessionID: test.session, Initialization: test.state}, test.readError }}}} - if err := worker.waitEnvironmentInitialization(t.Context(), sessions.Environment{SessionID: "session"}); !errors.Is(err, test.want) { + if err := worker.waitEnvironmentInitialization(t.Context(), sessions.Environment{AttachedSessionID: "session"}); !errors.Is(err, test.want) { t.Fatal(err) } }) diff --git a/services/core/internal/execution/runtime_connections.go b/services/core/internal/execution/runtime_connections.go index 85dc59587..efd72394a 100644 --- a/services/core/internal/execution/runtime_connections.go +++ b/services/core/internal/execution/runtime_connections.go @@ -24,7 +24,7 @@ type runtimeConnection struct { } func (r *runtimeLifecycle) observeConnection(ctx context.Context, owner deployment.Allocation) error { - bound, err := r.sessions.GetSessionRuntimeDevice(ctx, owner.TenantID, owner.SessionID) + bound, err := r.sessions.GetSessionRuntimeDevice(ctx, owner.TenantID, owner.AttachedSessionID) if errors.Is(err, sessions.ErrNotFound) { r.forgetAwaitingConnection(owner.DeviceID) return nil @@ -138,7 +138,7 @@ func (r *runtimeLifecycle) observeAwaitingConnections(parent context.Context) er operation, stop := context.WithTimeout(ctx, 5*time.Second) owner, err := r.reader.EnvironmentAllocation(operation, key) switch { - case err == nil && owner.DeviceID == device && owner.ProviderKey == r.config.InstallationID && owner.NodeID == r.nodeID && owner.State == "running" && owner.CreateSettled && !owner.SessionDeleted && !owner.Expired && (owner.ComputePhase == "disabled" || owner.ComputePhase == "running"): + case err == nil && owner.DeviceID == device && owner.ProviderKey == r.config.InstallationID && owner.NodeID == r.nodeID && owner.State == "running" && owner.CreateSettled && !owner.Reclaimed && !owner.Expired && (owner.ComputePhase == "disabled" || owner.ComputePhase == "running"): err = r.observeConnection(operation, owner) case err == nil || errors.Is(err, deployment.ErrNotFound): r.forgetAwaitingConnection(device) diff --git a/services/core/internal/execution/runtime_initialization.go b/services/core/internal/execution/runtime_initialization.go index d2645608b..e941247f4 100644 --- a/services/core/internal/execution/runtime_initialization.go +++ b/services/core/internal/execution/runtime_initialization.go @@ -58,23 +58,12 @@ func (w *Worker) runEnvironmentInitializations(ctx context.Context) error { if len(active) >= w.executionConcurrency() { continue } - peer, err := w.dispatcher.authorizedPeer(ctx, owner.DeviceID) - if err != nil { + if _, err := w.dispatcher.authorizedPeer(ctx, owner.DeviceID); err != nil { if errors.Is(err, sessions.ErrNotFound) || errors.Is(err, runtimegateway.ErrSessionClosed) || errors.Is(err, runtimegateway.ErrDeviceNotRegistered) { continue } return err } - harness, found, known := peer.AgentKindStatus(owner.Engine) - if !known { - continue - } - if !found || !harness.Available { - if err := w.dispatcher.sessionExecution.FailEnvironmentInitialization(ctx, owner, sessions.ProvisioningFailure{Step: sessions.ProvisioningHarness}); err != nil && !errors.Is(err, sessions.ErrNotFound) { - return err - } - continue - } if err := w.dispatcher.sessionExecution.ClaimEnvironmentInitialization(ctx, owner); err != nil { if errors.Is(err, sessions.ErrNotFound) || errors.Is(err, sessions.ErrTurnConflict) { continue @@ -102,7 +91,7 @@ func (w *Worker) initializeEnvironment(ctx context.Context, owner sessions.Envir // one that succeeded. Each step was already logged when it finished. recordSteps, stopSteps := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) if recordErr := w.dispatcher.sessionExecution.RecordEnvironmentInitializationSteps(recordSteps, owner, steps); recordErr != nil { - log.Warn(ctx, "Environment initialization steps not recorded", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID) + log.Warn(ctx, "Environment initialization steps not recorded", "environment_id", owner.EnvironmentID) } stopSteps() if err == nil { @@ -115,7 +104,7 @@ func (w *Worker) initializeEnvironment(ctx context.Context, owner sessions.Envir if failure.Step == "" && failure.Category == "" { failure.Category = provisioningCategory(err) } - log.Warn(ctx, "Environment preparation failed", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID, "step", failure.Step, "category", string(failure.Category)) + log.Warn(ctx, "Environment preparation failed", "environment_id", owner.EnvironmentID, "step", failure.Step, "category", string(failure.Category)) // A later scan settles an unrecorded failure; it never retries the setup. record, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer stop() @@ -134,7 +123,7 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.Environm if json.Unmarshal(environment.Configuration, &cfg) != nil || len(cfg.Files) > 50 { return sessions.ErrInvalidInput } - setup, err := w.dispatcher.SessionsReader.ReadEnvironmentSetup(ctx, owner.TenantID, owner.SessionID) + setup, err := w.dispatcher.SessionsReader.ReadEnvironmentSetup(ctx, owner.TenantID, owner.EnvironmentID) if err != nil { return err } @@ -142,17 +131,6 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.Environm if err != nil { return err } - placement, err := parseEnvironmentPlacement(environment.Configuration) - if err != nil { - return err - } - if placement.Type == "openai_hosted" { - info, found, known := peer.AgentKindStatus(owner.Engine) - if !known || !found || !info.Available || w.dispatcher.validateEnvironmentHistory(owner.Engine, environment, info.Capabilities.RetainedNativeHistory.IsSupported()) != nil { - *failure = sessions.ProvisioningFailure{Step: sessions.ProvisioningHarness} - return sessions.ErrInvalidInput - } - } identity := agentcapabilities.Identity{EnvironmentID: owner.EnvironmentID} operations := setupOperations(setup) step := func(index int) error { @@ -173,7 +151,7 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.Environm if err == nil && index < len(cfg.Files) { var metadata environmentconfig.InitialFileMetadata var body []byte - metadata, body, err = w.dispatcher.SessionsReader.ReadInitialEnvironmentFile(ctx, owner.TenantID, owner.SessionID, index) + metadata, body, err = w.dispatcher.SessionsReader.ReadInitialEnvironmentFile(ctx, owner.TenantID, owner.EnvironmentID, index) if err == nil { result, err = installInitialFile(ctx, peer, identity, metadata, body) recorded, _ = runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "file"}}.step(result) @@ -186,7 +164,7 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.Environm } if recorded.Step != "" { *steps = append(*steps, recorded) - attrs := []any{"environment_id", owner.EnvironmentID, "session_id", owner.SessionID, "step", recorded.Step, "duration_ms", recorded.DurationMS} + attrs := []any{"environment_id", owner.EnvironmentID, "step", recorded.Step, "duration_ms", recorded.DurationMS} if recorded.Index != nil { attrs = append(attrs, "index", *recorded.Index) } diff --git a/services/core/internal/execution/runtime_lifecycle.go b/services/core/internal/execution/runtime_lifecycle.go index 1411eed1b..965d38e7f 100644 --- a/services/core/internal/execution/runtime_lifecycle.go +++ b/services/core/internal/execution/runtime_lifecycle.go @@ -386,7 +386,7 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner deployment.Allocat if owner.ProviderKey != r.config.InstallationID || owner.NodeID != r.nodeID { return sandbox.ErrOwnership } - if owner.SessionDeleted || owner.Expired || owner.State == "cleanup_pending" { + if owner.Reclaimed || owner.Expired || owner.State == "cleanup_pending" { var err error owner, err = r.deployment.RequestCleanup(ctx, owner) if err != nil { @@ -418,7 +418,7 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner deployment.Allocat return err } info, err := provider.GetInfo(ctx, runtimeReference(owner)) - cleanup := owner.SessionDeleted || owner.Expired || owner.State == "cleanup_pending" + cleanup := owner.Reclaimed || owner.Expired || owner.State == "cleanup_pending" // Resource drift disqualifies execution, not cleanup already authorized by // Core. Kill independently verifies ownership before removing anything. invalidCleanup := cleanup && errors.Is(err, sandbox.ErrInvalid) && !errors.Is(err, sandbox.ErrOwnership) @@ -438,7 +438,7 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner deployment.Allocat return err } } - if owner.SessionDeleted || owner.Expired || owner.State == "cleanup_pending" { + if owner.Reclaimed || owner.Expired || owner.State == "cleanup_pending" { owner, err = r.deployment.RequestCleanup(ctx, owner) if err != nil { return err diff --git a/services/core/internal/execution/runtime_pending_test.go b/services/core/internal/execution/runtime_pending_test.go index 0d8063e2c..6d4438245 100644 --- a/services/core/internal/execution/runtime_pending_test.go +++ b/services/core/internal/execution/runtime_pending_test.go @@ -230,7 +230,7 @@ func TestAwaitingConnectionPassDropsStaleAndCoalescedWatches(t *testing.T) { owner := deployment.Allocation{DeviceID: "device", NodeID: "node", ProviderKey: "provider", State: "running", CreateSettled: true, ComputePhase: "running"} switch change { case "deleted": - owner.SessionDeleted = true + owner.Reclaimed = true case "expired": owner.Expired = true case "unsettled": diff --git a/services/core/internal/execution/runtime_preparation_cache.go b/services/core/internal/execution/runtime_preparation_cache.go index fb388379a..8057ccd15 100644 --- a/services/core/internal/execution/runtime_preparation_cache.go +++ b/services/core/internal/execution/runtime_preparation_cache.go @@ -42,7 +42,7 @@ func (w *Worker) preparationCache(ctx context.Context, owner sessions.Environmen encoder := json.NewEncoder(hash) _ = encoder.Encode(owner.TenantID) for index := range fileCount { - metadata, body, err := w.dispatcher.SessionsReader.ReadInitialEnvironmentFile(ctx, owner.TenantID, owner.SessionID, index) + metadata, body, err := w.dispatcher.SessionsReader.ReadInitialEnvironmentFile(ctx, owner.TenantID, owner.EnvironmentID, index) if err != nil { return nil, err } diff --git a/services/core/internal/execution/runtime_wake_hint.go b/services/core/internal/execution/runtime_wake_hint.go index 05fe876eb..ce5f14384 100644 --- a/services/core/internal/execution/runtime_wake_hint.go +++ b/services/core/internal/execution/runtime_wake_hint.go @@ -31,7 +31,7 @@ func (w *Worker) hintRuntimeWake(ctx context.Context, session sessions.Session) } owner, err := w.dispatcher.DeploymentReader.EnvironmentAllocation(lookup, deployment.AllocationKey{TenantID: session.TenantID, EnvironmentID: environment.ID}) if err != nil || owner.ProviderKey != config.InstallationID || owner.State != "running" || - !owner.CreateSettled || owner.SessionDeleted || owner.Expired { + !owner.CreateSettled || owner.Reclaimed || owner.Expired { return } switch owner.ComputePhase { diff --git a/services/core/internal/execution/worker.go b/services/core/internal/execution/worker.go index 9d946dba7..8764f4222 100644 --- a/services/core/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -235,11 +235,11 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { case err := <-lifecycleDone: return err case request := <-w.fileWrites: - if request.ctx.Err() != nil || active[request.environment.SessionID] || len(active) == w.executionConcurrency() { + if request.ctx.Err() != nil || active[request.environment.AttachedSessionID] || len(active) == w.executionConcurrency() { request.result <- fileWriteResult{err: ErrExecutionUnavailable} continue } - active[request.environment.SessionID] = true + active[request.environment.AttachedSessionID] = true w.observeSlots(len(active)) running.Add(1) go func() { @@ -248,7 +248,7 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { }() continue case write := <-writesCompleted: - delete(active, write.request.environment.SessionID) + delete(active, write.request.environment.AttachedSessionID) w.observeSlots(len(active)) write.request.result <- write.result if !rescanOnCompletion { @@ -256,13 +256,13 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { } rescanOnCompletion = false case request := <-w.directoryReads: - if request.ctx.Err() != nil || reads == w.executionConcurrency() || (!active[request.environment.SessionID] && len(active) == w.executionConcurrency()) { + if request.ctx.Err() != nil || reads == w.executionConcurrency() || (!active[request.environment.AttachedSessionID] && len(active) == w.executionConcurrency()) { request.reply(directoryReadResult{err: ErrExecutionUnavailable}) continue } - reserved := !active[request.environment.SessionID] + reserved := !active[request.environment.AttachedSessionID] if reserved { - active[request.environment.SessionID] = true + active[request.environment.AttachedSessionID] = true w.observeSlots(len(active)) } reads++ @@ -272,7 +272,7 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { result := w.runDirectoryRead(ctx, request, reserved) id := "" if reserved { - id = request.environment.SessionID + id = request.environment.AttachedSessionID } readsCompleted <- readCompletion{id: id, request: request, result: result} }() diff --git a/services/core/internal/execution/worker_device.go b/services/core/internal/execution/worker_device.go index 27685cbcc..de616de19 100644 --- a/services/core/internal/execution/worker_device.go +++ b/services/core/internal/execution/worker_device.go @@ -121,6 +121,32 @@ func (w *Worker) bindSessionDevice(ctx context.Context, session sessions.Session return false, nil } +// harnessUnavailable reports whether the Runtime bound to the Session is +// connected and reports the Session's Harness missing or unavailable. +func (w *Worker) harnessUnavailable(ctx context.Context, tenantID, sessionID string) (bool, error) { + session, err := w.dispatcher.SessionsReader.GetSession(ctx, tenantID, sessionID) + if err != nil { + return false, nilIfNotFound(err) + } + bound, err := w.dispatcher.SessionsReader.GetSessionDevice(ctx, tenantID, sessionID) + if err != nil { + return false, nilIfNotFound(err) + } + peer, err := w.dispatcher.authorizedPeer(ctx, bound.ID) + if err != nil { + return false, nil + } + info, found, known := peer.AgentKindStatus(session.Engine) + return known && (!found || !info.Available), nil +} + +func nilIfNotFound(err error) error { + if errors.Is(err, sessions.ErrNotFound) { + return nil + } + return err +} + func (w *Worker) ready(ctx context.Context, deviceID string, session sessions.Session, snapshot Snapshot) bool { peer, err := w.dispatcher.authorizedPeer(ctx, deviceID) if err != nil { diff --git a/services/core/internal/execution/worker_schedule.go b/services/core/internal/execution/worker_schedule.go index bf3a821cc..2730bdc01 100644 --- a/services/core/internal/execution/worker_schedule.go +++ b/services/core/internal/execution/worker_schedule.go @@ -75,6 +75,20 @@ func (s *workerSchedule) selectWork(ctx context.Context, w *Worker, devices []st if errors.Is(err, sessions.ErrNotFound) || errors.Is(err, sessions.ErrDeviceBindingConflict) { continue } + if err == nil && !ready { + // The Environment is prepared without a Harness; a connected + // Runtime that lacks the Session's Harness fails its input. + missing, err := w.harnessUnavailable(ctx, item.TenantID, item.SessionID) + if err != nil { + return nil, err + } + if missing { + if err := w.dispatcher.sessionExecution.FailEnvironmentInput(ctx, item.TenantID, item.SessionID, item.reservationID, "runtime_preparation_failed"); err != nil && !errors.Is(err, sessions.ErrNotFound) { + return nil, err + } + } + continue + } } if err != nil { return nil, err diff --git a/services/core/internal/persistence/postgres/deploymentpg/allocations.go b/services/core/internal/persistence/postgres/deploymentpg/allocations.go index ca26ea929..ed6edc0ce 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/allocations.go +++ b/services/core/internal/persistence/postgres/deploymentpg/allocations.go @@ -17,15 +17,16 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -// allocation converts a stored allocation with the facts of its Session. -func allocation(row sqlc.RuntimeAllocation, session, tenant pgtype.UUID, deleted pgtype.Timestamptz, expired bool) deployment.Allocation { +// allocation converts a stored allocation with the facts of its Environment: +// its tenant, attached Session, if any, and whether it was reclaimed. +func allocation(row sqlc.RuntimeAllocation, session, tenant pgtype.UUID, reclaimed, expired bool) deployment.Allocation { return deployment.Allocation{ DeploymentGeneration: uint64(row.DeploymentGeneration.Int64), NodeID: uuidString(row.NodeID), ObservationError: row.ObservationError, ComputePhase: row.ComputePhase, ComputeRevision: row.ComputeRevision, ComputeState: row.ComputeState, ComputeActivityAt: row.ComputeActivityAt.Time, ComputeWakeRequested: row.ComputeWakeRequested, ComputeRetainedUntil: timestamp(row.ComputeRetainedUntil), - ID: uuidString(row.ID), EnvironmentID: uuidString(row.EnvironmentID), SessionID: uuidString(session), TenantID: uuidString(tenant), + ID: uuidString(row.ID), EnvironmentID: uuidString(row.EnvironmentID), AttachedSessionID: uuidString(session), TenantID: uuidString(tenant), DeviceID: uuidString(row.DeviceID), ProviderKey: uuidString(row.ProviderKey), State: row.State, CreateSettled: row.CreateSettled, - SessionDeleted: deleted.Valid, Expired: expired, CreatedAt: row.CreatedAt.Time, + Reclaimed: reclaimed, Expired: expired, CreatedAt: row.CreatedAt.Time, } } @@ -40,7 +41,7 @@ func findAllocation(ctx context.Context, q *sqlc.Queries, tenant, environment pg if err != nil { return deployment.Allocation{}, false, err } - return allocation(row.RuntimeAllocation, row.SessionID, row.TenantID, row.DeletedAt, row.Expired), true, nil + return allocation(row.RuntimeAllocation, row.AttachedSessionID, row.TenantID, row.Reclaimed, row.Expired), true, nil } func allocationKey(key deployment.AllocationKey) (pgtype.UUID, pgtype.UUID, error) { @@ -75,25 +76,12 @@ func (e *Execution) WithReservation(ctx context.Context, key deployment.Allocati if err != nil { return err } - return translate(e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - owned, err := q.GetEnvironment(ctx, sqlc.GetEnvironmentParams{TenantID: tenant, ID: environment}) - if errors.Is(err, pgx.ErrNoRows) { + return translate(sessionpg.WithEnvironment(ctx, e.lease, tenant, environment, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession, session pgtype.UUID) error { + if locked.Deleted { return sessions.ErrNotFound } - if err != nil { - return err - } - session := owned.Environment.SessionID - locked, err := sessionpg.LockSession(ctx, q, tenant, session) - if err != nil { - return err - } t := &reservationTx{SessionTx: sessionpg.BindSession(q, tenant, session), ctx: ctx, q: q, tenant: tenant, environment: environment, session: session} - if err := apply(locked, t); err != nil { - return err - } - return sessionpg.PruneChanges(ctx, q, session) + return apply(locked, t) })) } @@ -107,29 +95,20 @@ func (e *Execution) WithAllocationCleanup(ctx context.Context, key deployment.Al }) } -// withAllocation locks the Session that owns the Environment's allocation, -// deleted or not, runs apply and prunes the Session's journal. +// withAllocation locks the Environment of the allocation and its attached +// Session, reclaimed or not, runs apply and prunes the Session's journal. func (e *Execution) withAllocation(ctx context.Context, key deployment.AllocationKey, apply func(*allocationTx) error) error { tenant, environment, err := allocationKey(key) if err != nil { return err } - return translate(e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - row, err := q.GetLatestRuntimeAllocation(ctx, sqlc.GetLatestRuntimeAllocationParams{TenantID: tenant, EnvironmentID: environment}) - if errors.Is(err, pgx.ErrNoRows) { - return deployment.ErrNotFound - } - if err != nil { - return err - } - if _, err := sessionpg.LockSession(ctx, q, row.TenantID, row.SessionID); err != nil { - return err - } - if err := apply(&allocationTx{ctx: ctx, q: q, tenant: tenant, environment: environment, session: row.SessionID}); err != nil { + return translate(sessionpg.WithEnvironment(ctx, e.lease, tenant, environment, func(ctx context.Context, q *sqlc.Queries, _ sessions.LockedSession, session pgtype.UUID) error { + if _, found, err := findAllocation(ctx, q, tenant, environment); err != nil { return err + } else if !found { + return deployment.ErrNotFound } - return sessionpg.PruneChanges(ctx, q, row.SessionID) + return apply(&allocationTx{ctx: ctx, q: q, tenant: tenant, environment: environment, session: session}) })) } @@ -148,20 +127,10 @@ func (s *Store) WithActivity(ctx context.Context, key deployment.AllocationKey, if err != nil { return err } - return translate(s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - owned, err := q.GetEnvironment(ctx, sqlc.GetEnvironmentParams{TenantID: tenant, ID: environment}) - if errors.Is(err, pgx.ErrNoRows) { + return translate(sessionpg.WithEnvironment(ctx, s.pool, tenant, environment, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession, _ pgtype.UUID) error { + if locked.Deleted { return sessions.ErrNotFound } - if err != nil { - return err - } - session := owned.Environment.SessionID - locked, err := sessionpg.LockSession(ctx, q, tenant, session) - if err != nil { - return err - } touch := activityTx(func() error { retained := pgtype.UUID{} current, found, err := findAllocation(ctx, q, tenant, environment) @@ -195,10 +164,7 @@ func (s *Store) WithActivity(ctx context.Context, key deployment.AllocationKey, } return q.TouchRuntimeActivity(ctx, sqlc.TouchRuntimeActivityParams{TenantID: tenant, EnvironmentID: environment, RetainedID: retained}) }) - if err := apply(locked, touch); err != nil { - return err - } - return sessionpg.PruneChanges(ctx, q, session) + return apply(locked, touch) })) } @@ -296,7 +262,7 @@ func (t *reservationTx) InsertAllocation(a deployment.NewAllocation) (deployment if err != nil { return deployment.Allocation{}, err } - return allocation(row, t.session, t.tenant, pgtype.Timestamptz{}, false), nil + return allocation(row, t.session, t.tenant, false, false), nil } // allocationTx is one allocation change in the transaction that locked the @@ -315,15 +281,15 @@ func (t *allocationTx) LoadAllocation() (deployment.Allocation, error) { return current, err } -func (t *allocationTx) LoadSessionDevice() (deployment.SessionDevice, bool, error) { - row, err := t.q.GetSessionDevice(t.ctx, sqlc.GetSessionDeviceParams{TenantID: t.tenant, ID: t.session}) +func (t *allocationTx) LoadEnvironmentDevice() (deployment.EnvironmentDevice, bool, error) { + row, err := t.q.GetEnvironmentDevice(t.ctx, sqlc.GetEnvironmentDeviceParams{TenantID: t.tenant, EnvironmentID: t.environment}) if errors.Is(err, pgx.ErrNoRows) { - return deployment.SessionDevice{}, false, nil + return deployment.EnvironmentDevice{}, false, nil } if err != nil { - return deployment.SessionDevice{}, false, err + return deployment.EnvironmentDevice{}, false, err } - return deployment.SessionDevice{ID: uuidString(row.ID), EnvironmentID: uuidString(row.EnvironmentID)}, true, nil + return deployment.EnvironmentDevice{ID: uuidString(row.ID), EnvironmentID: uuidString(row.EnvironmentID)}, true, nil } func (t *allocationTx) LoadActivity(current deployment.Allocation) (deployment.Activity, error) { @@ -413,9 +379,7 @@ func (t *allocationTx) change(current deployment.Allocation, write func(context. if err != nil { return deployment.Allocation{}, err } - changed := allocation(row, t.session, t.tenant, pgtype.Timestamptz{}, current.Expired) - changed.SessionDeleted = current.SessionDeleted - return changed, nil + return allocation(row, t.session, t.tenant, current.Reclaimed, current.Expired), nil } // cleanupTx is one allocation cleanup, with the owning Session's cancellation @@ -480,7 +444,7 @@ func (s *Store) CredentialAllocations(ctx context.Context, after string) ([]depl } result := make([]deployment.Allocation, 0, len(rows)) for _, row := range rows { - result = append(result, allocation(row.RuntimeAllocation, row.SessionID, row.TenantID, row.DeletedAt, row.Expired)) + result = append(result, allocation(row.RuntimeAllocation, row.AttachedSessionID, row.TenantID, row.Reclaimed, row.Expired)) } return result, nil } @@ -527,7 +491,7 @@ func (s *Store) NodeAllocations(ctx context.Context, nodeID string) ([]deploymen for _, a := range rows { result = append(result, deployment.NodeAllocation{ DeploymentGeneration: uint64(a.DeploymentGeneration.Int64), Diagnostic: a.ObservationError, ID: uuidString(a.ID), NodeID: uuidString(a.NodeID), - TenantID: uuidString(a.TenantID), SessionID: uuidString(a.SessionID), EnvironmentID: uuidString(a.EnvironmentID), State: a.State, + TenantID: uuidString(a.TenantID), SessionID: uuidString(a.AttachedSessionID), EnvironmentID: uuidString(a.EnvironmentID), State: a.State, ComputePhase: a.ComputePhase, ComputePhaseChangedAt: timestamp(a.ComputePhaseChangedAt), Initialization: a.Initialization, CreatedAt: a.CreatedAt.Time, }) } @@ -586,7 +550,7 @@ func (s *Store) LifecycleAllocations(ctx context.Context, nodeID, after string) } result := make([]deployment.Allocation, 0, len(rows)) for _, row := range rows { - result = append(result, allocation(row.RuntimeAllocation, row.SessionID, row.TenantID, row.DeletedAt, row.Expired)) + result = append(result, allocation(row.RuntimeAllocation, row.AttachedSessionID, row.TenantID, row.Reclaimed, row.Expired)) } return result, nil } diff --git a/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go b/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go index dc240edea..89915cb19 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go @@ -101,7 +101,7 @@ func TestReplacementKeepsSessionIdentityAndFencesPriorOwner(t *testing.T) { t.Fatal("replacement reused identity", fresh, first, second) } var device, native string - if err := f.pool.QueryRow(t.Context(), `SELECT device_id,native_session_id FROM session_devices WHERE session_id=$1`, second.SessionID).Scan(&device, &native); err != nil || device != second.DeviceID || native != "retained-native-session" { + if err := f.pool.QueryRow(t.Context(), `SELECT device_id,native_session_id FROM session_devices WHERE session_id=$1`, second.AttachedSessionID).Scan(&device, &native); err != nil || device != second.DeviceID || native != "retained-native-session" { t.Fatal(device, native, err) } for name, write := range map[string]func() (deployment.Allocation, error){"cleanup": func() (deployment.Allocation, error) { return changes.RequestCleanup(t.Context(), first) }, "release": func() (deployment.Allocation, error) { return changes.ReleaseAllocation(t.Context(), first) }, "observe": func() (deployment.Allocation, error) { return changes.ObserveRunning(t.Context(), first) }} { @@ -290,7 +290,7 @@ func TestResetArchivesRetainedSessionBeforeClearingDeployment(t *testing.T) { if err != nil || started.Reset == nil { t.Fatal(started, err) } - if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET created_at=$2 WHERE id=$1`, retained[4].SessionID, started.Reset.RequestedAt.Add(time.Second)); err != nil { + if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET created_at=$2 WHERE id=$1`, retained[4].AttachedSessionID, started.Reset.RequestedAt.Add(time.Second)); err != nil { t.Fatal(err) } candidates, err := f.adapter.ResetSessions(t.Context(), "", true) @@ -303,7 +303,7 @@ func TestResetArchivesRetainedSessionBeforeClearingDeployment(t *testing.T) { } for _, candidate := range candidates { for _, excluded := range retained[1:5] { - if candidate.SessionID == excluded.SessionID { + if candidate.SessionID == excluded.AttachedSessionID { t.Fatal("out-of-scope history entered reset", candidate) } } diff --git a/services/core/internal/persistence/postgres/placementpg/placementpg.go b/services/core/internal/persistence/postgres/placementpg/placementpg.go index ad5abde8a..577950cc3 100644 --- a/services/core/internal/persistence/postgres/placementpg/placementpg.go +++ b/services/core/internal/persistence/postgres/placementpg/placementpg.go @@ -44,13 +44,13 @@ func LoadNodes(ctx context.Context, q *sqlc.Queries) ([]placement.Node, error) { return nodes, nil } -// ReservePlacement reserves the placement for the Session's Environment. -func ReservePlacement(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, p placement.Placement) error { +// ReservePlacement reserves the placement for the Environment. +func ReservePlacement(ctx context.Context, q *sqlc.Queries, environment pgtype.UUID, p placement.Placement) error { id, err := pgunit.ParseID(p.NodeID) if err != nil { return err } - return q.CreateSessionRuntimePlacement(ctx, sqlc.CreateSessionRuntimePlacementParams{SessionID: session, NodeID: id, Generation: int64(p.Generation)}) + return q.CreateRuntimePlacement(ctx, sqlc.CreateRuntimePlacementParams{EnvironmentID: environment, NodeID: id, Generation: int64(p.Generation)}) } // LoadReserved returns the node placement reserved for the Environment. diff --git a/services/core/internal/persistence/postgres/sessionpg/artifacts.go b/services/core/internal/persistence/postgres/sessionpg/artifacts.go index c98e1548f..382a26a44 100644 --- a/services/core/internal/persistence/postgres/sessionpg/artifacts.go +++ b/services/core/internal/persistence/postgres/sessionpg/artifacts.go @@ -172,7 +172,7 @@ type artifactStaging struct { func (t *artifactStaging) LoadEnvironment(ctx context.Context) (sessions.Environment, error) { row, err := t.q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: t.turn.TenantID, ID: t.turn.SessionID}) - return environmentFromRow(row.Environment, row.TenantID, row.Configuration, row.ExternalWorkspace, err) + return environmentFromRow(row.Environment, row.AttachedSessionID, row.ExternalWorkspace, err) } func (t *artifactStaging) PutArtifactContent(ctx context.Context, path string, size int64, content io.Reader) error { diff --git a/services/core/internal/persistence/postgres/sessionpg/binding.go b/services/core/internal/persistence/postgres/sessionpg/binding.go index 6650c2552..145e20f43 100644 --- a/services/core/internal/persistence/postgres/sessionpg/binding.go +++ b/services/core/internal/persistence/postgres/sessionpg/binding.go @@ -116,11 +116,11 @@ func (t *SessionTx) LoadEnvironmentInput(ctx context.Context) (*sessions.Environ func (t *SessionTx) LoadEnvironment(ctx context.Context) (sessions.Environment, error) { row, err := t.q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: t.tenant, ID: t.session}) - return environmentFromRow(row.Environment, row.TenantID, row.Configuration, row.ExternalWorkspace, err) + return environmentFromRow(row.Environment, row.AttachedSessionID, row.ExternalWorkspace, err) } -// RecordEnvironmentFailure fails the Environment only while it is the -// Session's and still live; otherwise it changes nothing and returns +// RecordEnvironmentFailure fails the tenant's Environment only while it is +// still live; otherwise it changes nothing and returns // pgx.ErrNoRows. func (t *SessionTx) RecordEnvironmentFailure(ctx context.Context, environment, reason string, detail *sessions.ProvisioningFailureDetail) (time.Time, error) { id, err := parseID(environment) @@ -134,20 +134,20 @@ func (t *SessionTx) RecordEnvironmentFailure(ctx context.Context, environment, r } } failedAt, err := t.q.RecordEnvironmentFailure(ctx, sqlc.RecordEnvironmentFailureParams{ - ID: id, SessionID: t.session, TenantID: t.tenant, + ID: id, TenantID: t.tenant, FailureReason: pgtype.Text{String: reason, Valid: true}, FailureDetail: rawDetail, }) return failedAt.Time, err } -// ExpireEnvironment expires the Environment only while it is the Session's; -// another Environment is sessions.ErrNotFound and changes nothing. +// ExpireEnvironment expires the tenant's Environment; another one is +// sessions.ErrNotFound and changes nothing. func (t *SessionTx) ExpireEnvironment(ctx context.Context, environment string) error { id, err := parseID(environment) if err != nil { return err } - count, err := t.q.ExpireSessionEnvironment(ctx, sqlc.ExpireSessionEnvironmentParams{ID: id, SessionID: t.session, TenantID: t.tenant}) + count, err := t.q.ExpireEnvironment(ctx, sqlc.ExpireEnvironmentParams{ID: id, TenantID: t.tenant}) if err == nil && count != 1 { return sessions.ErrNotFound } @@ -176,7 +176,7 @@ func (t *SessionTx) InsertEnvironmentDevice(ctx context.Context, device sessions return err } inserted, err := t.q.CreateEnvironmentDevice(ctx, sqlc.CreateEnvironmentDeviceParams{ - ID: id, TenantID: t.tenant, SessionID: t.session, EnvironmentID: environment, + ID: id, TenantID: t.tenant, EnvironmentID: environment, Name: device.Name, CredentialHash: pgtype.Text{String: credentialHash, Valid: true}, }) if errors.Is(err, pgx.ErrNoRows) { diff --git a/services/core/internal/persistence/postgres/sessionpg/creation.go b/services/core/internal/persistence/postgres/sessionpg/creation.go index de740e39b..9bf107ec0 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation.go @@ -72,15 +72,21 @@ func (s *Store) FindCreation(ctx context.Context, tenantID, key string) (session // caller named it, which binds the sealed model execution and the audit. type creationTx struct { SessionTx - tenantID string - tx pgx.Tx - cipher *credentialcrypto.Cipher + // environment is the Session's Environment once CreateEnvironment ran. + environment pgtype.UUID + tenantID string + tx pgx.Tx + cipher *credentialcrypto.Cipher } func (t *creationTx) UpsertSession(ctx context.Context, session sessions.NewSession) (sessions.Creation, error) { + stored, _, err := sessions.SplitEnvironment(session.Configuration) + if err != nil { + return sessions.Creation{}, err + } params := sqlc.CreateSessionParams{ ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: t.tenant, Engine: session.Engine, - Metadata: session.Metadata, IdempotencyKey: session.Key, RequestHash: session.RequestHash, Configuration: session.Configuration, + Metadata: session.Metadata, IdempotencyKey: session.Key, RequestHash: session.RequestHash, Configuration: stored, CreatorKind: pgtype.Text{String: session.Creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: session.Creator.ID, Valid: true}, } if session.IntentHash != nil { @@ -94,8 +100,8 @@ func (t *creationTx) UpsertSession(ctx context.Context, session sessions.NewSess return sessions.Creation{}, storable(err) } t.session = row.ID - stored, err := sessionFromRow(row) - return sessions.Creation{Session: stored, Created: row.ID == params.ID, Cursor: row.EventSequence}, err + created, err := sessionFromRow(row) + return sessions.Creation{Session: created, Created: row.ID == params.ID, Cursor: row.EventSequence}, err } func (t *creationTx) LockDeployment(ctx context.Context) (placement.Deployment, error) { @@ -169,7 +175,7 @@ func (t *creationTx) SaveInitialFiles(ctx context.Context, initial []environment return fmt.Errorf("seal initial file: %w", err) } if err := t.q.CreateInitialEnvironmentFile(ctx, sqlc.CreateInitialEnvironmentFileParams{ - ID: pgtype.UUID{Bytes: id, Valid: true}, SessionID: t.session, Position: int32(i), Path: f.Path, SizeBytes: size, Contents: encrypted, + ID: pgtype.UUID{Bytes: id, Valid: true}, EnvironmentID: t.environment, Position: int32(i), Path: f.Path, SizeBytes: size, Contents: encrypted, }); err != nil { return storable(err) } @@ -178,7 +184,7 @@ func (t *creationTx) SaveInitialFiles(ctx context.Context, initial []environment if err != nil { return err } - return storable(t.q.SetSessionInitialFileMetadata(ctx, sqlc.SetSessionInitialFileMetadataParams{ID: t.session, Column2: encoded})) + return storable(t.q.SetEnvironmentInitialFileMetadata(ctx, sqlc.SetEnvironmentInitialFileMetadataParams{ID: t.environment, Column2: encoded})) } func (t *creationTx) SaveSetup(ctx context.Context, setup environmentconfig.Setup) error { @@ -190,10 +196,10 @@ func (t *creationTx) SaveSetup(ctx context.Context, setup environmentconfig.Setu if err != nil { return fmt.Errorf("seal environment setup: %w", err) } - if err := t.q.CreateEnvironmentSetup(ctx, sqlc.CreateEnvironmentSetupParams{SessionID: t.session, Contents: encrypted}); err != nil { + if err := t.q.CreateEnvironmentSetup(ctx, sqlc.CreateEnvironmentSetupParams{EnvironmentID: t.environment, Contents: encrypted}); err != nil { return err } - params := sqlc.SetSessionSetupMetadataParams{ID: t.session} + params := sqlc.SetEnvironmentSetupMetadataParams{ID: t.environment} for _, field := range []struct { target *[]byte value any @@ -207,15 +213,18 @@ func (t *creationTx) SaveSetup(ctx context.Context, setup environmentconfig.Setu return err } } - return storable(t.q.SetSessionSetupMetadata(ctx, params)) + return storable(t.q.SetEnvironmentSetupMetadata(ctx, params)) } -func (t *creationTx) CreateEnvironment(ctx context.Context) (string, error) { - id := uuid.New() - if err := t.q.CreateEnvironment(ctx, sqlc.CreateEnvironmentParams{ID: pgtype.UUID{Bytes: id, Valid: true}, SessionID: t.session}); err != nil { +func (t *creationTx) CreateEnvironment(ctx context.Context, configuration json.RawMessage, prepared bool) (string, error) { + t.environment = pgtype.UUID{Bytes: uuid.New(), Valid: true} + if err := t.q.CreateEnvironment(ctx, sqlc.CreateEnvironmentParams{ID: t.environment, TenantID: t.tenant, Configuration: configuration, Prepared: prepared}); err != nil { + return "", err + } + if err := t.q.AttachSessionEnvironment(ctx, sqlc.AttachSessionEnvironmentParams{ID: t.session, EnvironmentID: t.environment}); err != nil { return "", err } - return id.String(), nil + return optionalID(t.environment), nil } func (t *creationTx) PruneChanges(ctx context.Context) error { diff --git a/services/core/internal/persistence/postgres/sessionpg/devices.go b/services/core/internal/persistence/postgres/sessionpg/devices.go index 976014d2b..b785fb51e 100644 --- a/services/core/internal/persistence/postgres/sessionpg/devices.go +++ b/services/core/internal/persistence/postgres/sessionpg/devices.go @@ -214,8 +214,8 @@ func (s *Store) TouchAuthenticatedDevice(ctx context.Context, device, credential } // WithEnrollment authenticates the credential and reads the Environment on -// the pool before it knows which Session to lock; the enrollment transaction -// rechecks that authority under the Session lock. +// the pool; the enrollment transaction rechecks that authority under the +// Environment locks. func (s *Store) WithEnrollment(ctx context.Context, environment, credentialHash string, apply func(context.Context, sessions.EnrollmentTx, sessions.Environment, sessions.LockedSession) error) error { tenant, err := s.AuthenticateEnvironmentExecutor(ctx, environment, credentialHash) if err != nil { @@ -229,14 +229,13 @@ func (s *Store) WithEnrollment(ctx context.Context, environment, credentialHash if err != nil { return err } - return WithSession(ctx, s.units, lookup.TenantID, pgunit.PathID(current.SessionID), func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession) error { - tx := &enrollmentTx{SessionTx: BindSession(q, lookup.TenantID, pgunit.PathID(current.SessionID)), environment: lookup.ID, credentialHash: credentialHash} + return WithEnvironment(ctx, s.units, lookup.TenantID, lookup.ID, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession, attached pgtype.UUID) error { + tx := &enrollmentTx{SessionTx: BindSession(q, lookup.TenantID, attached), environment: lookup.ID, credentialHash: credentialHash} return apply(ctx, tx, current, locked) }) } -// enrollmentTx is one enrollment inside the transaction of the Environment's -// Session. +// enrollmentTx is one enrollment inside the Environment's transaction. type enrollmentTx struct { *SessionTx environment pgtype.UUID diff --git a/services/core/internal/persistence/postgres/sessionpg/environment.go b/services/core/internal/persistence/postgres/sessionpg/environment.go index e191fe228..7e7e6beeb 100644 --- a/services/core/internal/persistence/postgres/sessionpg/environment.go +++ b/services/core/internal/persistence/postgres/sessionpg/environment.go @@ -23,7 +23,7 @@ func LoadEnvironment(ctx context.Context, q *sqlc.Queries, tenant, environment s return sessions.Environment{}, err } row, err := q.GetEnvironment(ctx, sqlc.GetEnvironmentParams{TenantID: tenantID, ID: pgunit.PathID(environment)}) - return environmentFromRow(row.Environment, row.TenantID, row.Configuration, row.ExternalWorkspace, err) + return environmentFromRow(row.Environment, row.AttachedSessionID, row.ExternalWorkspace, err) } // loadSessionEnvironment reads, on q, the Environment of the tenant's Session @@ -39,7 +39,7 @@ func loadSessionEnvironment(ctx context.Context, q *sqlc.Queries, tenant, sessio return sessions.Environment{}, err } row, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenantID, ID: id}) - return environmentFromRow(row.Environment, row.TenantID, row.Configuration, row.ExternalWorkspace, err) + return environmentFromRow(row.Environment, row.AttachedSessionID, row.ExternalWorkspace, err) } func (s *Store) GetEnvironment(ctx context.Context, tenant, environment string) (sessions.Environment, error) { @@ -62,8 +62,8 @@ func (s *Store) ListEnvironmentInitializations(ctx context.Context, after string result := make([]sessions.EnvironmentInitialization, 0, len(rows)) for _, row := range rows { result = append(result, sessions.EnvironmentInitialization{ - EnvironmentID: optionalID(row.ID), SessionID: optionalID(row.SessionID), TenantID: optionalID(row.TenantID), - DeviceID: optionalID(row.DeviceID), State: row.Initialization, Engine: row.Engine, + EnvironmentID: optionalID(row.ID), TenantID: optionalID(row.TenantID), + DeviceID: optionalID(row.DeviceID), State: row.Initialization, }) } return result, nil diff --git a/services/core/internal/persistence/postgres/sessionpg/environment_lock.go b/services/core/internal/persistence/postgres/sessionpg/environment_lock.go new file mode 100644 index 000000000..ad60da600 --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/environment_lock.go @@ -0,0 +1,67 @@ +package sessionpg + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +var errAttachmentMoved = errors.New("the Environment's attached Session changed while locking") + +// WithEnvironment runs one Environment transaction on runner. It reads the +// attached Session, locks that Session if there is one, then locks the +// Environment and checks that the attachment did not move; it retries once +// when it did. The lock order is always Session, then Environment. apply gets +// what the locks show, with Deleted true for a Session-owned Environment whose +// Session was deleted and Engine the attached Session's, and the attached +// Session, which is invalid when none is attached. The attached Session's +// journal is pruned afterwards. +func WithEnvironment(ctx context.Context, runner pgunit.Transactor, tenant, environment pgtype.UUID, apply func(context.Context, *sqlc.Queries, sessions.LockedSession, pgtype.UUID) error) error { + var err error + for range 2 { + err = runner.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + return lockEnvironment(ctx, sqlc.New(tx), tenant, environment, apply) + }) + if !errors.Is(err, errAttachmentMoved) { + return err + } + } + return sessions.ErrTurnConflict +} + +func lockEnvironment(ctx context.Context, q *sqlc.Queries, tenant, environment pgtype.UUID, apply func(context.Context, *sqlc.Queries, sessions.LockedSession, pgtype.UUID) error) error { + seen, err := q.GetEnvironmentAttachment(ctx, sqlc.GetEnvironmentAttachmentParams{TenantID: tenant, ID: environment}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.ErrNotFound + } + if err != nil { + return err + } + var locked sessions.LockedSession + if seen.AttachedSessionID.Valid { + if locked, err = LockSession(ctx, q, tenant, seen.AttachedSessionID); err != nil { + return err + } + } + row, err := q.LockEnvironment(ctx, sqlc.LockEnvironmentParams{TenantID: tenant, ID: environment}) + if err != nil { + return err + } + if row.AttachedSessionID != seen.AttachedSessionID { + return errAttachmentMoved + } + locked.Deleted = row.SessionOwned && !row.AttachedSessionID.Valid + if err := apply(ctx, q, locked, row.AttachedSessionID); err != nil { + return err + } + if !row.AttachedSessionID.Valid { + return nil + } + return PruneChanges(ctx, q, row.AttachedSessionID) +} diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_environment.go b/services/core/internal/persistence/postgres/sessionpg/execution_environment.go index c99a98ab7..98326c513 100644 --- a/services/core/internal/persistence/postgres/sessionpg/execution_environment.go +++ b/services/core/internal/persistence/postgres/sessionpg/execution_environment.go @@ -7,39 +7,36 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -func (e *Execution) WithInitialization(ctx context.Context, tenant, session string, apply func(context.Context, sessions.InitializationTx, sessions.LockedSession) error) error { - tenantID, err := parseID(tenant) - if err != nil { - return err - } - sessionID := pgunit.PathID(session) - return WithSession(ctx, e.lease, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession) error { - return apply(ctx, &environmentTx{SessionTx: BindSession(q, tenantID, sessionID)}, locked) +// WithInitialization runs apply in the transaction of the tenant's +// Environment. A malformed Environment ID is a missing Environment. +func (e *Execution) WithInitialization(ctx context.Context, tenant, environment string, apply func(context.Context, sessions.InitializationTx, sessions.LockedSession) error) error { + return e.withEnvironment(ctx, tenant, environment, func(ctx context.Context, tx *environmentTx, locked sessions.LockedSession) error { + return apply(ctx, tx, locked) }) } -// WithConnection reads the Environment in the transaction before it locks -// the Environment's Session. +// WithConnection runs apply in the transaction of the tenant's Environment. func (e *Execution) WithConnection(ctx context.Context, tenant, environment string, apply func(context.Context, sessions.ConnectionTx, sessions.LockedSession) error) error { - return e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - current, err := LoadEnvironment(ctx, q, tenant, environment) - if err != nil { - return err - } - lookup, err := ResourceLookup(tenant, current.SessionID) - if err != nil { - return err - } - return inSession(ctx, q, lookup.TenantID, lookup.ID, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession) error { - return apply(ctx, &environmentTx{SessionTx: BindSession(q, lookup.TenantID, lookup.ID)}, locked) - }) + return e.withEnvironment(ctx, tenant, environment, func(ctx context.Context, tx *environmentTx, locked sessions.LockedSession) error { + return apply(ctx, tx, locked) + }) +} + +func (e *Execution) withEnvironment(ctx context.Context, tenant, environment string, apply func(context.Context, *environmentTx, sessions.LockedSession) error) error { + tenantID, err := parseID(tenant) + if err != nil { + return err + } + id := pgunit.PathID(environment) + return WithEnvironment(ctx, e.lease, tenantID, id, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession, attached pgtype.UUID) error { + return apply(ctx, &environmentTx{SessionTx: BindSession(q, tenantID, attached), environment: id}, locked) }) } @@ -74,17 +71,33 @@ func (e *Execution) ListEnvironmentConnections(ctx context.Context, after string return keys, nil } -// environmentTx is the Session's Environment inside the Session transaction -// of an initialization or connection operation. -type environmentTx struct{ *SessionTx } +// environmentTx is an Environment inside the transaction of an initialization +// or connection operation. Its Session is the attached one, invalid when none +// is attached. +type environmentTx struct { + *SessionTx + environment pgtype.UUID +} var ( _ sessions.InitializationTx = (*environmentTx)(nil) _ sessions.ConnectionTx = (*environmentTx)(nil) ) -func (t *environmentTx) LoadSessionDevice(ctx context.Context) (sessions.ExecutionDevice, bool, error) { - return loadSessionDevice(ctx, t.q, t.tenant, t.session) +func (t *environmentTx) LoadEnvironment(ctx context.Context) (sessions.Environment, error) { + row, err := t.q.GetEnvironment(ctx, sqlc.GetEnvironmentParams{TenantID: t.tenant, ID: t.environment}) + return environmentFromRow(row.Environment, row.AttachedSessionID, row.ExternalWorkspace, err) +} + +func (t *environmentTx) LoadEnvironmentDevice(ctx context.Context) (sessions.ExecutionDevice, bool, error) { + row, err := t.q.GetEnvironmentDevice(ctx, sqlc.GetEnvironmentDeviceParams{TenantID: t.tenant, EnvironmentID: t.environment}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.ExecutionDevice{}, false, nil + } + if err != nil { + return sessions.ExecutionDevice{}, false, err + } + return sessions.ExecutionDevice{ID: uuid.UUID(row.ID.Bytes).String(), Name: row.Name, EnvironmentID: optionalID(row.EnvironmentID)}, true, nil } func (t *environmentTx) ClaimInitialization(ctx context.Context, environment string) (bool, error) { diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_file_writes.go b/services/core/internal/persistence/postgres/sessionpg/execution_file_writes.go index 41b946de4..39246d68e 100644 --- a/services/core/internal/persistence/postgres/sessionpg/execution_file_writes.go +++ b/services/core/internal/persistence/postgres/sessionpg/execution_file_writes.go @@ -10,84 +10,51 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" ) -// WithFileWriteReservation reads the Environment in the transaction before it -// locks the Environment's Session. +// WithFileWriteReservation runs apply in the transaction of the tenant's +// Environment, with the Environment it reads under the locks. func (e *Execution) WithFileWriteReservation(ctx context.Context, tenant, environment string, apply func(context.Context, sessions.FileWriteReservationTx, sessions.Environment, sessions.LockedSession) error) error { - return e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - owner, err := LoadEnvironment(ctx, q, tenant, environment) + return e.withFileWrite(ctx, tenant, environment, func(ctx context.Context, tx *fileWriteTx, locked sessions.LockedSession) error { + owner, err := tx.LoadEnvironment(ctx) if err != nil { return err } - tenantID, err := parseID(tenant) - if err != nil { - return err - } - environmentID, err := parseID(owner.ID) - if err != nil { - return err - } - sessionID, err := parseID(owner.SessionID) - if err != nil { - return err - } - return inSession(ctx, q, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession) error { - return apply(ctx, newFileWriteTx(q, tenantID, sessionID, environmentID), owner, locked) - }) + return apply(ctx, tx, owner, locked) }) } -// WithFileWriteSettlement reads the write in the transaction before it locks -// the Session of the write's Environment, whether or not that Session was -// publicly deleted. A malformed ID is sessions.ErrInvalidInput and an unknown -// write sessions.ErrNotFound. +// WithFileWriteSettlement runs apply in the transaction of the write's +// Environment, whether or not its Session was publicly deleted. A malformed ID +// is sessions.ErrInvalidInput and an unknown write sessions.ErrNotFound. func (e *Execution) WithFileWriteSettlement(ctx context.Context, tenant, environment, write string, apply func(context.Context, sessions.FileWriteSettlementTx) error) error { - tenantID, err := parseID(tenant) - if err != nil { - return err - } - environmentID, err := parseID(environment) - if err != nil { - return err - } - writeID, err := parseID(write) - if err != nil { + if _, err := parseID(write); err != nil { return err } - return e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - row, err := q.GetEnvironmentFileWrite(ctx, sqlc.GetEnvironmentFileWriteParams{TenantID: tenantID, EnvironmentID: environmentID, ID: writeID}) - if errors.Is(err, pgx.ErrNoRows) { + return e.withFileWrite(ctx, tenant, environment, func(ctx context.Context, tx *fileWriteTx, _ sessions.LockedSession) error { + if _, err := tx.loadFileWrite(ctx, write); errors.Is(err, pgx.ErrNoRows) { return sessions.ErrNotFound - } - if err != nil { + } else if err != nil { return err } - return inSession(ctx, q, tenantID, row.SessionID, func(ctx context.Context, q *sqlc.Queries, _ sessions.LockedSession) error { - return apply(ctx, newFileWriteTx(q, tenantID, row.SessionID, environmentID)) - }) + return apply(ctx, tx) }) } -// fileWriteTx is the file writes to the Session's Environment inside the -// Session transaction. -type fileWriteTx struct { - environmentTx - environment pgtype.UUID +func (e *Execution) withFileWrite(ctx context.Context, tenant, environment string, apply func(context.Context, *fileWriteTx, sessions.LockedSession) error) error { + return e.withEnvironment(ctx, tenant, environment, func(ctx context.Context, tx *environmentTx, locked sessions.LockedSession) error { + return apply(ctx, &fileWriteTx{environmentTx: *tx}, locked) + }) } +// fileWriteTx is the file writes to the Environment inside its transaction. +type fileWriteTx struct{ environmentTx } + var ( _ sessions.FileWriteReservationTx = (*fileWriteTx)(nil) _ sessions.FileWriteSettlementTx = (*fileWriteTx)(nil) ) -func newFileWriteTx(q *sqlc.Queries, tenant, session, environment pgtype.UUID) *fileWriteTx { - return &fileWriteTx{environmentTx: environmentTx{SessionTx: BindSession(q, tenant, session)}, environment: environment} -} - func (t *fileWriteTx) LoadFileWrite(ctx context.Context, write string) (sessions.EnvironmentFileWrite, bool, error) { row, err := t.loadFileWrite(ctx, write) if errors.Is(err, pgx.ErrNoRows) { diff --git a/services/core/internal/persistence/postgres/sessionpg/executor_credentials.go b/services/core/internal/persistence/postgres/sessionpg/executor_credentials.go index 6d77d7a8b..d660e7368 100644 --- a/services/core/internal/persistence/postgres/sessionpg/executor_credentials.go +++ b/services/core/internal/persistence/postgres/sessionpg/executor_credentials.go @@ -190,8 +190,8 @@ func (s *Store) WithExecutorCredentials(ctx context.Context, tenant string, appl }) } -// WithEnvironmentExecutorCredentials reads the Environment on the pool before -// it knows which Session to lock. +// WithEnvironmentExecutorCredentials runs apply in the transaction of the +// tenant's Environment. func (s *Store) WithEnvironmentExecutorCredentials(ctx context.Context, tenant, environment string, apply func(context.Context, sessions.EnvironmentExecutorCredentialTx, sessions.LockedSession) error) error { tenantID, err := parseID(tenant) if err != nil { @@ -200,13 +200,8 @@ func (s *Store) WithEnvironmentExecutorCredentials(ctx context.Context, tenant, if _, err := parseID(environment); err != nil { return err } - current, err := LoadEnvironment(ctx, s.units.Queries(), tenant, environment) - if err != nil { - return err - } - session := pgunit.PathID(current.SessionID) - return WithSession(ctx, s.units, tenantID, session, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession) error { - return apply(ctx, &environmentCredentialTx{executorCredentialTx: executorCredentialTx{q: q, tenant: tenantID}, session: session}, locked) + return WithEnvironment(ctx, s.units, tenantID, pgunit.PathID(environment), func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession, attached pgtype.UUID) error { + return apply(ctx, &environmentCredentialTx{executorCredentialTx: executorCredentialTx{q: q, tenant: tenantID}, session: attached}, locked) }) } diff --git a/services/core/internal/persistence/postgres/sessionpg/frozen.go b/services/core/internal/persistence/postgres/sessionpg/frozen.go index ad2217270..7c4c1c81c 100644 --- a/services/core/internal/persistence/postgres/sessionpg/frozen.go +++ b/services/core/internal/persistence/postgres/sessionpg/frozen.go @@ -15,26 +15,28 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -// ReadEnvironmentSetup opens the setup the Session froze at creation. Frozen +// ReadEnvironmentSetup opens the setup frozen for the Environment at its +// Session's creation. An Environment whose Session was deleted is +// sessions.ErrNotFound. Frozen // data that does not open, decode or validate is corrupt stored data, never // the caller's input, so it is an internal error. -func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string) (environmentconfig.Setup, error) { - lookup, err := ResourceLookup(tenant, session) +func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, environment string) (environmentconfig.Setup, error) { + lookup, err := ResourceLookup(tenant, environment) if err != nil { return environmentconfig.Setup{}, sessions.ErrNotFound } - encrypted, err := s.units.Queries().GetEnvironmentSetup(ctx, sqlc.GetEnvironmentSetupParams{TenantID: lookup.TenantID, ID: lookup.ID}) - if errors.Is(err, pgx.ErrNoRows) { + row, err := s.units.Queries().GetEnvironmentSetup(ctx, sqlc.GetEnvironmentSetupParams{TenantID: lookup.TenantID, ID: lookup.ID}) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && !row.OwnerSessionID.Valid) { return environmentconfig.Setup{}, sessions.ErrNotFound } if err != nil { return environmentconfig.Setup{}, err } var setup environmentconfig.Setup - if len(encrypted) == 0 { + if len(row.Contents) == 0 { return setup, nil } - plaintext, err := s.cipher.OpenEnvironmentSetup(encrypted, setupBinding(lookup.TenantID, lookup.ID)) + plaintext, err := s.cipher.OpenEnvironmentSetup(row.Contents, setupBinding(lookup.TenantID, row.OwnerSessionID)) if err != nil { return environmentconfig.Setup{}, fmt.Errorf("open frozen environment setup: %w", err) } @@ -44,23 +46,23 @@ func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string return setup, nil } -// ReadInitialEnvironmentFile opens the initial file the Session froze at -// position. A file that does not open or match its recorded size is corrupt +// ReadInitialEnvironmentFile opens the initial file frozen for the +// Environment at position. A file that does not open or match its recorded size is corrupt // stored data, so it is an internal error. -func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, session string, position int) (environmentconfig.InitialFileMetadata, []byte, error) { - lookup, err := ResourceLookup(tenant, session) +func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, environment string, position int) (environmentconfig.InitialFileMetadata, []byte, error) { + lookup, err := ResourceLookup(tenant, environment) if err != nil { return environmentconfig.InitialFileMetadata{}, nil, err } - row, err := s.units.Queries().GetInitialEnvironmentFile(ctx, sqlc.GetInitialEnvironmentFileParams{TenantID: lookup.TenantID, SessionID: lookup.ID, Position: int32(position)}) - if errors.Is(err, pgx.ErrNoRows) { + row, err := s.units.Queries().GetInitialEnvironmentFile(ctx, sqlc.GetInitialEnvironmentFileParams{TenantID: lookup.TenantID, EnvironmentID: lookup.ID, Position: int32(position)}) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && !row.OwnerSessionID.Valid) { return environmentconfig.InitialFileMetadata{}, nil, sessions.ErrNotFound } if err != nil { return environmentconfig.InitialFileMetadata{}, nil, err } id := uuid.UUID(row.ID.Bytes).String() - body, err := s.cipher.OpenEnvironmentFile(row.Contents, fileBinding(lookup.TenantID, lookup.ID, id)) + body, err := s.cipher.OpenEnvironmentFile(row.Contents, fileBinding(lookup.TenantID, row.OwnerSessionID, id)) if err != nil { return environmentconfig.InitialFileMetadata{}, nil, fmt.Errorf("open frozen initial file: %w", err) } diff --git a/services/core/internal/persistence/postgres/sessionpg/rows.go b/services/core/internal/persistence/postgres/sessionpg/rows.go index 621864473..003d807ba 100644 --- a/services/core/internal/persistence/postgres/sessionpg/rows.go +++ b/services/core/internal/persistence/postgres/sessionpg/rows.go @@ -104,20 +104,20 @@ func sessionCreator(kind, id pgtype.Text) (*identity.Subject, error) { // environmentFromRow maps a stored Environment of the tenant with its // normalized configuration snapshot, from the result of the query that read // it: no rows is sessions.ErrNotFound. -func environmentFromRow(row sqlc.Environment, tenant pgtype.UUID, configuration []byte, externalWorkspace bool, err error) (sessions.Environment, error) { +func environmentFromRow(row sqlc.Environment, attached pgtype.UUID, externalWorkspace bool, err error) (sessions.Environment, error) { if errors.Is(err, pgx.ErrNoRows) { return sessions.Environment{}, sessions.ErrNotFound } if err != nil { return sessions.Environment{}, fmt.Errorf("get environment: %w", err) } - configuration, err = jsonobject.Normalize(configuration) + configuration, err := jsonobject.Normalize(row.Configuration) if err != nil { return sessions.Environment{}, fmt.Errorf("decode environment configuration: %w", err) } return sessions.Environment{ - ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), - TenantID: uuid.UUID(tenant.Bytes).String(), Status: row.Status, + ID: uuid.UUID(row.ID.Bytes).String(), AttachedSessionID: optionalID(attached), + TenantID: uuid.UUID(row.TenantID.Bytes).String(), SessionOwned: row.SessionOwned, Status: row.Status, ExternalWorkspace: externalWorkspace, Initialization: row.Initialization, CreatedAt: row.CreatedAt.Time, Configuration: configuration, }, nil } diff --git a/services/core/internal/persistence/postgres/sessionpg/session_reads.go b/services/core/internal/persistence/postgres/sessionpg/session_reads.go index b56526dc9..7581d54b1 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_reads.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_reads.go @@ -291,16 +291,20 @@ func loadSession(ctx context.Context, q *sqlc.Queries, tenant, id pgtype.UUID) ( } // loadSessionActivity adds the Environment, input activity and latest Turn -// projection of GetSession to session within the caller's snapshot. +// projection of GetSession to session within the caller's snapshot, and +// composes the Environment's configuration into the Session's. func loadSessionActivity(ctx context.Context, q *sqlc.Queries, session sessions.Session) (sessions.Session, error) { id, _ := parseID(session.ID) tenant, _ := parseID(session.TenantID) environment, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: id}) if err == nil { - value, err := environmentFromRow(environment.Environment, environment.TenantID, environment.Configuration, environment.ExternalWorkspace, nil) + value, err := environmentFromRow(environment.Environment, environment.AttachedSessionID, environment.ExternalWorkspace, nil) if err != nil { return session, err } + if session.Configuration, err = sessions.ComposeEnvironment(session.Configuration, value.Configuration); err != nil { + return session, err + } session.Environment = &value session.EnvironmentFailure = environmentFailure(environment.Environment) if err := json.Unmarshal(environment.Environment.InitializationSteps, &session.EnvironmentInitializationSteps); err != nil { @@ -311,7 +315,11 @@ func loadSessionActivity(ctx context.Context, q *sqlc.Queries, session sessions. return session, err } session.EnvironmentInputActivity, session.PendingInput = sessions.InputActivity(state) - } else if !errors.Is(err, pgx.ErrNoRows) { + } else if errors.Is(err, pgx.ErrNoRows) { + if session.Configuration, err = sessions.ComposeEnvironment(session.Configuration, nil); err != nil { + return session, err + } + } else { return session, err } row, err := q.GetLatestSessionTurn(ctx, id) @@ -344,3 +352,16 @@ func environmentFailure(row sqlc.Environment) *sessions.EnvironmentFailure { } return failure } + +// environmentSnapshot is the Session's whole configuration snapshot: its +// stored part with its Environment's configuration composed in. +func environmentSnapshot(ctx context.Context, q *sqlc.Queries, tenant, session pgtype.UUID, stored []byte) (json.RawMessage, error) { + row, err := q.GetSessionEnvironment(ctx, sqlc.GetSessionEnvironmentParams{TenantID: tenant, ID: session}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.ComposeEnvironment(stored, nil) + } + if err != nil { + return nil, err + } + return sessions.ComposeEnvironment(stored, row.Environment.Configuration) +} diff --git a/services/core/internal/persistence/postgres/sessionpg/session_writes.go b/services/core/internal/persistence/postgres/sessionpg/session_writes.go index 6dbf4eb9d..9810d078b 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_writes.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_writes.go @@ -63,7 +63,16 @@ func (s *Store) UpdateSession(ctx context.Context, tenantID, sessionID string, e } configuration := current.Configuration if configure != nil { - if configuration, err = configure(current.Engine, configuration); err != nil { + // configure sees the Session's whole snapshot; the Environment's + // part stays with the Environment. + snapshot, err := environmentSnapshot(ctx, q, tenant, current.ID, current.Configuration) + if err != nil { + return err + } + if snapshot, err = configure(current.Engine, snapshot); err != nil { + return err + } + if configuration, _, err = sessions.SplitEnvironment(snapshot); err != nil { return err } } diff --git a/services/core/internal/persistence/postgres/workspacepg/store.go b/services/core/internal/persistence/postgres/workspacepg/store.go index 72de5ffb6..9126fe12c 100644 --- a/services/core/internal/persistence/postgres/workspacepg/store.go +++ b/services/core/internal/persistence/postgres/workspacepg/store.go @@ -191,7 +191,7 @@ func (e *Execution) Bind(ctx context.Context, tenant, environment, configuration var result workspaces.Record err = e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) - deleted, err := lockSession(ctx, q, tenant, environment) + deleted, err := lockEnvironment(ctx, q, tenant, environment) if err != nil { return err } @@ -323,7 +323,7 @@ func (e *Execution) change(ctx context.Context, ref workspacefs.Reference, apply } return e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) - deleted, err := lockSession(ctx, q, ref.TenantID, ref.EnvironmentID) + deleted, err := lockEnvironment(ctx, q, ref.TenantID, ref.EnvironmentID) if err != nil { return err } @@ -338,16 +338,16 @@ func (e *Execution) change(ctx context.Context, ref workspacefs.Reference, apply }) } -func lockSession(ctx context.Context, q *sqlc.Queries, tenant, environment string) (bool, error) { - id, err := q.LockWorkspaceSession(ctx, sqlc.LockWorkspaceSessionParams{TenantID: pgunit.PathID(tenant), ID: pgunit.PathID(environment)}) +func lockEnvironment(ctx context.Context, q *sqlc.Queries, tenant, environment string) (bool, error) { + id, err := q.LockWorkspaceEnvironment(ctx, sqlc.LockWorkspaceEnvironmentParams{TenantID: pgunit.PathID(tenant), ID: pgunit.PathID(environment)}) if errors.Is(err, pgx.ErrNoRows) { return false, workspaces.ErrNotFound } if err != nil { return false, err } - // Read after acquiring the Session lock with a fresh statement snapshot. - return q.WorkspaceSessionDeleted(ctx, id) + // Read after acquiring the Environment lock with a fresh statement snapshot. + return q.WorkspaceEnvironmentReclaimed(ctx, id) } func get(ctx context.Context, q *sqlc.Queries, c credentials, tenant, environment string) (workspaces.Record, error) { diff --git a/services/core/internal/runtimeenrollment/connection.go b/services/core/internal/runtimeenrollment/connection.go index 8377e9cd0..a17cc0e0c 100644 --- a/services/core/internal/runtimeenrollment/connection.go +++ b/services/core/internal/runtimeenrollment/connection.go @@ -81,7 +81,7 @@ func RuntimeConnected(ctx context.Context, s ConnectionStore, registry *runtimeg if current.Status == "failed" || current.Status == "expired" { return false, sessions.ErrNotFound } - bound, err := s.GetSessionDevice(ctx, tenant, current.SessionID) + bound, err := s.GetSessionDevice(ctx, tenant, current.AttachedSessionID) if errors.Is(err, sessions.ErrNotFound) { return false, nil } diff --git a/services/core/internal/runtimeenrollment/connection_test.go b/services/core/internal/runtimeenrollment/connection_test.go index 36eec4a0a..b80c865bf 100644 --- a/services/core/internal/runtimeenrollment/connection_test.go +++ b/services/core/internal/runtimeenrollment/connection_test.go @@ -29,7 +29,7 @@ func (s *connectionStub) AuthenticateEnvironmentExecutor(_ context.Context, envi return "tenant", s.err } func (*connectionStub) GetEnvironment(context.Context, string, string) (sessions.Environment, error) { - return sessions.Environment{ID: "environment", SessionID: "session", Status: "pending"}, nil + return sessions.Environment{ID: "environment", AttachedSessionID: "session", Status: "pending"}, nil } func (*connectionStub) GetSessionDevice(context.Context, string, string) (sessions.ExecutionDevice, error) { return sessions.ExecutionDevice{}, sessions.ErrNotFound @@ -95,7 +95,7 @@ func (s *liveConnectionStore) AuthenticateEnvironmentExecutor(context.Context, s return "tenant", nil } func (s *liveConnectionStore) GetEnvironment(context.Context, string, string) (sessions.Environment, error) { - return sessions.Environment{ID: "environment", SessionID: "session", Status: "connected"}, nil + return sessions.Environment{ID: "environment", AttachedSessionID: "session", Status: "connected"}, nil } func (s *liveConnectionStore) GetSessionDevice(context.Context, string, string) (sessions.ExecutionDevice, error) { return sessions.ExecutionDevice{ID: "device", EnvironmentID: "environment"}, nil diff --git a/services/core/internal/runtimehistory/service.go b/services/core/internal/runtimehistory/service.go index 2a8be7b60..6ac557e87 100644 --- a/services/core/internal/runtimehistory/service.go +++ b/services/core/internal/runtimehistory/service.go @@ -93,7 +93,7 @@ func (s *Service) resolveScope(ctx context.Context, tenantID, sessionID string) if err != nil { return Scope{}, fmt.Errorf("resolve Runtime history Environment: %w", err) } - if environment.TenantID != tenantID || environment.SessionID != sessionID { + if environment.TenantID != tenantID || environment.AttachedSessionID != sessionID { return Scope{}, errors.New("Runtime history Environment does not match resolved ownership") } var configuration struct { diff --git a/services/core/internal/runtimehistory/service_test.go b/services/core/internal/runtimehistory/service_test.go index 390b1323c..87df8348a 100644 --- a/services/core/internal/runtimehistory/service_test.go +++ b/services/core/internal/runtimehistory/service_test.go @@ -58,7 +58,7 @@ func sessionEnvironment(t testing.TB, environment sessions.Environment, err erro // hosted reads the hosted Environment of the Session the tests query. func hosted(t testing.TB) *fakeEnvironmentReader { - return sessionEnvironment(t, sessions.Environment{ID: environmentID, TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":"openai_hosted"}`)}, nil) + return sessionEnvironment(t, sessions.Environment{ID: environmentID, TenantID: tenantID, AttachedSessionID: sessionID, Configuration: []byte(`{"type":"openai_hosted"}`)}, nil) } type fakeReader struct { @@ -191,18 +191,18 @@ func TestServiceRejectsInvalidRangeAndResolverIdentity(t *testing.T) { t.Fatalf("invalid range reached reader: %+v", reader.queries) } for _, environment := range []sessions.Environment{ - {ID: environmentID, TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":"self_hosted"}`)}, - {ID: environmentID, TenantID: "55555555-5555-4555-8555-555555555555", SessionID: sessionID, Configuration: []byte(`{"type":"openai_hosted"}`)}, - {ID: environmentID, TenantID: tenantID, SessionID: "66666666-6666-4666-8666-666666666666", Configuration: []byte(`{"type":"openai_hosted"}`)}, - {ID: environmentID, TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":`)}, - {ID: "environment", TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":"openai_hosted"}`)}, + {ID: environmentID, TenantID: tenantID, AttachedSessionID: sessionID, Configuration: []byte(`{"type":"self_hosted"}`)}, + {ID: environmentID, TenantID: "55555555-5555-4555-8555-555555555555", AttachedSessionID: sessionID, Configuration: []byte(`{"type":"openai_hosted"}`)}, + {ID: environmentID, TenantID: tenantID, AttachedSessionID: "66666666-6666-4666-8666-666666666666", Configuration: []byte(`{"type":"openai_hosted"}`)}, + {ID: environmentID, TenantID: tenantID, AttachedSessionID: sessionID, Configuration: []byte(`{"type":`)}, + {ID: "environment", TenantID: tenantID, AttachedSessionID: sessionID, Configuration: []byte(`{"type":"openai_hosted"}`)}, } { service.environments = sessionEnvironment(t, environment, nil) if _, err := service.QuerySession(t.Context(), tenantID, sessionID, Range{Start: now.Add(-time.Hour), End: now, MaxPoints: 60}); err == nil || len(reader.queries) != 0 { t.Fatalf("unsafe Runtime history Environment reached reader: %+v", environment) } } - service.environments = sessionEnvironment(t, sessions.Environment{ID: environmentID, TenantID: tenantID, SessionID: sessionID, Configuration: []byte(`{"type":"self_hosted"}`)}, nil) + service.environments = sessionEnvironment(t, sessions.Environment{ID: environmentID, TenantID: tenantID, AttachedSessionID: sessionID, Configuration: []byte(`{"type":"self_hosted"}`)}, nil) if _, err := service.QuerySession(t.Context(), tenantID, sessionID, Range{Start: now.Add(-time.Hour), End: now, MaxPoints: 60}); !errors.Is(err, ErrUnsupported) { t.Fatalf("self-hosted history was not unsupported: %v", err) } diff --git a/services/core/internal/sessions/creation.go b/services/core/internal/sessions/creation.go index 615691e68..f2f9367a4 100644 --- a/services/core/internal/sessions/creation.go +++ b/services/core/internal/sessions/creation.go @@ -88,16 +88,17 @@ type CreationTx interface { SaveExecutionConfiguration(ctx context.Context, projection v1.SessionExecutionConfiguration, revision uuid.UUID) error // SaveInitialFiles copies the validated initial files, a file_id one from // its locked source File, seals them under the Session and records their - // metadata in its configuration. A missing source is ErrNotFound and one + // metadata in the Environment's configuration. A missing source is ErrNotFound and one // beyond environmentconfig.MaxInitialFileBytes ErrInvalidInput. SaveInitialFiles(ctx context.Context, files []environmentconfig.InitialFile) error // SaveSetup seals the installed setup under the Session and records its - // metadata in its configuration. + // metadata in the Environment's configuration. SaveSetup(ctx context.Context, setup environmentconfig.Setup) error - // CreateEnvironment creates the Session's Environment, pending - // preparation when the Session froze a setup or initial files, and - // returns its ID. - CreateEnvironment(ctx context.Context) (string, error) + // CreateEnvironment creates the Session's Environment with the + // configuration, pending preparation when prepared, attaches it to the + // Session and returns its ID. It precedes the setup and initial files, + // which are saved for it. + CreateEnvironment(ctx context.Context, configuration json.RawMessage, prepared bool) (string, error) // CreateInputReservation reserves the batch under key until the deadline // the database clock sets, marked as the Session's initial input and // bound to settings. @@ -129,6 +130,9 @@ func (s *Service) CreateSession(ctx context.Context, tenant string, input Create } var created []writeaudit.Resource if upserted.Created { + // The stored row holds none of the Environment; the snapshot the + // caller sent is the Session's full configuration. + upserted.Session.Configuration = session.Configuration if created, err = s.createResources(ctx, tx, upserted.Session, input, batch, encoded); err != nil { return err } @@ -190,6 +194,23 @@ func (s *Service) createResources(ctx context.Context, tx CreationTx, session Se return nil, err } } + created := []writeaudit.Resource{{Type: writeaudit.ResourceSession, ID: session.ID}} + creates, err := createsEnvironment(session.Configuration) + if err != nil { + return nil, err + } + environment := "" + if creates { + _, configuration, err := SplitEnvironment(session.Configuration) + if err != nil { + return nil, err + } + prepared := hosted || len(input.InitialFiles) > 0 || !setup.Empty() + if environment, err = tx.CreateEnvironment(ctx, configuration, prepared); err != nil { + return nil, err + } + created = append(created, writeaudit.Resource{Type: writeaudit.ResourceEnvironment, ID: environment, ParentID: session.ID}) + } if len(input.InitialFiles) > 0 { if environmentconfig.ValidateInitialFiles(input.InitialFiles) != nil { return nil, ErrInvalidInput @@ -203,18 +224,6 @@ func (s *Service) createResources(ctx context.Context, tx CreationTx, session Se return nil, err } } - created := []writeaudit.Resource{{Type: writeaudit.ResourceSession, ID: session.ID}} - creates, err := createsEnvironment(session.Configuration) - if err != nil { - return nil, err - } - environment := "" - if creates { - if environment, err = tx.CreateEnvironment(ctx); err != nil { - return nil, err - } - created = append(created, writeaudit.Resource{Type: writeaudit.ResourceEnvironment, ID: environment, ParentID: session.ID}) - } if hosted { if err := s.rules.CheckPublicOrigin(deployment.Provider); err != nil { return nil, err diff --git a/services/core/internal/sessions/creation_test.go b/services/core/internal/sessions/creation_test.go index b40fa412c..9d45582ac 100644 --- a/services/core/internal/sessions/creation_test.go +++ b/services/core/internal/sessions/creation_test.go @@ -85,7 +85,7 @@ func (f *fakeCreationTx) SaveSetup(_ context.Context, setup environmentconfig.Se return f.saveSetup() } -func (f *fakeCreationTx) CreateEnvironment(context.Context) (string, error) { +func (f *fakeCreationTx) CreateEnvironment(context.Context, json.RawMessage, bool) (string, error) { f.record("CreateEnvironment", f.createEnvironment != nil) return f.createEnvironment() } @@ -411,7 +411,7 @@ func TestCreateSession(t *testing.T) { t.Fatalf("result %+v, %v", result, err) } want := []string{"UpsertSession create", "LockSkills skill_a", "ReadSkillVersion skill_a 3", "SaveModelExecution https://model.example/v1", - "SaveExecutionConfiguration available " + uuid.Nil.String(), "SaveInitialFiles 1", "SaveSetup skill_a@3", "CreateEnvironment", + "SaveExecutionConfiguration available " + uuid.Nil.String(), "CreateEnvironment", "SaveInitialFiles 1", "SaveSetup skill_a@3", "LoadEnvironmentInput", `CreateInputReservation [{"kind":"message","payload":` + hi + `}] initial`, "LoadEnvironmentInput", "PruneChanges", "AuditCreation session:session environment:environment:session", "LoadSession"} if strings.Join(calls, "\n") != strings.Join(want, "\n") { diff --git a/services/core/internal/sessions/environment.go b/services/core/internal/sessions/environment.go index 78fb7afb5..c56ce7b33 100644 --- a/services/core/internal/sessions/environment.go +++ b/services/core/internal/sessions/environment.go @@ -25,15 +25,15 @@ type EnvironmentReader interface { // given Environment, a page of the live Environments whose preparation is // pending or running. ListEnvironmentInitializations(ctx context.Context, after string) ([]EnvironmentInitialization, error) - // ReadEnvironmentSetup opens the setup frozen for the Session's - // Environment. A missing Session is ErrNotFound; frozen data that does not + // ReadEnvironmentSetup opens the setup frozen for the Environment. A + // missing Environment or one whose Session was deleted is ErrNotFound; frozen data that does not // open or validate is an internal error. - ReadEnvironmentSetup(ctx context.Context, tenant, session string) (environmentconfig.Setup, error) + ReadEnvironmentSetup(ctx context.Context, tenant, environment string) (environmentconfig.Setup, error) // ReadInitialEnvironmentFile opens the initial file frozen at position for - // the Session's Environment, so an installation holds one file at a time. + // the Environment, so an installation holds one file at a time. // A missing file is ErrNotFound; a file that does not open or match its // recorded size is an internal error. - ReadInitialEnvironmentFile(ctx context.Context, tenant, session string, position int) (environmentconfig.InitialFileMetadata, []byte, error) + ReadInitialEnvironmentFile(ctx context.Context, tenant, environment string, position int) (environmentconfig.InitialFileMetadata, []byte, error) } // createsEnvironment reports whether a Session created with the configuration @@ -59,14 +59,67 @@ func createsEnvironment(configuration json.RawMessage) (bool, error) { } } +// SplitEnvironment separates a Session configuration snapshot into the part +// the Session stores and the configuration of its Environment, which is nil +// when the snapshot names none or the type none. +func SplitEnvironment(configuration json.RawMessage) (session, environment json.RawMessage, err error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(configuration, &fields); err != nil { + return nil, nil, fmt.Errorf("%w: invalid environment configuration", ErrInvalidInput) + } + environment = fields["environment"] + delete(fields, "environment") + if session, err = json.Marshal(fields); err != nil { + return nil, nil, err + } + if environment, err = normalizeEnvironment(environment); err != nil { + return nil, nil, err + } + return session, environment, nil +} + +// ComposeEnvironment is the Session configuration snapshot of its stored part +// and its Environment's configuration; none is the type none. +func ComposeEnvironment(session, environment json.RawMessage) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(session, &fields); err != nil { + return nil, fmt.Errorf("decode session configuration: %w", err) + } + if len(environment) == 0 { + environment = json.RawMessage(`{"type":"none"}`) + } + fields["environment"] = environment + return json.Marshal(fields) +} + +// normalizeEnvironment returns nil for an absent or none Environment. +func normalizeEnvironment(environment json.RawMessage) (json.RawMessage, error) { + if len(environment) == 0 { + return nil, nil + } + var typed struct { + Type string `json:"type"` + } + if err := json.Unmarshal(environment, &typed); err != nil { + return nil, fmt.Errorf("%w: invalid environment configuration", ErrInvalidInput) + } + if typed.Type == "none" || string(environment) == "null" { + return nil, nil + } + return environment, nil +} + // Environment retains execution ownership; its configuration is an internal snapshot, not a public response. type Environment struct { // ExternalWorkspace is a read projection of the immutable filesystem binding, not a setting. ExternalWorkspace bool Initialization string ID string - SessionID string TenantID string + // SessionOwned reports that the Environment is reclaimed with its Session. + SessionOwned bool + // AttachedSessionID is the live Session using the Environment, or empty. + AttachedSessionID string Status string CreatedAt time.Time Configuration json.RawMessage @@ -75,7 +128,7 @@ type Environment struct { // EnvironmentInitialization owns preparation independently of compute ownership. // A running record without its process-local owner is unknown, never replayable. type EnvironmentInitialization struct { - EnvironmentID, SessionID, TenantID, DeviceID, State, Engine string + EnvironmentID, TenantID, DeviceID, State string } // EnvironmentInputActivity is the reservation-owned override before a newer Turn exists. diff --git a/services/core/internal/sessions/environment_termination.go b/services/core/internal/sessions/environment_termination.go index b7225a44e..25978da74 100644 --- a/services/core/internal/sessions/environment_termination.go +++ b/services/core/internal/sessions/environment_termination.go @@ -76,6 +76,9 @@ func FailEnvironment(ctx context.Context, tx EnvironmentFailureTx, environment E if err != nil { return err } + if environment.AttachedSessionID == "" { + return nil + } if err := tx.AppendChanges(ctx, EnvironmentStateChange(environment.ID, kind, "failed")); err != nil { return err } diff --git a/services/core/internal/sessions/environment_termination_test.go b/services/core/internal/sessions/environment_termination_test.go index 4f86127c2..3304f2f99 100644 --- a/services/core/internal/sessions/environment_termination_test.go +++ b/services/core/internal/sessions/environment_termination_test.go @@ -61,7 +61,7 @@ func TestFailEnvironment(t *testing.T) { failedAt := time.Unix(1700000000, 0) settled := &EnvironmentInputState{State: EnvironmentInputFailed, EnvironmentType: "openai_hosted", SettledAt: failedAt} usage := json.RawMessage(`{"input_tokens":1}`) - environment := Environment{ID: "environment", Configuration: json.RawMessage(`{"type":"openai_hosted"}`)} + environment := Environment{ID: "environment", AttachedSessionID: "session", Configuration: json.RawMessage(`{"type":"openai_hosted"}`)} var changes []SessionChange f := &fakeTx{t: t, recordEnvironmentFailure: returns(failedAt), appendChanges: collect(&changes), failPendingInput: done, @@ -86,8 +86,14 @@ func TestFailEnvironment(t *testing.T) { t.Fatalf("changes %+v", changes) } + unattached := &fakeTx{t: t, recordEnvironmentFailure: returns(failedAt)} + if err := FailEnvironment(t.Context(), unattached, Environment{ID: "environment", Configuration: json.RawMessage(`{"type":"openai_hosted"}`)}, "reason", nil); err != nil { + t.Fatal(err) + } + assertCalls(t, unattached, "RecordEnvironmentFailure environment reason") + invalid := &fakeTx{t: t, recordEnvironmentFailure: returns(failedAt)} - if err := FailEnvironment(t.Context(), invalid, Environment{ID: "environment", Configuration: json.RawMessage(`{}`)}, "reason", nil); err == nil { + if err := FailEnvironment(t.Context(), invalid, Environment{ID: "environment", AttachedSessionID: "session", Configuration: json.RawMessage(`{}`)}, "reason", nil); err == nil { t.Fatal("invalid Environment type failed") } assertCalls(t, invalid, "RecordEnvironmentFailure environment reason") @@ -95,7 +101,7 @@ func TestFailEnvironment(t *testing.T) { func TestTerminateEnvironment(t *testing.T) { configuration := json.RawMessage(`{"type":"openai_hosted"}`) - connected := Environment{ID: "environment", Status: "connected", Configuration: configuration} + connected := Environment{ID: "environment", AttachedSessionID: "session", Status: "connected", Configuration: configuration} var changes []SessionChange live := &fakeTx{t: t, loadEnvironment: returns(connected), recordEnvironmentFailure: returns(time.Unix(1700000000, 0)), appendChanges: collect(&changes), @@ -123,7 +129,7 @@ func TestTerminateEnvironment(t *testing.T) { assertCalls(t, expired, "LoadEnvironment", "LoadEnvironmentInput", "ExpireEnvironment environment", "FailPendingInput", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput") terminal := &fakeTx{t: t, - loadEnvironment: returns(Environment{ID: "environment", Status: "failed", Configuration: configuration}), + loadEnvironment: returns(Environment{ID: "environment", AttachedSessionID: "session", Status: "failed", Configuration: configuration}), loadEnvironmentInput: inputs(), failPendingInput: done, loadActiveTurn: activeTurn(nil), cancelPendingInput: done, } if err := TerminateEnvironment(t.Context(), terminal, false, "reason", nil); err != nil { diff --git a/services/core/internal/sessions/execution_environment.go b/services/core/internal/sessions/execution_environment.go index bee1eac57..b687c6517 100644 --- a/services/core/internal/sessions/execution_environment.go +++ b/services/core/internal/sessions/execution_environment.go @@ -21,13 +21,12 @@ type EnvironmentConnection struct { // execution operations. type EnvironmentExecution interface { // WithInitialization runs apply in the transaction of the tenant's - // Session, with what the Session lock shows. A malformed Session ID is a - // missing Session. - WithInitialization(ctx context.Context, tenant, session string, apply func(context.Context, InitializationTx, LockedSession) error) error - // WithConnection runs apply in the transaction of the Session that owns - // the tenant's Environment, with what the Session lock shows. An - // Environment of a publicly deleted Session, or a missing one, is - // ErrNotFound. + // Environment, with what the locks show. A malformed Environment ID is a + // missing Environment. + WithInitialization(ctx context.Context, tenant, environment string, apply func(context.Context, InitializationTx, LockedSession) error) error + // WithConnection runs apply in the transaction of the tenant's + // Environment, with what the locks show. An Environment of a publicly + // deleted Session, or a missing one, is ErrNotFound. WithConnection(ctx context.Context, tenant, environment string, apply func(context.Context, ConnectionTx, LockedSession) error) error // WithDeviceBinding runs apply in the transaction of the tenant's Session, // with what the Session lock shows. @@ -49,15 +48,15 @@ type EnvironmentExecution interface { ListEnvironmentConnections(ctx context.Context, after string) ([]EnvironmentKey, error) } -// InitializationTx is the Session transaction the Environment initialization +// InitializationTx is the Environment transaction the initialization // operations run in. type InitializationTx interface { EnvironmentFailureTx - // LoadEnvironment reads the Session's Environment. + // LoadEnvironment reads the Environment. LoadEnvironment(ctx context.Context) (Environment, error) - // LoadSessionDevice reads the Runtime device bound to the Session and - // reports whether the Session has one that still has authority. - LoadSessionDevice(ctx context.Context) (ExecutionDevice, bool, error) + // LoadEnvironmentDevice reads the Runtime device of the Environment and + // reports whether it has one that still has authority. + LoadEnvironmentDevice(ctx context.Context) (ExecutionDevice, bool, error) // ClaimInitialization starts the pending preparation of the live // Environment and reports whether it did. ClaimInitialization(ctx context.Context, environment string) (bool, error) @@ -72,11 +71,11 @@ type InitializationTx interface { FailInitialization(ctx context.Context, environment string) error } -// ConnectionTx is the Session transaction the Environment connection -// operations run in. +// ConnectionTx is the Environment transaction the connection operations run +// in. type ConnectionTx interface { InputActivityTx - // LoadEnvironment reads the Session's Environment. + // LoadEnvironment reads the Environment. LoadEnvironment(ctx context.Context) (Environment, error) // LoadConnection reads the Environment's connection generation and // reports whether it has one. @@ -111,7 +110,7 @@ func terminalEnvironment(status string) bool { } // checkInitializationOwner confirms that the claim of a listed initialization -// still names the Session's live Environment: another Environment is +// still names the live Environment: another Environment is // ErrDeviceBindingConflict, and a failed or expired one ErrNotFound. func checkInitializationOwner(owner EnvironmentInitialization, environment Environment) error { if environment.ID != owner.EnvironmentID { @@ -124,8 +123,8 @@ func checkInitializationOwner(owner EnvironmentInitialization, environment Envir } // checkInitializationDevice confirms that the device the initialization was -// listed with is still the Session's device for this Environment. A Session -// without a device is ErrNotFound and another binding ErrTurnConflict. +// listed with is still the Environment's device. An Environment without a +// device is ErrNotFound and another one ErrTurnConflict. func checkInitializationDevice(owner EnvironmentInitialization, environment Environment, bound ExecutionDevice, found bool) error { if !found { return ErrNotFound @@ -136,10 +135,10 @@ func checkInitializationDevice(owner EnvironmentInitialization, environment Envi return nil } -// withInitialization runs apply on the Session's live Environment that owner -// names, in the transaction of its Session. +// withInitialization runs apply on the live Environment that owner names, in +// its transaction. func (o *ExecutionOperations) withInitialization(ctx context.Context, owner EnvironmentInitialization, apply func(context.Context, InitializationTx, Environment) error) error { - return o.storage.WithInitialization(ctx, owner.TenantID, owner.SessionID, func(ctx context.Context, tx InitializationTx, locked LockedSession) error { + return o.storage.WithInitialization(ctx, owner.TenantID, owner.EnvironmentID, func(ctx context.Context, tx InitializationTx, locked LockedSession) error { if err := locked.Public(); err != nil { return err } @@ -159,7 +158,7 @@ func (o *ExecutionOperations) withInitialization(ctx context.Context, owner Envi // applied; one that did not is ErrTurnConflict. func (o *ExecutionOperations) advanceInitialization(ctx context.Context, owner EnvironmentInitialization, transition func(context.Context, InitializationTx, Environment) (bool, error)) error { return o.withInitialization(ctx, owner, func(ctx context.Context, tx InitializationTx, environment Environment) error { - bound, found, err := tx.LoadSessionDevice(ctx) + bound, found, err := tx.LoadEnvironmentDevice(ctx) if err != nil { return err } @@ -199,7 +198,7 @@ func journalInitialization(ctx context.Context, tx InitializationTx, environment return false, err } applied, err := transition(ctx, environment.ID) - if err != nil || !applied || kind != "openai_hosted" { + if err != nil || !applied || kind != "openai_hosted" || environment.AttachedSessionID == "" { return applied, err } return true, tx.AppendChanges(ctx, EnvironmentStateChange(environment.ID, kind, status)) @@ -247,8 +246,8 @@ func connectionStatus(connected bool) string { return "disconnected" } -// withConnection runs apply on the Environment in the transaction of its -// Session, and reports the input activity that apply changes. +// withConnection runs apply on the Environment in its transaction, and +// reports the input activity that apply changes to the attached Session. func (o *ExecutionOperations) withConnection(ctx context.Context, tenant, environment string, apply func(context.Context, ConnectionTx, Environment) error) error { return o.storage.WithConnection(ctx, tenant, environment, func(ctx context.Context, tx ConnectionTx, locked LockedSession) error { if err := locked.Public(); err != nil { @@ -258,6 +257,9 @@ func (o *ExecutionOperations) withConnection(ctx context.Context, tenant, enviro if err != nil { return err } + if current.AttachedSessionID == "" { + return apply(ctx, tx, current) + } return TrackInputActivity(ctx, tx, func(ctx context.Context) error { return apply(ctx, tx, current) }) }) } @@ -272,6 +274,9 @@ func recordConnection(ctx context.Context, tx ConnectionTx, environment Environm if err := tx.SetConnectionStatus(ctx, environment.ID, status); err != nil { return err } + if environment.AttachedSessionID == "" { + return nil + } return tx.AppendChanges(ctx, EnvironmentStateChange(environment.ID, kind, status)) } diff --git a/services/core/internal/sessions/execution_environment_test.go b/services/core/internal/sessions/execution_environment_test.go index f8af313ca..9507b0442 100644 --- a/services/core/internal/sessions/execution_environment_test.go +++ b/services/core/internal/sessions/execution_environment_test.go @@ -9,9 +9,9 @@ import ( "time" ) -func (f *fakeTx) LoadSessionDevice(context.Context) (ExecutionDevice, bool, error) { - f.record("LoadSessionDevice", f.loadSessionDevice != nil) - return f.loadSessionDevice() +func (f *fakeTx) LoadEnvironmentDevice(context.Context) (ExecutionDevice, bool, error) { + f.record("LoadEnvironmentDevice", f.loadEnvironmentDevice != nil) + return f.loadEnvironmentDevice() } func (f *fakeTx) ClaimInitialization(_ context.Context, environment string) (bool, error) { @@ -130,7 +130,7 @@ func executionOperations(t *testing.T, storage *fakeExecutionStorage) *Execution return operations } -var hostedEnvironment = Environment{ID: "environment", SessionID: "session", Status: "pending", Configuration: []byte(`{"type":"openai_hosted"}`)} +var hostedEnvironment = Environment{ID: "environment", AttachedSessionID: "session", Status: "pending", Configuration: []byte(`{"type":"openai_hosted"}`)} func TestInitializationOwnerAndDeviceRules(t *testing.T) { owner := EnvironmentInitialization{EnvironmentID: "environment", DeviceID: "device"} @@ -141,7 +141,7 @@ func TestInitializationOwnerAndDeviceRules(t *testing.T) { }{ {"live", Environment{ID: "environment", Status: "pending"}, nil}, {"another Environment", Environment{ID: "other", Status: "pending"}, ErrDeviceBindingConflict}, - {"failed", Environment{ID: "environment", Status: "failed"}, ErrNotFound}, + {"failed", Environment{ID: "environment", AttachedSessionID: "session", Status: "failed"}, ErrNotFound}, {"expired", Environment{ID: "environment", Status: "expired"}, ErrNotFound}, } { if err := checkInitializationOwner(owner, test.environment); !errors.Is(err, test.want) { @@ -168,11 +168,11 @@ func TestInitializationOwnerAndDeviceRules(t *testing.T) { func TestInitializationTransitionsRunUnderTheListedDevice(t *testing.T) { var journaled []SessionChange - owner := EnvironmentInitialization{TenantID: "tenant", SessionID: "session", EnvironmentID: "environment", DeviceID: "device"} + owner := EnvironmentInitialization{TenantID: "tenant", EnvironmentID: "environment", DeviceID: "device"} listed := func() (ExecutionDevice, bool, error) { return ExecutionDevice{ID: "device", EnvironmentID: "environment"}, true, nil } - checked := []string{"WithInitialization tenant session", "LoadEnvironment", "LoadSessionDevice"} + checked := []string{"WithInitialization tenant environment", "LoadEnvironment", "LoadEnvironmentDevice"} for _, test := range []struct { name string tx fakeTx @@ -181,25 +181,25 @@ func TestInitializationTransitionsRunUnderTheListedDevice(t *testing.T) { want error calls []string }{ - {"claim", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: listed, claimInitialization: returns(true), appendChanges: collect(&journaled)}, + {"claim", fakeTx{loadEnvironment: returns(hostedEnvironment), loadEnvironmentDevice: listed, claimInitialization: returns(true), appendChanges: collect(&journaled)}, LockedSession{}, func(o *ExecutionOperations) error { return o.ClaimEnvironmentInitialization(t.Context(), owner) }, nil, append(checked, "ClaimInitialization environment", "AppendChanges agent.session.environment.pending")}, - {"claim of a self-hosted Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", Status: "pending", Configuration: []byte(`{"type":"self_hosted"}`)}), loadSessionDevice: listed, claimInitialization: returns(true)}, + {"claim of a self-hosted Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", Status: "pending", Configuration: []byte(`{"type":"self_hosted"}`)}), loadEnvironmentDevice: listed, claimInitialization: returns(true)}, LockedSession{}, func(o *ExecutionOperations) error { return o.ClaimEnvironmentInitialization(t.Context(), owner) }, nil, append(checked, "ClaimInitialization environment")}, - {"claim that did not apply", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: listed, claimInitialization: returns(false)}, + {"claim that did not apply", fakeTx{loadEnvironment: returns(hostedEnvironment), loadEnvironmentDevice: listed, claimInitialization: returns(false)}, LockedSession{}, func(o *ExecutionOperations) error { return o.ClaimEnvironmentInitialization(t.Context(), owner) }, ErrTurnConflict, append(checked, "ClaimInitialization environment")}, - {"complete", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: listed, completeInitialization: returns(true), appendChanges: collect(&journaled)}, + {"complete", fakeTx{loadEnvironment: returns(hostedEnvironment), loadEnvironmentDevice: listed, completeInitialization: returns(true), appendChanges: collect(&journaled)}, LockedSession{}, func(o *ExecutionOperations) error { return o.CompleteEnvironmentInitialization(t.Context(), owner) }, nil, append(checked, "CompleteInitialization environment", "AppendChanges agent.session.environment.ready")}, - {"device moved", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: loads(ExecutionDevice{ID: "other", EnvironmentID: "environment"}, true)}, + {"device moved", fakeTx{loadEnvironment: returns(hostedEnvironment), loadEnvironmentDevice: loads(ExecutionDevice{ID: "other", EnvironmentID: "environment"}, true)}, LockedSession{}, func(o *ExecutionOperations) error { return o.CompleteEnvironmentInitialization(t.Context(), owner) }, ErrTurnConflict, checked}, {"deleted Session", fakeTx{}, LockedSession{Deleted: true}, func(o *ExecutionOperations) error { return o.ClaimEnvironmentInitialization(t.Context(), owner) }, ErrNotFound, checked[:1]}, - {"failed Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", Status: "failed"})}, + {"failed Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", AttachedSessionID: "session", Status: "failed"})}, LockedSession{}, func(o *ExecutionOperations) error { return o.ClaimEnvironmentInitialization(t.Context(), owner) }, ErrNotFound, checked[:2]}, } { @@ -216,8 +216,8 @@ func TestInitializationTransitionsRunUnderTheListedDevice(t *testing.T) { } func TestFailEnvironmentInitializationKeepsACompletedPreparation(t *testing.T) { - owner := EnvironmentInitialization{TenantID: "tenant", SessionID: "session", EnvironmentID: "environment"} - failure := ProvisioningFailure{Step: ProvisioningHarness} + owner := EnvironmentInitialization{TenantID: "tenant", EnvironmentID: "environment"} + failure := ProvisioningFailure{Step: ProvisioningPythonPackages} complete := hostedEnvironment complete.Initialization = "complete" @@ -226,7 +226,7 @@ func TestFailEnvironmentInitializationKeepsACompletedPreparation(t *testing.T) { if !errors.Is(err, ErrTurnConflict) { t.Fatalf("completed preparation failed: %v", err) } - assertCalls(t, &tx, "WithInitialization tenant session", "LoadEnvironment") + assertCalls(t, &tx, "WithInitialization tenant environment", "LoadEnvironment") // The preparation fails before the Environment does; the device is not // consulted. @@ -236,7 +236,7 @@ func TestFailEnvironmentInitializationKeepsACompletedPreparation(t *testing.T) { if !errors.Is(err, errStorage) { t.Fatal(err) } - assertCalls(t, &tx, "WithInitialization tenant session", "LoadEnvironment", "FailInitialization environment", + assertCalls(t, &tx, "WithInitialization tenant environment", "LoadEnvironment", "FailInitialization environment", "RecordEnvironmentFailure environment "+failure.Reason()) } @@ -269,7 +269,7 @@ func TestConnectionGenerationsAreOrdered(t *testing.T) { return o.ReplaceEnvironmentConnection(t.Context(), "tenant", "environment", generation) }, nil, tracked("LoadConnection environment")}, - {"replacement of a failed Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", Status: "failed"}), loadEnvironmentInput: quietInput}, LockedSession{}, + {"replacement of a failed Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", AttachedSessionID: "session", Status: "failed"}), loadEnvironmentInput: quietInput}, LockedSession{}, func(o *ExecutionOperations) error { return o.ReplaceEnvironmentConnection(t.Context(), "tenant", "environment", generation) }, @@ -294,7 +294,7 @@ func TestConnectionGenerationsAreOrdered(t *testing.T) { return o.ObserveEnvironmentConnection(t.Context(), "tenant", "environment", generation, 5, false) }, nil, tracked("LoadConnection environment")}, - {"observation of a failed Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", Status: "failed"}), loadEnvironmentInput: quietInput, loadConnection: loads(EnvironmentConnection{Generation: generation, Revision: 1}, true)}, LockedSession{}, + {"observation of a failed Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", AttachedSessionID: "session", Status: "failed"}), loadEnvironmentInput: quietInput, loadConnection: loads(EnvironmentConnection{Generation: generation, Revision: 1}, true)}, LockedSession{}, func(o *ExecutionOperations) error { return o.ObserveEnvironmentConnection(t.Context(), "tenant", "environment", generation, 2, true) }, diff --git a/services/core/internal/sessions/execution_file_writes.go b/services/core/internal/sessions/execution_file_writes.go index 3546d78af..9a7c4fefe 100644 --- a/services/core/internal/sessions/execution_file_writes.go +++ b/services/core/internal/sessions/execution_file_writes.go @@ -20,11 +20,11 @@ type FileWriteReservationTx interface { FileWriteTx ComputeAdmissionTx InputStartTx - // LoadEnvironment reads the Session's Environment. + // LoadEnvironment reads the Environment. LoadEnvironment(ctx context.Context) (Environment, error) - // LoadSessionDevice reads the Runtime device bound to the Session and - // reports whether the Session has one that still has authority. - LoadSessionDevice(ctx context.Context) (ExecutionDevice, bool, error) + // LoadEnvironmentDevice reads the Runtime device of the Environment and + // reports whether it has one that still has authority. + LoadEnvironmentDevice(ctx context.Context) (ExecutionDevice, bool, error) // LoadPendingInput reports whether Environment input reserved for the // Session is pending. LoadPendingInput(ctx context.Context) (bool, error) @@ -33,7 +33,7 @@ type FileWriteReservationTx interface { CreateFileWrite(ctx context.Context, key FileWriteIdentity) (EnvironmentFileWrite, error) } -// FileWriteSettlementTx is the Session transaction a file write is settled in. +// FileWriteSettlementTx is the Environment transaction a file write is settled in. type FileWriteSettlementTx interface { FileWriteTx // SettleFileWrite settles the pending write in the state and returns it. @@ -89,10 +89,10 @@ func (o *ExecutionOperations) ReserveEnvironmentFileWrite(ctx context.Context, t return result, nil } -// admitFileWrite admits a new write to the Session's live Environment through -// the device the key names, dedicated to that Environment, while the Session -// is idle with no pending input. A failed or expired Environment is -// ErrInvalidInput, a Session without a device ErrNotFound, another device +// admitFileWrite admits a new write to the live Environment through the +// device the key names, dedicated to that Environment, while its attached +// Session, if any, is idle with no pending input. A failed or expired +// Environment is ErrInvalidInput, an Environment without a device ErrNotFound, another device // ErrDeviceBindingConflict, and work in progress ErrTurnConflict. func admitFileWrite(ctx context.Context, tx FileWriteReservationTx, owner Environment, key FileWriteIdentity) error { current, err := tx.LoadEnvironment(ctx) @@ -102,7 +102,7 @@ func admitFileWrite(ctx context.Context, tx FileWriteReservationTx, owner Enviro if current.ID != owner.ID || terminalEnvironment(current.Status) { return ErrInvalidInput } - bound, found, err := tx.LoadSessionDevice(ctx) + bound, found, err := tx.LoadEnvironmentDevice(ctx) if err != nil { return err } diff --git a/services/core/internal/sessions/execution_file_writes_test.go b/services/core/internal/sessions/execution_file_writes_test.go index e6f14259e..6ed581eea 100644 --- a/services/core/internal/sessions/execution_file_writes_test.go +++ b/services/core/internal/sessions/execution_file_writes_test.go @@ -51,7 +51,7 @@ var testWriteKey = FileWriteIdentity{ID: testFileWrite, DeviceID: testDevice, Re func TestReserveEnvironmentFileWrite(t *testing.T) { reserve := "WithFileWriteReservation " + testTenant + " " + testEnvironment - admission := []string{reserve, "LoadFileWrite " + testFileWrite, "LoadEnvironment", "LoadSessionDevice", "LoadComputeSuspension", "LoadPendingFileWrite", "LoadActiveTurn", "LoadPendingInput"} + admission := []string{reserve, "LoadFileWrite " + testFileWrite, "LoadEnvironment", "LoadEnvironmentDevice", "LoadComputeSuspension", "LoadPendingFileWrite", "LoadActiveTurn", "LoadPendingInput"} pending := EnvironmentFileWrite{Identity: testWriteKey, State: "pending"} bound := loads(ExecutionDevice{ID: testDevice, EnvironmentID: testEnvironment}, true) admitted := func(tx fakeTx) fakeTx { @@ -59,8 +59,8 @@ func TestReserveEnvironmentFileWrite(t *testing.T) { if tx.loadEnvironment == nil { tx.loadEnvironment = returns(selfHosted) } - if tx.loadSessionDevice == nil { - tx.loadSessionDevice = bound + if tx.loadEnvironmentDevice == nil { + tx.loadEnvironmentDevice = bound } return tx } @@ -87,8 +87,8 @@ func TestReserveEnvironmentFileWrite(t *testing.T) { {"a deleted Session", fakeTx{}, LockedSession{Deleted: true}, ErrNotFound, false, admission[:1]}, {"a replaced Environment", admitted(fakeTx{loadEnvironment: returns(Environment{ID: testKey, Status: "ready"})}), LockedSession{}, ErrInvalidInput, false, admission[:3]}, {"a failed Environment", admitted(fakeTx{loadEnvironment: returns(Environment{ID: testEnvironment, Status: "failed"})}), LockedSession{}, ErrInvalidInput, false, admission[:3]}, - {"no device", admitted(fakeTx{loadSessionDevice: loads(ExecutionDevice{}, false)}), LockedSession{}, ErrNotFound, false, admission[:4]}, - {"another device", admitted(fakeTx{loadSessionDevice: loads(ExecutionDevice{ID: testKey, EnvironmentID: testEnvironment}, true)}), LockedSession{}, ErrDeviceBindingConflict, false, admission[:4]}, + {"no device", admitted(fakeTx{loadEnvironmentDevice: loads(ExecutionDevice{}, false)}), LockedSession{}, ErrNotFound, false, admission[:4]}, + {"another device", admitted(fakeTx{loadEnvironmentDevice: loads(ExecutionDevice{ID: testKey, EnvironmentID: testEnvironment}, true)}), LockedSession{}, ErrDeviceBindingConflict, false, admission[:4]}, {"suspended compute", func() fakeTx { tx := admitted(fakeTx{}); tx.loadComputeSuspension = returns(true); return tx }(), LockedSession{}, ErrTurnConflict, false, admission[:5]}, {"pending input", idle(fakeTx{loadPendingInput: returns(true)}), LockedSession{}, ErrTurnConflict, false, admission}, } { diff --git a/services/core/internal/sessions/executor_credentials_test.go b/services/core/internal/sessions/executor_credentials_test.go index 345b4b35f..d2149d15e 100644 --- a/services/core/internal/sessions/executor_credentials_test.go +++ b/services/core/internal/sessions/executor_credentials_test.go @@ -119,7 +119,7 @@ const ( var ( testPrincipal = identity.Principal{ProjectScope: identity.ProjectScope{TenantID: testTenant, OrganizationID: "organization", ProjectID: "project"}, SubjectKind: "user", SubjectID: "creator"} - selfHosted = Environment{ID: testEnvironment, SessionID: testSession, Status: "ready", Configuration: []byte(`{"type":"self_hosted"}`)} + selfHosted = Environment{ID: testEnvironment, AttachedSessionID: testSession, Status: "ready", Configuration: []byte(`{"type":"self_hosted"}`)} // theCreator is a fake LoadSessionCreator: testPrincipal created the // Session. theCreator = loads(testPrincipal.Subject(), true) diff --git a/services/core/internal/sessions/provisioning.go b/services/core/internal/sessions/provisioning.go index 5230f8d89..0dc0ddf76 100644 --- a/services/core/internal/sessions/provisioning.go +++ b/services/core/internal/sessions/provisioning.go @@ -93,7 +93,6 @@ const ( ProvisioningNPMPackages = "npm" ProvisioningInitialFile = "file" ProvisioningSkill = "skill" - ProvisioningHarness = "harness" ) // ProvisioningFailure identifies a confirmed failed initialization step. @@ -124,8 +123,6 @@ func (f ProvisioningFailure) Reason() string { return fmt.Sprintf("Failed to provision environment: script %q failed with exit code %d", label, f.ExitCode) case f.Step == ProvisioningInitialFile: return "Failed to provision environment: initial file installation failed" - case f.Step == ProvisioningHarness: - return "Failed to prepare environment: the selected Harness is unavailable. Install the supported Harness version on the Runtime and create a new Session." case f.Step == ProvisioningSkill: return "Failed to provision environment: Skill installation failed" } @@ -163,7 +160,7 @@ func SanitizedProvisioningDetail(f ProvisioningFailureDetail) *ProvisioningFailu result.Index = &value } case ProvisioningPythonPackages, ProvisioningNPMPackages: - case ProvisioningInitialFile, ProvisioningSkill, ProvisioningHarness: + case ProvisioningInitialFile, ProvisioningSkill: value := *f.Step result.Step = &value return result diff --git a/services/core/internal/sessions/transaction_test.go b/services/core/internal/sessions/transaction_test.go index 318013ab3..bcc397699 100644 --- a/services/core/internal/sessions/transaction_test.go +++ b/services/core/internal/sessions/transaction_test.go @@ -39,7 +39,7 @@ type fakeTx struct { loadPendingFileWrite func() (bool, error) loadBoundDevice func() (bool, error) insertEnvironmentDevice func() error - loadSessionDevice func() (ExecutionDevice, bool, error) + loadEnvironmentDevice func() (ExecutionDevice, bool, error) claimInitialization func() (bool, error) completeInitialization func() (bool, error) failInitialization func() error diff --git a/services/core/tests/integration/admin_session_archive_test.go b/services/core/tests/integration/admin_session_archive_test.go index 5101af9b1..4cab6715f 100644 --- a/services/core/tests/integration/admin_session_archive_test.go +++ b/services/core/tests/integration/admin_session_archive_test.go @@ -257,7 +257,7 @@ func TestManagedSessionArchivePreservesFailuresAndRejectsSelfHosted(t *testing.T if _, err := s.pool.Exec(t.Context(), "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID); err != nil { t.Fatal(err) } - if err := sessionExecution(t, w.lease).FailEnvironmentInitialization(t.Context(), sessions.EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID}, sessions.ProvisioningFailure{Step: sessions.ProvisioningSetupCommand, Index: 0, ExitCode: 2}); err != nil { + if err := sessionExecution(t, w.lease).FailEnvironmentInitialization(t.Context(), sessions.EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, TenantID: owner.TenantID, DeviceID: owner.DeviceID}, sessions.ProvisioningFailure{Step: sessions.ProvisioningSetupCommand, Index: 0, ExitCode: 2}); err != nil { t.Fatal(err) } failed, err := sessionAdapter(s).GetSession(t.Context(), tenant, session.ID) diff --git a/services/core/tests/integration/environment_directory_test.go b/services/core/tests/integration/environment_directory_test.go index a06ca7d1b..22f199957 100644 --- a/services/core/tests/integration/environment_directory_test.go +++ b/services/core/tests/integration/environment_directory_test.go @@ -120,7 +120,7 @@ func TestEnvironmentDirectoryWorkerReadsWithoutExecutionPrerequisites(t *testing t.Fatal("foreign reader admitted", err) } wrong := environment - wrong.SessionID = uuid.NewString() + wrong.AttachedSessionID = uuid.NewString() if _, err := w.ReadEnvironmentDirectory(t.Context(), wrong, "reports"); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("wrong Session admitted", err) } diff --git a/services/core/tests/integration/environment_initialization_test.go b/services/core/tests/integration/environment_initialization_test.go index 313546f6b..c801e327d 100644 --- a/services/core/tests/integration/environment_initialization_test.go +++ b/services/core/tests/integration/environment_initialization_test.go @@ -188,7 +188,7 @@ func TestEnvironmentInitializationRevocationBeforeClaim(t *testing.T) { if err != nil { t.Fatal(err) } - return sessions.EnvironmentInitialization{EnvironmentID: environment.ID, SessionID: session.ID, TenantID: principal.TenantID, DeviceID: enrolled.DeviceID, State: "pending", Engine: "codex"} + return sessions.EnvironmentInitialization{EnvironmentID: environment.ID, TenantID: principal.TenantID, DeviceID: enrolled.DeviceID, State: "pending"} } revoked, other := create(), create() owned := executionOwner(t, s).Sessions diff --git a/services/core/tests/integration/environment_retrieve_public_test.go b/services/core/tests/integration/environment_retrieve_public_test.go index 79e87ba7a..89e23b4e6 100644 --- a/services/core/tests/integration/environment_retrieve_public_test.go +++ b/services/core/tests/integration/environment_retrieve_public_test.go @@ -105,7 +105,7 @@ func TestEnvironmentRetrievalOfficialClient(t *testing.T) { if err := reopened.pool.QueryRow(t.Context(), `SELECT (SELECT count(*) FROM turns WHERE session_id=$1) + (SELECT count(*) FROM environment_input_reservations WHERE session_id=$1) + - (SELECT count(*) FROM session_events WHERE session_id=$1)`, before.SessionID).Scan(&history); err != nil || history != 0 { + (SELECT count(*) FROM session_events WHERE session_id=$1)`, before.AttachedSessionID).Scan(&history); err != nil || history != 0 { t.Fatal("read-only retrieval admitted work or emitted events", history, err) } } diff --git a/services/core/tests/integration/environments_test.go b/services/core/tests/integration/environments_test.go index 6c8b1d799..edca342ed 100644 --- a/services/core/tests/integration/environments_test.go +++ b/services/core/tests/integration/environments_test.go @@ -36,7 +36,7 @@ func TestEnvironmentOwnershipPersistsAndStaysScoped(t *testing.T) { t.Fatal(err) } first, err := sessionAdapter(s).GetSessionEnvironment(ctx, tenant, session.ID) - if err != nil || first.ID == "" || first.ID == session.ID || first.SessionID != session.ID || first.TenantID != tenant || first.Status != "pending" || first.CreatedAt.IsZero() { + if err != nil || first.ID == "" || first.ID == session.ID || first.AttachedSessionID != session.ID || first.TenantID != tenant || first.Status != "pending" || first.CreatedAt.IsZero() { t.Fatal(first, err) } got, err := sessionAdapter(s).GetEnvironment(ctx, tenant, first.ID) diff --git a/services/core/tests/integration/runtime_allocations_test.go b/services/core/tests/integration/runtime_allocations_test.go index 824ab501a..4c9f474ac 100644 --- a/services/core/tests/integration/runtime_allocations_test.go +++ b/services/core/tests/integration/runtime_allocations_test.go @@ -55,7 +55,7 @@ func TestRuntimeAllocationAtomicOwnershipAndRecovery(t *testing.T) { t.Fatal(err) } retained, err := deploymentStore(next).EnvironmentAllocation(t.Context(), deployment.AllocationKey{TenantID: tenant, EnvironmentID: environment.ID}) - if err != nil || !retained.SessionDeleted || retained.ID != owner.ID { + if err != nil || !retained.Reclaimed || retained.ID != owner.ID { t.Fatalf("deletion discarded cleanup identity: %+v %v", retained, err) } if _, err := deploymentExecution(t, next).ObserveRunning(t.Context(), owner); !errors.Is(err, sessions.ErrNotFound) { diff --git a/services/core/tests/integration/runtime_compute_lifecycle_test.go b/services/core/tests/integration/runtime_compute_lifecycle_test.go index 5b209a8f4..968f9ad05 100644 --- a/services/core/tests/integration/runtime_compute_lifecycle_test.go +++ b/services/core/tests/integration/runtime_compute_lifecycle_test.go @@ -371,13 +371,13 @@ func (f *computeLifecycleFixture) phase(tenant, environment, phase string) deplo } func (f *computeLifecycleFixture) complete(owner deployment.Allocation) string { id := uuid.NewString() - f.sql(`INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '2 minutes','{}')`, id, owner.SessionID) + f.sql(`INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '2 minutes','{}')`, id, owner.AttachedSessionID) f.sql(`UPDATE runtime_allocations SET compute_activity_at=clock_timestamp()-interval '2 minutes',compute_phase_changed_at=clock_timestamp()-interval '2 minutes' WHERE id=$1`, owner.ID) return id } func (f *computeLifecycleFixture) queued(owner deployment.Allocation) string { id := uuid.NewString() - f.sql(`INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, id, owner.SessionID) + f.sql(`INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, id, owner.AttachedSessionID) return id } @@ -391,7 +391,7 @@ func TestRuntimeComputeLifecycleIdleSuspendAndQueuedSameSessionWake(t *testing.T } queued := f.queued(suspended) awake := f.phase(tenant, env.ID, "running") - if awake.SessionID != session.ID || awake.ID != owner.ID || awake.DeviceID != owner.DeviceID || f.provider.creates != 1 || f.provider.restores != 1 || f.provider.snapshotDeletes != 1 { + if awake.AttachedSessionID != session.ID || awake.ID != owner.ID || awake.DeviceID != owner.DeviceID || f.provider.creates != 1 || f.provider.restores != 1 || f.provider.snapshotDeletes != 1 { t.Fatal("wake replaced Session or replayed allocation") } var completedCount, queuedCount int diff --git a/services/core/tests/integration/runtime_file_admission_test.go b/services/core/tests/integration/runtime_file_admission_test.go index 0368a101b..a844f6d88 100644 --- a/services/core/tests/integration/runtime_file_admission_test.go +++ b/services/core/tests/integration/runtime_file_admission_test.go @@ -86,7 +86,7 @@ func TestRuntimeFileWriteAndQuiesceSerializeBothOrders(t *testing.T) { writes := sessionExecution(t, w.lease) key := runtimeFileWriteKey(owner) until := time.Now().Add(time.Hour) - ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.SessionID) + ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.AttachedSessionID) done := make(chan error, 1) go func() { var err error diff --git a/services/core/tests/integration/runtime_idle_clock_test.go b/services/core/tests/integration/runtime_idle_clock_test.go index 04fbd9f59..0d59a334c 100644 --- a/services/core/tests/integration/runtime_idle_clock_test.go +++ b/services/core/tests/integration/runtime_idle_clock_test.go @@ -87,11 +87,11 @@ func TestManagedIdleClockIgnoresRootHostSkew(t *testing.T) { t.Run(skew.String(), func(t *testing.T) { s, w, owner := managedIdleClockFixture(t) turn := uuid.NewString() - runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at,settings) VALUES($1,$2,'in_progress',clock_timestamp(),'{}')", turn, owner.SessionID) + runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at,settings) VALUES($1,$2,'in_progress',clock_timestamp(),'{}')", turn, owner.AttachedSessionID) source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) before := runtimeDatabaseTime(t, s) - completed, err := completeExecution(t.Context(), t, w, owner.TenantID, owner.SessionID, turn, sessions.TurnCompleted, outcome, "", 0) + completed, err := completeExecution(t.Context(), t, w, owner.TenantID, owner.AttachedSessionID, turn, sessions.TurnCompleted, outcome, "", 0) after := runtimeDatabaseTime(t, s) if err != nil || completed.CompletedAt.UnixMilli() != source { t.Fatal("native completion changed or rejected", completed, err) @@ -100,7 +100,7 @@ func TestManagedIdleClockIgnoresRootHostSkew(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := completeExecution(t.Context(), t, w, owner.TenantID, owner.SessionID, turn, sessions.TurnCompleted, outcome, "", 0); !errors.Is(err, sessions.ErrTurnConflict) { + if _, err := completeExecution(t.Context(), t, w, owner.TenantID, owner.AttachedSessionID, turn, sessions.TurnCompleted, outcome, "", 0); !errors.Is(err, sessions.ErrTurnConflict) { t.Fatal("terminal replay accepted", err) } unchanged, err := deploymentStore(w).Activity(t.Context(), owner.ID) @@ -108,7 +108,7 @@ func TestManagedIdleClockIgnoresRootHostSkew(t *testing.T) { t.Fatal("terminal retry reset idle", unchanged, err) } verifyManagedIdleClock(t, s, w, owner, before, after) - read, err := sessionAdapter(s).GetTurn(t.Context(), owner.TenantID, owner.SessionID, turn) + read, err := sessionAdapter(s).GetTurn(t.Context(), owner.TenantID, owner.AttachedSessionID, turn) if err != nil || read.CompletedAt.UnixMilli() != source { t.Fatal("public native timestamp rewritten", read, err) } @@ -121,14 +121,14 @@ func TestManagedIdleClockIgnoresChildHostSkewAndReplay(t *testing.T) { s, w, owner := managedIdleClockFixture(t) root := runtimeSuspensionCompleted(t, s.pool, owner) child := uuid.NewString() - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.SessionID, root) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal,public_visible) VALUES($1,$2,$3,'codex','child','root',1,$4,1,true)`, child, owner.SessionID, owner.DeviceID, root) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.AttachedSessionID, root) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal,public_visible) VALUES($1,$2,$3,'codex','child','root',1,$4,1,true)`, child, owner.AttachedSessionID, owner.DeviceID, root) source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() created := source - 1000 payload, _ := json.Marshal(proto.SubagentTurnPayload{NativeID: "child", TurnID: "remote-turn", Status: sessions.TurnCompleted, CreatedAtMS: created, StartedAtMS: &created, CompletedAtMS: &source}) tenant, _ := parseID(owner.TenantID) project := func() error { - return w.withSession(t.Context(), owner.TenantID, owner.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + return w.withSession(t.Context(), owner.TenantID, owner.AttachedSessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { return sessions.ProjectSource(ctx, sessionpg.BindSession(q, tenant, session), sessions.Source{Turn: root, Kind: proto.TypeSubagentTurn, Payload: payload}) }) } @@ -149,12 +149,12 @@ func TestManagedIdleClockIgnoresChildHostSkewAndReplay(t *testing.T) { t.Fatal("replayed child completion reset idle", unchanged, err) } verifyManagedIdleClock(t, s, w, owner, before, after) - page, err := sessionAdapter(s).ListSubagentTurns(t.Context(), owner.TenantID, owner.SessionID, child, "", 10, true) + page, err := sessionAdapter(s).ListSubagentTurns(t.Context(), owner.TenantID, owner.AttachedSessionID, child, "", 10, true) if err != nil || len(page.Data) != 1 { t.Fatal(page, err) } // Public child Turns have second precision; read the stored native time. - sessionID, _ := parseID(owner.SessionID) + sessionID, _ := parseID(owner.AttachedSessionID) turnID, _ := parseID(page.Data[0].ID) read, err := s.queries.GetChildTurn(t.Context(), sqlc.GetChildTurnParams{SessionID: sessionID, ID: turnID}) if err != nil || read.CompletedAt.Time.UnixMilli() != source { @@ -225,7 +225,7 @@ func TestManagedIdleClockReconnectPreservesReceipts(t *testing.T) { if err != nil || !reflect.DeepEqual(before, after) { t.Fatal("reconnect changed compute receipt or idle clock", err) } - if _, err := sessionAdapter(s).GetTurn(t.Context(), owner.TenantID, owner.SessionID, turn); err != nil { + if _, err := sessionAdapter(s).GetTurn(t.Context(), owner.TenantID, owner.AttachedSessionID, turn); err != nil { t.Fatal(err) } } diff --git a/services/core/tests/integration/runtime_node_lifecycle_test.go b/services/core/tests/integration/runtime_node_lifecycle_test.go index 93d05756a..4d240f87a 100644 --- a/services/core/tests/integration/runtime_node_lifecycle_test.go +++ b/services/core/tests/integration/runtime_node_lifecycle_test.go @@ -21,7 +21,7 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { wakeTenant, _, wakeEnv := f.session(f.nodeB, false) wakeOwner := f.provision(wakeTenant, wakeEnv) f.phase(wakeTenant, wakeEnv.ID, "running") - if _, err := f.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '2 minutes','{}')", uuid.NewString(), wakeOwner.SessionID); err != nil { + if _, err := f.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '2 minutes','{}')", uuid.NewString(), wakeOwner.AttachedSessionID); err != nil { t.Fatal(err) } if _, err := f.pool.Exec(t.Context(), "UPDATE runtime_allocations SET compute_activity_at=clock_timestamp()-interval '2 minutes',compute_phase_changed_at=clock_timestamp()-interval '2 minutes' WHERE id=$1", wakeOwner.ID); err != nil { diff --git a/services/core/tests/integration/runtime_suspension_concurrency_test.go b/services/core/tests/integration/runtime_suspension_concurrency_test.go index 8e7fb953e..8f74a9819 100644 --- a/services/core/tests/integration/runtime_suspension_concurrency_test.go +++ b/services/core/tests/integration/runtime_suspension_concurrency_test.go @@ -57,11 +57,11 @@ func TestRuntimeSuspensionClaimReadsPhaseAfterSessionLock(t *testing.T) { t.Run(phase, func(t *testing.T) { s, w, pool, owner := runtimeSuspensionFixture(t) turn := uuid.NewString() - runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, turn, owner.SessionID) - ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.SessionID) + runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, turn, owner.AttachedSessionID) + ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.AttachedSessionID) done := make(chan error, 1) go func() { - _, err := transitionTurn(ctx, w, owner.TenantID, owner.SessionID, turn, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) + _, err := transitionTurn(ctx, w, owner.TenantID, owner.AttachedSessionID, turn, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) done <- err }() runtimeSuspensionWaitBlocked(t, ctx, pool, blocker, done) @@ -77,12 +77,12 @@ func TestRuntimeSuspensionClaimReadsPhaseAfterSessionLock(t *testing.T) { if blocked && !errors.Is(err, sessions.ErrTurnConflict) || !blocked && err != nil { t.Fatal("incorrect claim outcome", phase, err) } - got, err := sessionAdapter(s).GetTurn(ctx, owner.TenantID, owner.SessionID, turn) + got, err := sessionAdapter(s).GetTurn(ctx, owner.TenantID, owner.AttachedSessionID, turn) if err != nil { t.Fatal(err) } var startedEvents int - if err := pool.QueryRow(ctx, `SELECT count(*) FROM session_events WHERE session_id=$1 AND payload->'event'->>'type'='agent.session.turn.in_progress'`, owner.SessionID).Scan(&startedEvents); err != nil { + if err := pool.QueryRow(ctx, `SELECT count(*) FROM session_events WHERE session_id=$1 AND payload->'event'->>'type'='agent.session.turn.in_progress'`, owner.AttachedSessionID).Scan(&startedEvents); err != nil { t.Fatal(err) } if blocked && (got.Status != sessions.TurnQueued || !got.StartedAt.IsZero() || startedEvents != 0) { @@ -98,17 +98,17 @@ func TestRuntimeSuspensionClaimReadsPhaseAfterSessionLock(t *testing.T) { func TestRuntimeSuspensionPromotionRetainsPendingInput(t *testing.T) { s, w, pool, owner := runtimeSuspensionFixture(t) runtimeSuspensionSQL(t, pool, `UPDATE runtime_allocations SET compute_phase='waking',compute_retained_until=clock_timestamp()+interval '1 hour' WHERE id=$1`, owner.ID) - pending := reserveEnvironmentInput(t, s, owner.TenantID, owner.SessionID, "during-wake") - if _, err := sessionExecution(t, w.lease).PromoteEnvironmentInput(t.Context(), owner.TenantID, owner.SessionID, pending.ID); !errors.Is(err, sessions.ErrTurnConflict) { + pending := reserveEnvironmentInput(t, s, owner.TenantID, owner.AttachedSessionID, "during-wake") + if _, err := sessionExecution(t, w.lease).PromoteEnvironmentInput(t.Context(), owner.TenantID, owner.AttachedSessionID, pending.ID); !errors.Is(err, sessions.ErrTurnConflict) { t.Fatal("waking allocation promoted model input", err) } - got, err := sessionAdapter(s).GetEnvironmentInputReservation(t.Context(), owner.TenantID, owner.SessionID, pending.ID) + got, err := sessionAdapter(s).GetEnvironmentInputReservation(t.Context(), owner.TenantID, owner.AttachedSessionID, pending.ID) if err != nil || got.State != sessions.EnvironmentInputPending || got.SettledAt != nil || len(got.Receipts) != 0 { t.Fatal("blocked promotion partially committed", got, err) } - environmentInputHistory(t, pool, owner.SessionID, 0, 0) + environmentInputHistory(t, pool, owner.AttachedSessionID, 0, 0) runtimeSuspensionSQL(t, pool, `UPDATE runtime_allocations SET compute_phase='running',compute_retained_until=NULL WHERE id=$1`, owner.ID) - got, err = sessionExecution(t, w.lease).PromoteEnvironmentInput(t.Context(), owner.TenantID, owner.SessionID, pending.ID) + got, err = sessionExecution(t, w.lease).PromoteEnvironmentInput(t.Context(), owner.TenantID, owner.AttachedSessionID, pending.ID) if err != nil || got.State != sessions.EnvironmentInputAdmitted || len(got.Receipts) != 2 { t.Fatal("pending request could not resume once running", got, err) } @@ -121,7 +121,7 @@ func TestRuntimeSuspensionCaptureRechecksNewPendingWork(t *testing.T) { runtimeSuspensionCompleted(t, pool, owner) until := time.Now().Add(time.Hour) owner = runtimeSuspensionStep(t, w, owner, "quiescing", &until) - ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.SessionID) + ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.AttachedSessionID) done := make(chan error, 1) go func() { _, err := deploymentExecution(t, w).SetCompute(ctx, owner, "suspending", json.RawMessage(`{}`), &until) @@ -131,9 +131,9 @@ func TestRuntimeSuspensionCaptureRechecksNewPendingWork(t *testing.T) { var err error switch kind { case "queued": - _, err = tx.Exec(ctx, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, uuid.NewString(), owner.SessionID) + _, err = tx.Exec(ctx, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, uuid.NewString(), owner.AttachedSessionID) case "input": - _, err = tx.Exec(ctx, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline,settings) VALUES($1,$2,'pending','[{}]',clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.SessionID) + _, err = tx.Exec(ctx, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline,settings) VALUES($1,$2,'pending','[{}]',clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.AttachedSessionID) case "file_write": _, err = tx.Exec(ctx, `INSERT INTO environment_file_writes(id,environment_id,device_id,request_sha256) VALUES($1,$2,$3,$4)`, uuid.NewString(), owner.EnvironmentID, owner.DeviceID, strings.Repeat("a", 64)) case "wake": @@ -160,12 +160,12 @@ func TestRuntimeSuspensionWakeUsesSessionLock(t *testing.T) { for _, deleted := range []bool{false, true} { t.Run(map[bool]string{false: "phase_commit", true: "deletion"}[deleted], func(t *testing.T) { s, _, pool, owner := runtimeSuspensionFixture(t) - ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.SessionID) + ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.AttachedSessionID) done := make(chan error, 1) go func() { done <- deploymentService(t, s).TouchActivity(ctx, owner.TenantID, owner.EnvironmentID) }() runtimeSuspensionWaitBlocked(t, ctx, pool, blocker, done) if deleted { - if _, err := tx.Exec(ctx, `UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1`, owner.SessionID); err != nil { + if _, err := tx.Exec(ctx, `UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1`, owner.AttachedSessionID); err != nil { t.Fatal(err) } } else if _, err := tx.Exec(ctx, `UPDATE runtime_allocations SET compute_phase='suspending',compute_retained_until=clock_timestamp()+interval '1 hour' WHERE id=$1`, owner.ID); err != nil { @@ -190,7 +190,7 @@ func TestRuntimeSuspensionQuiesceCannotOvertakeClaim(t *testing.T) { s, w, pool, owner := runtimeSuspensionFixture(t) runtimeSuspensionCompleted(t, pool, owner) until := time.Now().Add(time.Hour) - ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.SessionID) + ctx, tx, blocker := runtimeSuspensionLockedSession(t, pool, owner.AttachedSessionID) done := make(chan error, 1) go func() { _, err := deploymentExecution(t, w).SetCompute(ctx, owner, "quiescing", json.RawMessage(`{}`), &until) @@ -198,10 +198,10 @@ func TestRuntimeSuspensionQuiesceCannotOvertakeClaim(t *testing.T) { }() runtimeSuspensionWaitBlocked(t, ctx, pool, blocker, done) turn := uuid.NewString() - if _, err := tx.Exec(ctx, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, turn, owner.SessionID); err != nil { + if _, err := tx.Exec(ctx, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'queued','{}')`, turn, owner.AttachedSessionID); err != nil { t.Fatal(err) } - params, err := sessionpg.TurnLookup(owner.TenantID, owner.SessionID, turn) + params, err := sessionpg.TurnLookup(owner.TenantID, owner.AttachedSessionID, turn) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/runtime_suspension_test.go b/services/core/tests/integration/runtime_suspension_test.go index 8ed3a7767..98d6de933 100644 --- a/services/core/tests/integration/runtime_suspension_test.go +++ b/services/core/tests/integration/runtime_suspension_test.go @@ -49,7 +49,7 @@ func runtimeSuspensionSQL(t *testing.T, pool *pgxpool.Pool, query string, args . func runtimeSuspensionCompleted(t *testing.T, pool *pgxpool.Pool, owner deployment.Allocation) string { t.Helper() id := uuid.NewString() - runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '10 minutes','{}')`, id, owner.SessionID) + runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,completed_at,settings) VALUES($1,$2,'completed',clock_timestamp()-interval '10 minutes','{}')`, id, owner.AttachedSessionID) return id } @@ -79,16 +79,16 @@ func TestRuntimeSuspensionRequiresIdleAndNoPendingWork(t *testing.T) { } switch kind { case "queued", "in_progress", "waiting": - runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,$3,'{}')`, uuid.NewString(), owner.SessionID, kind) + runtimeSuspensionSQL(t, pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,$3,'{}')`, uuid.NewString(), owner.AttachedSessionID, kind) case "subagent_queued", "subagent_in_progress", "subagent_waiting": child := uuid.NewString() - runtimeSuspensionSQL(t, pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.SessionID, completed) - runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.SessionID, owner.DeviceID, completed) - runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn',$4,clock_timestamp())`, uuid.NewString(), owner.SessionID, child, strings.TrimPrefix(kind, "subagent_")) + runtimeSuspensionSQL(t, pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.AttachedSessionID, completed) + runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.AttachedSessionID, owner.DeviceID, completed) + runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn',$4,clock_timestamp())`, uuid.NewString(), owner.AttachedSessionID, child, strings.TrimPrefix(kind, "subagent_")) case "initial_input": - runtimeSuspensionSQL(t, pool, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,is_initial,created_at,deadline,settings) VALUES($1,$2,'initial','[{}]',true,clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.SessionID) + runtimeSuspensionSQL(t, pool, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,is_initial,created_at,deadline,settings) VALUES($1,$2,'initial','[{}]',true,clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.AttachedSessionID) case "input_reservation": - runtimeSuspensionSQL(t, pool, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline,settings) VALUES($1,$2,'pending','[{}]',clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.SessionID) + runtimeSuspensionSQL(t, pool, `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline,settings) VALUES($1,$2,'pending','[{}]',clock_timestamp(),clock_timestamp()+interval '1 minute','{}')`, uuid.NewString(), owner.AttachedSessionID) case "file_write": runtimeSuspensionSQL(t, pool, `INSERT INTO environment_file_writes(id,environment_id,device_id,request_sha256) VALUES($1,$2,$3,$4)`, uuid.NewString(), owner.EnvironmentID, owner.DeviceID, strings.Repeat("a", 64)) } @@ -188,7 +188,7 @@ func TestRuntimeSuspensionWakeDoesNotLoseNewerWork(t *testing.T) { if _, err := deploymentExecution(t, w).CheckRunning(t.Context(), owner); err != nil { t.Fatal(err) } - if _, err := sessionAdapter(s).GetSession(t.Context(), owner.TenantID, owner.SessionID); err != nil { + if _, err := sessionAdapter(s).GetSession(t.Context(), owner.TenantID, owner.AttachedSessionID); err != nil { t.Fatal(err) } quiet, err := deploymentStore(w).Activity(t.Context(), owner.ID) @@ -252,14 +252,14 @@ func TestRuntimeSuspensionRetentionAndDeletedSession(t *testing.T) { if _, err := deploymentExecution(t, w).BeginRestore(t.Context(), retained, sandbox.CheckpointCompatibility{ArtifactDomain: "fixture-store", ExecutionClass: "fixture-runtime"}, json.RawMessage(`{}`)); !errors.Is(err, deployment.ErrAllocationConflict) { t.Fatal("expired snapshot restored from stale observation", err) } - if err := sessionService(t, s).DeleteSession(t.Context(), sessions.DeleteSessionCommand{TenantID: owner.TenantID, SessionID: owner.SessionID}); err != nil { + if err := sessionService(t, s).DeleteSession(t.Context(), sessions.DeleteSessionCommand{TenantID: owner.TenantID, SessionID: owner.AttachedSessionID}); err != nil { t.Fatal(err) } if err := deploymentService(t, s).TouchActivity(t.Context(), owner.TenantID, owner.EnvironmentID); !errors.Is(err, sessions.ErrNotFound) { t.Fatal(err) } deleted, err := deploymentStore(s).EnvironmentAllocation(t.Context(), deployment.AllocationKey{TenantID: owner.TenantID, EnvironmentID: owner.EnvironmentID}) - if err != nil || !deleted.SessionDeleted || deleted.ComputeWakeRequested { + if err != nil || !deleted.Reclaimed || deleted.ComputeWakeRequested { t.Fatal("deleted session was woken", deleted, err) } if _, err := deploymentExecution(t, w).BeginRestore(t.Context(), deleted, sandbox.CheckpointCompatibility{ArtifactDomain: "fixture-store", ExecutionClass: "fixture-runtime"}, json.RawMessage(`{}`)); !errors.Is(err, sessions.ErrNotFound) { @@ -352,9 +352,9 @@ func TestRuntimeSuspensionRechecksCompletionAgainstIdleTimeout(t *testing.T) { runtimeSuspensionSQL(t, pool, `UPDATE turns SET completed_at=clock_timestamp() WHERE id=$1`, turn) case "subagent": child := uuid.NewString() - runtimeSuspensionSQL(t, pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.SessionID, turn) - runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.SessionID, owner.DeviceID, turn) - runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at,completed_at) VALUES($1,$2,$3,'child-turn','completed',clock_timestamp()-interval '10 minutes',clock_timestamp())`, uuid.NewString(), owner.SessionID, child) + runtimeSuspensionSQL(t, pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.AttachedSessionID, turn) + runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.AttachedSessionID, owner.DeviceID, turn) + runtimeSuspensionSQL(t, pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at,completed_at) VALUES($1,$2,$3,'child-turn','completed',clock_timestamp()-interval '10 minutes',clock_timestamp())`, uuid.NewString(), owner.AttachedSessionID, child) default: runtimeSuspensionSQL(t, pool, `INSERT INTO environment_file_writes(id,environment_id,device_id,request_sha256,state,created_at,settled_at) VALUES($1,$2,$3,$4,$5,clock_timestamp()-interval '10 minutes',clock_timestamp())`, uuid.NewString(), owner.EnvironmentID, owner.DeviceID, strings.Repeat("a", 64), strings.TrimPrefix(kind, "file_")) } diff --git a/services/core/tests/integration/session_diagnostics_test.go b/services/core/tests/integration/session_diagnostics_test.go index 510d27a70..2b19cc0e9 100644 --- a/services/core/tests/integration/session_diagnostics_test.go +++ b/services/core/tests/integration/session_diagnostics_test.go @@ -70,19 +70,19 @@ func TestDiagnosticForceSettlementIgnoresNativeClock(t *testing.T) { t.Run(skew.String(), func(t *testing.T) { s, w, owner := managedIdleClockFixture(t) turn := uuid.NewString() - runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at,settings) VALUES($1,$2,'in_progress',clock_timestamp(),'{}')", turn, owner.SessionID) + runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at,settings) VALUES($1,$2,'in_progress',clock_timestamp(),'{}')", turn, owner.AttachedSessionID) events := []sessions.ExecutionEvent{diagnosticToolEvent("first", "before", "in_progress"), diagnosticToolEvent("second", "before", "in_progress")} - if err := sessionExecution(t, w.lease).AppendTurnEvents(t.Context(), owner.TenantID, owner.SessionID, turn, 1, events); err != nil { + if err := sessionExecution(t, w.lease).AppendTurnEvents(t.Context(), owner.TenantID, owner.AttachedSessionID, turn, 1, events); err != nil { t.Fatal(err) } source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() before := runtimeDatabaseTime(t, s) - completed, err := completeExecution(t.Context(), t, w, owner.TenantID, owner.SessionID, turn, sessions.TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)), "", 0) + completed, err := completeExecution(t.Context(), t, w, owner.TenantID, owner.AttachedSessionID, turn, sessions.TurnCompleted, json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)), "", 0) after := runtimeDatabaseTime(t, s) if err != nil || completed.CompletedAt.UnixMilli() != source { t.Fatal("public native completion changed", completed, err) } - snap, err := sessionAdapter(s).GetTurnDiagnosticsSnapshot(t.Context(), owner.TenantID, owner.SessionID, turn) + snap, err := sessionAdapter(s).GetTurnDiagnosticsSnapshot(t.Context(), owner.TenantID, owner.AttachedSessionID, turn) if err != nil || len(snap.Items) != 2 { t.Fatal(snap, err) } @@ -223,7 +223,7 @@ func TestDiagnosticProvisioningDetailAtomicAndPrivate(t *testing.T) { }) failure := sessions.ProvisioningFailure{Step: sessions.ProvisioningSetupCommand, Index: 2, ExitCode: 7} runtimeSuspensionSQL(t, pool, "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID) - preparation := sessions.EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID} + preparation := sessions.EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, TenantID: owner.TenantID, DeviceID: owner.DeviceID} if err = sessionExecution(t, writer.lease).FailEnvironmentInitialization(t.Context(), preparation, failure); err == nil { t.Fatal("failure committed without events") } @@ -268,7 +268,7 @@ func TestDiagnosticInitializationStepsAndFailureCategory(t *testing.T) { t.Fatal(err) } runtimeSuspensionSQL(t, s.pool, "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID) - preparation := sessions.EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID} + preparation := sessions.EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, TenantID: owner.TenantID, DeviceID: owner.DeviceID} index := 1 steps := []sessions.InitializationStep{{Step: "npm", DurationMS: 4100}, {Step: "setup", Index: &index, DurationMS: 5200}} execution := sessionExecution(t, writer.lease) @@ -349,10 +349,10 @@ func TestDiagnosticRootReadRejectsActualChildTurn(t *testing.T) { s, w, owner := managedIdleClockFixture(t) root := runtimeSuspensionCompleted(t, s.pool, owner) child, turn := uuid.NewString(), uuid.NewString() - runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.SessionID, root) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.SessionID, owner.DeviceID, root) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn','in_progress',clock_timestamp())`, turn, owner.SessionID, child) - if _, err := sessionAdapter(w).GetTurnDiagnosticsSnapshot(t.Context(), owner.TenantID, owner.SessionID, turn); !errors.Is(err, sessions.ErrNotFound) { + runtimeSuspensionSQL(t, s.pool, `INSERT INTO turn_events(session_id,turn_id,ordinal,kind,payload) VALUES($1,$2,1,'subagent','{}')`, owner.AttachedSessionID, root) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_identities(id,session_id,device_id,engine,native_id,parent_native_id,native_created_at,first_turn_id,first_event_ordinal) VALUES($1,$2,$3,'codex','child','root',1,$4,1)`, child, owner.AttachedSessionID, owner.DeviceID, root) + runtimeSuspensionSQL(t, s.pool, `INSERT INTO subagent_turns(id,session_id,subagent_id,native_id,status,created_at) VALUES($1,$2,$3,'child-turn','in_progress',clock_timestamp())`, turn, owner.AttachedSessionID, child) + if _, err := sessionAdapter(w).GetTurnDiagnosticsSnapshot(t.Context(), owner.TenantID, owner.AttachedSessionID, turn); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("child Turn became root diagnostics", err) } } diff --git a/services/core/tests/integration/session_reads_fixture_test.go b/services/core/tests/integration/session_reads_fixture_test.go index 3aa8e060e..fa0af1a6b 100644 --- a/services/core/tests/integration/session_reads_fixture_test.go +++ b/services/core/tests/integration/session_reads_fixture_test.go @@ -25,7 +25,7 @@ func FixtureEnvironmentDevice(t testing.TB, ctx context.Context, pool *pgxpool.P if err != nil { return sessions.ExecutionDevice{}, err } - lookup, err := sessionpg.ResourceLookup(tenant, current.SessionID) + lookup, err := sessionpg.ResourceLookup(tenant, current.AttachedSessionID) if err != nil { return sessions.ExecutionDevice{}, err } From 87f78e391de91dc7e6df75f5504c6be2cb54a35c Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 10:26:19 +0000 Subject: [PATCH 3/9] Format tests --- .../internal/deployment/allocations_test.go | 20 +++++++++---------- .../internal/sessions/transaction_test.go | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/services/core/internal/deployment/allocations_test.go b/services/core/internal/deployment/allocations_test.go index 509b461d9..aaf856143 100644 --- a/services/core/internal/deployment/allocations_test.go +++ b/services/core/internal/deployment/allocations_test.go @@ -94,16 +94,16 @@ func (f *fakeReservationTx) InsertAllocation(allocation NewAllocation) (Allocati } type fakeAllocationTx struct { - idleSeconds int64 - canRetainEnvironment func(Allocation) (bool, error) - t testing.TB - loadActivity func(Allocation) (Activity, error) - loadSuspensionDemand func(Allocation) (SuspensionDemand, error) - setCompute func(Allocation, ComputeChange) (Allocation, error) - loadAllocation func() (Allocation, error) - loadEnvironmentDevice func() (EnvironmentDevice, bool, error) - settleCreation func(Allocation) (Allocation, error) - release func(Allocation) (Allocation, error) + idleSeconds int64 + canRetainEnvironment func(Allocation) (bool, error) + t testing.TB + loadActivity func(Allocation) (Activity, error) + loadSuspensionDemand func(Allocation) (SuspensionDemand, error) + setCompute func(Allocation, ComputeChange) (Allocation, error) + loadAllocation func() (Allocation, error) + loadEnvironmentDevice func() (EnvironmentDevice, bool, error) + settleCreation func(Allocation) (Allocation, error) + release func(Allocation) (Allocation, error) } func (f *fakeAllocationTx) LoadAllocation() (Allocation, error) { diff --git a/services/core/internal/sessions/transaction_test.go b/services/core/internal/sessions/transaction_test.go index bcc397699..06147254f 100644 --- a/services/core/internal/sessions/transaction_test.go +++ b/services/core/internal/sessions/transaction_test.go @@ -39,7 +39,7 @@ type fakeTx struct { loadPendingFileWrite func() (bool, error) loadBoundDevice func() (bool, error) insertEnvironmentDevice func() error - loadEnvironmentDevice func() (ExecutionDevice, bool, error) + loadEnvironmentDevice func() (ExecutionDevice, bool, error) claimInitialization func() (bool, error) completeInitialization func() (bool, error) failInitialization func() error From 8800f2de632efddd6ffa49d614c4e4f43135a7b6 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 10:35:15 +0000 Subject: [PATCH 4/9] Lock workspace work through the Environment lock; fix store fixtures for the Environment-owned rows --- .../core/internal/db/queries/workspaces.sql | 7 ---- .../core/internal/db/sqlc/environments.sql.go | 4 +-- .../core/internal/db/sqlc/workspaces.sql.go | 28 --------------- .../core/internal/deployment/replacement.go | 13 ++++--- .../runtime_preparation_capacity_test.go | 2 +- .../postgres/deploymentpg/allocations.go | 2 +- .../postgres/deploymentpg/allocations_test.go | 4 +-- .../deploymentpg/checkpoint_transfer_test.go | 2 +- .../deploymentpg/placement_demand_test.go | 6 ++-- .../postgres/deploymentpg/replacement_test.go | 2 +- .../deploymentpg/suspension_pressure_test.go | 4 +-- .../postgres/runtimehistorypg/samples_test.go | 2 +- .../postgres/sessionpg/creation_test.go | 12 +++---- .../sessionpg/execution_environment.go | 6 ++-- .../postgres/sessionpg/frozen_test.go | 32 ++++++++--------- .../postgres/sessionpg/session_test.go | 12 +++---- .../postgres/workspacepg/capacity_test.go | 6 ++-- .../persistence/postgres/workspacepg/store.go | 34 +++++++------------ .../postgres/workspacepg/store_test.go | 2 +- .../tests/integration/sandbox_reset_test.go | 2 +- 20 files changed, 73 insertions(+), 109 deletions(-) diff --git a/services/core/internal/db/queries/workspaces.sql b/services/core/internal/db/queries/workspaces.sql index b8b42cdd9..fcf6556ed 100644 --- a/services/core/internal/db/queries/workspaces.sql +++ b/services/core/internal/db/queries/workspaces.sql @@ -41,13 +41,6 @@ UPDATE workspace_fs_configurations SET active = true WHERE id = $1; -- name: GetActiveWorkspaceConfiguration :one SELECT * FROM workspace_fs_configurations WHERE active; --- name: LockWorkspaceEnvironment :one -SELECT e.id FROM environments e WHERE e.tenant_id = $1 AND e.id = $2 FOR UPDATE; - --- name: WorkspaceEnvironmentReclaimed :one -SELECT (e.session_owned AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL))::boolean AS reclaimed -FROM environments e WHERE e.id = $1; - -- name: GetEnvironmentWorkspace :one SELECT sqlc.embed(w), sqlc.embed(c), e.tenant_id FROM environment_workspaces w diff --git a/services/core/internal/db/sqlc/environments.sql.go b/services/core/internal/db/sqlc/environments.sql.go index 1206f20a1..9fc72e77a 100644 --- a/services/core/internal/db/sqlc/environments.sql.go +++ b/services/core/internal/db/sqlc/environments.sql.go @@ -26,8 +26,8 @@ func (q *Queries) AttachSessionEnvironment(ctx context.Context, arg AttachSessio } const createEnvironment = `-- name: CreateEnvironment :exec -INSERT INTO environments (id, tenant_id, configuration, initialization) -VALUES ($1, $2, $3, CASE WHEN $4::boolean THEN 'pending' ELSE 'complete' END) +INSERT INTO environments (id, tenant_id, configuration, session_owned, initialization) +VALUES ($1, $2, $3, true, CASE WHEN $4::boolean THEN 'pending' ELSE 'complete' END) ` type CreateEnvironmentParams struct { diff --git a/services/core/internal/db/sqlc/workspaces.sql.go b/services/core/internal/db/sqlc/workspaces.sql.go index 6b832ad31..f134d632b 100644 --- a/services/core/internal/db/sqlc/workspaces.sql.go +++ b/services/core/internal/db/sqlc/workspaces.sql.go @@ -255,22 +255,6 @@ func (q *Queries) LockWorkspaceConfigurations(ctx context.Context) error { return err } -const lockWorkspaceEnvironment = `-- name: LockWorkspaceEnvironment :one -SELECT e.id FROM environments e WHERE e.tenant_id = $1 AND e.id = $2 FOR UPDATE -` - -type LockWorkspaceEnvironmentParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) LockWorkspaceEnvironment(ctx context.Context, arg LockWorkspaceEnvironmentParams) (pgtype.UUID, error) { - row := q.db.QueryRow(ctx, lockWorkspaceEnvironment, arg.TenantID, arg.ID) - var id pgtype.UUID - err := row.Scan(&id) - return id, err -} - const lockWorkspaceTenant = `-- name: LockWorkspaceTenant :exec SELECT pg_advisory_xact_lock(hashtext('oac.workspace-tenant-capacity'), hashtext($1::uuid::text)) ` @@ -375,15 +359,3 @@ func (q *Queries) WorkspaceComputeReleased(ctx context.Context, environmentID pg err := row.Scan(&released) return released, err } - -const workspaceEnvironmentReclaimed = `-- name: WorkspaceEnvironmentReclaimed :one -SELECT (e.session_owned AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL))::boolean AS reclaimed -FROM environments e WHERE e.id = $1 -` - -func (q *Queries) WorkspaceEnvironmentReclaimed(ctx context.Context, id pgtype.UUID) (bool, error) { - row := q.db.QueryRow(ctx, workspaceEnvironmentReclaimed, id) - var reclaimed bool - err := row.Scan(&reclaimed) - return reclaimed, err -} diff --git a/services/core/internal/deployment/replacement.go b/services/core/internal/deployment/replacement.go index 7e731f6f8..6ba842bf6 100644 --- a/services/core/internal/deployment/replacement.go +++ b/services/core/internal/deployment/replacement.go @@ -102,9 +102,14 @@ func (e *ExecutionOperations) EnsurePlacement(ctx context.Context, key Allocatio // allocation admission and capacity-pressure decisions. Retained files require // another external-storage generation; new Sessions may select either kind. func (e *ExecutionOperations) eligibleNodes(d placement.Deployment, nodes []placement.Node, engineKind string, retained bool, loadGeneration func(uint64) (GenerationSpecification, error)) ([]placement.Node, error) { - declaration, known := e.harnesses.Lookup(engineKind) - if !known { - return nil, sessions.ErrInvalidInput + // An Environment with no attached Session has no Harness and no native history to retain. + historySupported := true + if engineKind != "" { + declaration, known := e.harnesses.Lookup(engineKind) + if !known { + return nil, sessions.ErrInvalidInput + } + historySupported = declaration.Capabilities.RetainedNativeHistory.IsSupported() } compatible := make(map[uint64]bool) filtered := make([]placement.Node, 0, len(nodes)) @@ -129,7 +134,7 @@ func (e *ExecutionOperations) eligibleNodes(d placement.Deployment, nodes []plac if err := json.Unmarshal(specification, &spec); err != nil { return nil, err } - accepts = (!retained || spec.Workspace != nil) && sessions.ValidateRetainedHistory(spec.Workspace != nil, declaration.Capabilities.RetainedNativeHistory.IsSupported()) == nil + accepts = (!retained || spec.Workspace != nil) && sessions.ValidateRetainedHistory(spec.Workspace != nil, historySupported) == nil compatible[generation] = accepts } if accepts { diff --git a/services/core/internal/execution/runtime_preparation_capacity_test.go b/services/core/internal/execution/runtime_preparation_capacity_test.go index 6d61f3ca9..50965b93f 100644 --- a/services/core/internal/execution/runtime_preparation_capacity_test.go +++ b/services/core/internal/execution/runtime_preparation_capacity_test.go @@ -65,7 +65,7 @@ func TestWorkspaceQuotaRefusalYieldsComputeToAnotherTenant(t *testing.T) { if _, err := f.pool.Exec(t.Context(), `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) VALUES($1,$2,'codex',$3,'fixture','{"environment":{"type":"openai_hosted"}}')`, storageSession, a.TenantID, uuid.NewString()); err != nil { t.Fatal(err) } - if _, err := f.pool.Exec(t.Context(), `INSERT INTO environments(id,session_id) VALUES($1,$2)`, storageEnvironment, storageSession); err != nil { + if _, err := f.pool.Exec(t.Context(), `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned) SELECT $1, tenant_id, configuration->'environment', true FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, storageEnvironment, storageSession); err != nil { t.Fatal(err) } occupied, err := writer.Bind(t.Context(), a.TenantID, storageEnvironment, f.control.configuration.ID, capacity) diff --git a/services/core/internal/persistence/postgres/deploymentpg/allocations.go b/services/core/internal/persistence/postgres/deploymentpg/allocations.go index ed6edc0ce..d0fd93cf7 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/allocations.go +++ b/services/core/internal/persistence/postgres/deploymentpg/allocations.go @@ -223,7 +223,7 @@ func (t *reservationTx) ReservePlacement(p placement.Placement) error { return err } if reserved.NodeID == "" { - return placementpg.ReservePlacement(t.ctx, t.q, t.session, p) + return placementpg.ReservePlacement(t.ctx, t.q, t.environment, p) } node, err := parseID(p.NodeID) if err != nil { diff --git a/services/core/internal/persistence/postgres/deploymentpg/allocations_test.go b/services/core/internal/persistence/postgres/deploymentpg/allocations_test.go index f25a04de8..c13a6cefd 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/allocations_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/allocations_test.go @@ -25,7 +25,7 @@ func hostedEnvironment(t *testing.T, pool *pgxpool.Pool) deployment.AllocationKe VALUES ($1, $2, 'codex', 'key', 'hash', '{"environment":{"type":"openai_hosted"}}')`, session, tenant); err != nil { t.Fatal(err) } - if _, err := pool.Exec(t.Context(), `INSERT INTO environments(id, session_id) VALUES ($1, $2)`, environment, session); err != nil { + if _, err := pool.Exec(t.Context(), `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned) SELECT $1, tenant_id, configuration->'environment', true FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, environment, session); err != nil { t.Fatal(err) } return deployment.AllocationKey{TenantID: tenant.String(), EnvironmentID: environment.String()} @@ -46,7 +46,7 @@ func allocationRows(t *testing.T, f fixture, key deployment.AllocationKey) (allo COALESCE((SELECT state FROM runtime_allocations WHERE environment_id = e.id), ''), COALESCE((SELECT revoked_at IS NOT NULL FROM devices WHERE environment_id = e.id), false), e.status, - (SELECT count(*) FROM session_events WHERE session_id = e.session_id) + (SELECT count(*) FROM session_events WHERE session_id IN (SELECT id FROM sessions WHERE environment_id = e.id)) FROM environments e WHERE e.id = $1`, key.EnvironmentID).Scan(&allocations, &state, &revoked, &status, &changes) if err != nil { t.Fatal(err) diff --git a/services/core/internal/persistence/postgres/deploymentpg/checkpoint_transfer_test.go b/services/core/internal/persistence/postgres/deploymentpg/checkpoint_transfer_test.go index b61ed0a35..8c3f6ea44 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/checkpoint_transfer_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/checkpoint_transfer_test.go @@ -136,7 +136,7 @@ func TestCheckpointTransferRequiresCompatibleGenerationAndCapacity(t *testing.T) arg = owner.ID want = deployment.ErrAllocationConflict case "deleted": - statement = `UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=(SELECT session_id FROM environments WHERE id=$1)` + statement = `UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=(SELECT id FROM sessions WHERE environment_id=$1 AND deleted_at IS NULL)` arg = owner.EnvironmentID want = sessions.ErrNotFound case "epoch": diff --git a/services/core/internal/persistence/postgres/deploymentpg/placement_demand_test.go b/services/core/internal/persistence/postgres/deploymentpg/placement_demand_test.go index f98086397..b3dbf6afe 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/placement_demand_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/placement_demand_test.go @@ -69,7 +69,7 @@ func TestPlacementDemandPagesByOriginalDemandTime(t *testing.T) { for i := 0; i < 40; i++ { key := pendingHostedEnvironment(t, f) // UUIDs are random; demand order must follow durable acceptance time. - if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET created_at=$2 WHERE id=(SELECT session_id FROM environments WHERE id=$1)`, key.EnvironmentID, at.Add(time.Duration(i)*time.Second)); err != nil { + if _, err := f.pool.Exec(t.Context(), `UPDATE environments SET created_at=$2 WHERE id=$1`, key.EnvironmentID, at.Add(time.Duration(i)*time.Second)); err != nil { t.Fatal(err) } } @@ -101,7 +101,7 @@ func TestFirstPlacementChecksActualGenerationHistorySupport(t *testing.T) { node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1}) f.connect(t, node.NodeID) key := pendingHostedEnvironment(t, f) - if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET engine='mcode' WHERE id=(SELECT session_id FROM environments WHERE id=$1)`, key.EnvironmentID); err != nil { + if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET engine='mcode' WHERE id=(SELECT id FROM sessions WHERE environment_id=$1 AND deleted_at IS NULL)`, key.EnvironmentID); err != nil { t.Fatal(err) } if _, err := changes.EnsurePlacement(t.Context(), key, installation); !errors.Is(err, placement.ErrNodeUnavailable) { @@ -111,7 +111,7 @@ func TestFirstPlacementChecksActualGenerationHistorySupport(t *testing.T) { if err != nil || len(pending) != 1 { t.Fatal(pending, err) } - if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET engine='codex' WHERE id=(SELECT session_id FROM environments WHERE id=$1)`, key.EnvironmentID); err != nil { + if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET engine='codex' WHERE id=(SELECT id FROM sessions WHERE environment_id=$1 AND deleted_at IS NULL)`, key.EnvironmentID); err != nil { t.Fatal(err) } if _, err := changes.EnsurePlacement(t.Context(), key, installation); err != nil { diff --git a/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go b/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go index 89915cb19..28a79affb 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go @@ -290,7 +290,7 @@ func TestResetArchivesRetainedSessionBeforeClearingDeployment(t *testing.T) { if err != nil || started.Reset == nil { t.Fatal(started, err) } - if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET created_at=$2 WHERE id=$1`, retained[4].AttachedSessionID, started.Reset.RequestedAt.Add(time.Second)); err != nil { + if _, err := f.pool.Exec(t.Context(), `UPDATE environments SET created_at=$2 WHERE id=$1`, retained[4].EnvironmentID, started.Reset.RequestedAt.Add(time.Second)); err != nil { t.Fatal(err) } candidates, err := f.adapter.ResetSessions(t.Context(), "", true) diff --git a/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go b/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go index aba54d3a3..ee80e6987 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go @@ -204,7 +204,7 @@ func TestPressureDemandScansPastIncompatiblePage(t *testing.T) { owner := runningPressureOwner(t, f, changes, installation, node.NodeID, view.Generation) for range 32 { key := pendingPressureDemand(t, f) - if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET engine='mcode' WHERE id=(SELECT session_id FROM environments WHERE id=$1)`, key.EnvironmentID); err != nil { + if _, err := f.pool.Exec(t.Context(), `UPDATE sessions SET engine='mcode' WHERE id=(SELECT id FROM sessions WHERE environment_id=$1 AND deleted_at IS NULL)`, key.EnvironmentID); err != nil { t.Fatal(err) } } @@ -295,7 +295,7 @@ func TestPressureReclamationIgnoresUnavailableNodeReceipts(t *testing.T) { f.connect(t, node.NodeID) waiting := pendingPressureDemand(t, f) // A real pending reservation supplies demand and retains its original deadline. - if _, err := f.pool.Exec(t.Context(), `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,settings,created_at,deadline) SELECT $1,session_id,'pressure','[{}]','{}',clock_timestamp(),clock_timestamp()+interval '5 minutes' FROM environments WHERE id=$2`, uuid.NewString(), waiting.EnvironmentID); err != nil { + if _, err := f.pool.Exec(t.Context(), `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,settings,created_at,deadline) SELECT $1,id,'pressure','[{}]','{}',clock_timestamp(),clock_timestamp()+interval '5 minutes' FROM sessions WHERE environment_id=$2`, uuid.NewString(), waiting.EnvironmentID); err != nil { t.Fatal(err) } owner := runningPressureOwner(t, f, changes, installation, node.NodeID, view.Generation) diff --git a/services/core/internal/persistence/postgres/runtimehistorypg/samples_test.go b/services/core/internal/persistence/postgres/runtimehistorypg/samples_test.go index 195581a7c..5028c34ed 100644 --- a/services/core/internal/persistence/postgres/runtimehistorypg/samples_test.go +++ b/services/core/internal/persistence/postgres/runtimehistorypg/samples_test.go @@ -34,7 +34,7 @@ func historyOwner(t *testing.T, pool *pgxpool.Pool) runtimehistory.Scope { if _, err := pool.Exec(t.Context(), `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash) VALUES ($1, $2, 'codex', 'key', 'hash')`, scope.SessionID, scope.TenantID); err != nil { t.Fatal(err) } - if _, err := pool.Exec(t.Context(), `INSERT INTO environments(id, session_id) VALUES ($1, $2)`, scope.EnvironmentID, scope.SessionID); err != nil { + if _, err := pool.Exec(t.Context(), `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned) SELECT $1, tenant_id, COALESCE(configuration->'environment', '{"type":"openai_hosted"}'), true FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, scope.EnvironmentID, scope.SessionID); err != nil { t.Fatal(err) } return scope diff --git a/services/core/internal/persistence/postgres/sessionpg/creation_test.go b/services/core/internal/persistence/postgres/sessionpg/creation_test.go index f3001517b..db43bafdf 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation_test.go @@ -395,8 +395,8 @@ func TestCreationFreezesResourcesOnce(t *testing.T) { t.Fatal("Session configuration", string(created.Session.Configuration)) } sealed := `FROM (SELECT encrypted_config AS b FROM session_model_execution WHERE session_id=$1 - UNION ALL SELECT contents FROM environment_setups WHERE session_id=$1 - UNION ALL SELECT contents FROM initial_environment_files WHERE session_id=$1) frozen` + UNION ALL SELECT contents FROM environment_setups WHERE environment_id=(SELECT environment_id FROM sessions WHERE id=$1) + UNION ALL SELECT contents FROM initial_environment_files WHERE environment_id=(SELECT environment_id FROM sessions WHERE id=$1)) frozen` if countRows(t, pool, "SELECT count(*) "+sealed, id) != 4 || countRows(t, pool, "SELECT count(*) "+sealed+" WHERE position($2::bytea IN b) > 0", id, []byte(canary)) != 0 { t.Fatal("frozen data is missing or not sealed") } @@ -406,12 +406,12 @@ func TestCreationFreezesResourcesOnce(t *testing.T) { if err != nil || provider.APIKey != canary { t.Fatal("frozen provider", err) } - setup, err := store.ReadEnvironmentSetup(ctx, tenant, id) + setup, err := store.ReadEnvironmentSetup(ctx, tenant, created.Session.Environment.ID) if err != nil || len(setup.Skills) != 2 || !reflect.DeepEqual(setup.Skills[0], inline) || setup.Skills[1].Metadata.Version != "2" || !bytes.Equal(setup.Skills[1].Archive, latest) { t.Fatal("frozen Skills", err) } for position := range input.InitialFiles { - if _, body, err := store.ReadInitialEnvironmentFile(ctx, tenant, id, position); err != nil || string(body) != canary { + if _, body, err := store.ReadInitialEnvironmentFile(ctx, tenant, created.Session.Environment.ID, position); err != nil || string(body) != canary { t.Fatal("frozen initial file", position, err) } } @@ -520,7 +520,7 @@ func TestHostedCreationAdmitsUnderDeploymentLockAndLeavesComputeUnreserved(t *te if err != nil || !queued.Created { t.Fatal("creation without available compute was not accepted", queued, err) } - if count := countRows(t, pool, "SELECT count(*) FROM sessions s JOIN environments e ON e.session_id=s.id WHERE s.tenant_id=$1", tenant); count != 2 { + if count := countRows(t, pool, "SELECT count(*) FROM sessions s JOIN environments e ON s.environment_id=e.id WHERE s.tenant_id=$1", tenant); count != 2 { t.Fatal("accepted hosted creation did not preserve both Sessions", count) } if count := countRows(t, pool, "SELECT count(*) FROM runtime_placements WHERE environment_id=$1", queued.Session.Environment.ID); count != 0 { @@ -596,7 +596,7 @@ func TestSkillFreezeSerializesWithVersionDeletion(t *testing.T) { if creation.err != nil { t.Fatal(creation.err) } - setup, err := store.ReadEnvironmentSetup(ctx, tenant, creation.creation.Session.ID) + setup, err := store.ReadEnvironmentSetup(ctx, tenant, creation.creation.Session.Environment.ID) if err != nil || setup.Skills[0].Metadata.Version != "2" || !bytes.Equal(setup.Skills[0].Archive, second) { t.Fatal("frozen version after its deletion", err) } diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_environment.go b/services/core/internal/persistence/postgres/sessionpg/execution_environment.go index 98326c513..c4f353af7 100644 --- a/services/core/internal/persistence/postgres/sessionpg/execution_environment.go +++ b/services/core/internal/persistence/postgres/sessionpg/execution_environment.go @@ -10,7 +10,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -34,7 +33,10 @@ func (e *Execution) withEnvironment(ctx context.Context, tenant, environment str if err != nil { return err } - id := pgunit.PathID(environment) + id, err := parseID(environment) + if err != nil { + return err + } return WithEnvironment(ctx, e.lease, tenantID, id, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession, attached pgtype.UUID) error { return apply(ctx, &environmentTx{SessionTx: BindSession(q, tenantID, attached), environment: id}, locked) }) diff --git a/services/core/internal/persistence/postgres/sessionpg/frozen_test.go b/services/core/internal/persistence/postgres/sessionpg/frozen_test.go index 99cdb5d04..eaf0abe6b 100644 --- a/services/core/internal/persistence/postgres/sessionpg/frozen_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/frozen_test.go @@ -25,8 +25,8 @@ func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { pool := pgtest.Open(t) cipher := pgtest.CredentialKey(t) adapter := New(pgunit.NewPool(pool), cipher) - tenantID, sessionID, _ := newEnvironment(t, pool, "self_hosted", "pending") - tenant, session := uuidText(tenantID), uuidText(sessionID) + tenantID, sessionID, environmentID := newEnvironment(t, pool, "self_hosted", "pending") + tenant, session, environment := uuidText(tenantID), uuidText(sessionID), uuidText(environmentID) binding := credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: "session", OwnerID: session, Field: "initialization"} freeze := func(plaintext string, binding credentialcrypto.EnvironmentSetupBinding) []byte { t.Helper() @@ -37,7 +37,7 @@ func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { return sealed } - if setup, err := adapter.ReadEnvironmentSetup(t.Context(), tenant, session); err != nil || !reflect.DeepEqual(setup, environmentconfig.Setup{}) { + if setup, err := adapter.ReadEnvironmentSetup(t.Context(), tenant, environment); err != nil || !reflect.DeepEqual(setup, environmentconfig.Setup{}) { t.Fatalf("Session without a frozen setup: %+v, %v", setup, err) } for name, ids := range map[string][2]string{"unknown Session": {tenant, uuid.NewString()}, "other tenant": {uuid.NewString(), session}, "malformed Session": {tenant, "session"}} { @@ -46,8 +46,8 @@ func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { } } - exec(t, pool, `INSERT INTO environment_setups(session_id, contents) VALUES ($1, $2)`, sessionID, freeze(`{"env":{"GREETING":"hello"},"packages":{"npm":["left-pad"],"python":[]}}`, binding)) - setup, err := adapter.ReadEnvironmentSetup(t.Context(), tenant, session) + exec(t, pool, `INSERT INTO environment_setups(environment_id, contents) VALUES ($1, $2)`, environmentID, freeze(`{"env":{"GREETING":"hello"},"packages":{"npm":["left-pad"],"python":[]}}`, binding)) + setup, err := adapter.ReadEnvironmentSetup(t.Context(), tenant, environment) if err != nil || setup.Env["GREETING"] != "hello" || !reflect.DeepEqual(setup.Packages.NPM, []string{"left-pad"}) { t.Fatalf("frozen setup %+v, %v", setup, err) } @@ -64,14 +64,14 @@ func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { "empty system packages": freeze(`{"packages":{"npm":[],"python":[],"system":[]}}`, binding), "system packages": freeze(`{"packages":{"npm":[],"python":[],"system":["jq"]}}`, binding), } { - exec(t, pool, `UPDATE environment_setups SET contents = $2 WHERE session_id = $1`, sessionID, contents) - if _, err := adapter.ReadEnvironmentSetup(t.Context(), tenant, session); !corruptFrozenData(err) { + exec(t, pool, `UPDATE environment_setups SET contents = $2 WHERE environment_id = $1`, environmentID, contents) + if _, err := adapter.ReadEnvironmentSetup(t.Context(), tenant, environment); !corruptFrozenData(err) { t.Fatalf("%s: %v", name, err) } } exec(t, pool, `UPDATE sessions SET deleted_at = clock_timestamp() WHERE id = $1`, sessionID) - if _, err := adapter.ReadEnvironmentSetup(t.Context(), tenant, session); !errors.Is(err, sessions.ErrNotFound) { + if _, err := adapter.ReadEnvironmentSetup(t.Context(), tenant, environment); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("deleted Session: %v", err) } } @@ -80,8 +80,8 @@ func TestReadInitialEnvironmentFileClassifiesFrozenData(t *testing.T) { pool := pgtest.Open(t) cipher := pgtest.CredentialKey(t) adapter := New(pgunit.NewPool(pool), cipher) - tenantID, sessionID, _ := newEnvironment(t, pool, "self_hosted", "pending") - tenant, session := uuidText(tenantID), uuidText(sessionID) + tenantID, sessionID, environmentID := newEnvironment(t, pool, "self_hosted", "pending") + tenant, session, environment := uuidText(tenantID), uuidText(sessionID), uuidText(environmentID) file := uuid.NewString() binding := credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "session", OwnerID: session, FileID: file} freeze := func(body string, binding credentialcrypto.EnvironmentFileBinding) []byte { @@ -92,16 +92,16 @@ func TestReadInitialEnvironmentFileClassifiesFrozenData(t *testing.T) { } return sealed } - exec(t, pool, `INSERT INTO initial_environment_files(id, session_id, position, path, size_bytes, contents) VALUES ($1, $2, 0, 'notes.txt', 5, $3)`, file, sessionID, freeze("hello", binding)) + exec(t, pool, `INSERT INTO initial_environment_files(id, environment_id, position, path, size_bytes, contents) VALUES ($1, $2, 0, 'notes.txt', 5, $3)`, file, environmentID, freeze("hello", binding)) - metadata, body, err := adapter.ReadInitialEnvironmentFile(t.Context(), tenant, session, 0) + metadata, body, err := adapter.ReadInitialEnvironmentFile(t.Context(), tenant, environment, 0) if err != nil || metadata.ID != file || metadata.Path != "notes.txt" || metadata.SizeBytes == nil || *metadata.SizeBytes != 5 || string(body) != "hello" { t.Fatalf("frozen file %+v %q, %v", metadata, body, err) } - if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), tenant, session, 1); !errors.Is(err, sessions.ErrNotFound) { + if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), tenant, environment, 1); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("missing position: %v", err) } - if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), uuid.NewString(), session, 0); !errors.Is(err, sessions.ErrNotFound) { + if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), uuid.NewString(), environment, 0); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("other tenant: %v", err) } @@ -113,13 +113,13 @@ func TestReadInitialEnvironmentFileClassifiesFrozenData(t *testing.T) { "size mismatch": freeze("hello!", binding), } { exec(t, pool, `UPDATE initial_environment_files SET contents = $2 WHERE id = $1`, file, contents) - if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), tenant, session, 0); !corruptFrozenData(err) { + if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), tenant, environment, 0); !corruptFrozenData(err) { t.Fatalf("%s: %v", name, err) } } exec(t, pool, `UPDATE sessions SET deleted_at = clock_timestamp() WHERE id = $1`, sessionID) - if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), tenant, session, 0); !errors.Is(err, sessions.ErrNotFound) { + if _, _, err := adapter.ReadInitialEnvironmentFile(t.Context(), tenant, environment, 0); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("deleted Session: %v", err) } } diff --git a/services/core/internal/persistence/postgres/sessionpg/session_test.go b/services/core/internal/persistence/postgres/sessionpg/session_test.go index 334f0103a..45ab147e2 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_test.go @@ -39,7 +39,7 @@ func newEnvironment(t *testing.T, pool *pgxpool.Pool, kind, status string) (pgty tenant, session, environment := uuid.New(), uuid.New(), uuid.New() exec(t, pool, `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash, configuration) VALUES ($1, $2, 'codex', 'key', 'hash', jsonb_build_object('environment', jsonb_build_object('type', $3::text)))`, session, tenant, kind) - exec(t, pool, `INSERT INTO environments(id, session_id, status) VALUES ($1, $2, $3)`, environment, session, status) + exec(t, pool, `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned, status) SELECT $1, tenant_id, configuration->'environment', true, $3 FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, environment, session, status) return pgID(tenant), pgID(session), pgID(environment) } @@ -177,10 +177,10 @@ type environmentState struct { func readEnvironmentState(t *testing.T, pool *pgxpool.Pool, session pgtype.UUID) environmentState { t.Helper() var state environmentState - if err := pool.QueryRow(t.Context(), `SELECT e.status, COALESCE((SELECT status FROM turns WHERE session_id = e.session_id), ''), - (SELECT state FROM environment_input_reservations WHERE session_id = e.session_id), - (SELECT count(*) FROM session_events WHERE session_id = e.session_id) - FROM environments e WHERE e.session_id = $1`, session).Scan(&state.environment, &state.turn, &state.input, &state.events); err != nil { + if err := pool.QueryRow(t.Context(), `SELECT e.status, COALESCE((SELECT status FROM turns WHERE session_id = s.id), ''), + (SELECT state FROM environment_input_reservations WHERE session_id = s.id), + (SELECT count(*) FROM session_events WHERE session_id = s.id) + FROM sessions s JOIN environments e ON e.id = s.environment_id WHERE s.id = $1`, session).Scan(&state.environment, &state.turn, &state.input, &state.events); err != nil { t.Fatal(err) } return state @@ -221,7 +221,7 @@ func TestFailEnvironmentThroughTheBindingCommitsWithItsTransaction(t *testing.T) t.Fatalf("committed %+v", got) } var failedAt time.Time - if err := pool.QueryRow(t.Context(), `SELECT failed_at FROM environments WHERE session_id = $1`, session).Scan(&failedAt); err != nil { + if err := pool.QueryRow(t.Context(), `SELECT e.failed_at FROM environments e JOIN sessions s ON s.environment_id = e.id WHERE s.id = $1`, session).Scan(&failedAt); err != nil { t.Fatal(err) } _, changes := journal(t, pool, session) diff --git a/services/core/internal/persistence/postgres/workspacepg/capacity_test.go b/services/core/internal/persistence/postgres/workspacepg/capacity_test.go index 49c49f8e8..6b5f5f222 100644 --- a/services/core/internal/persistence/postgres/workspacepg/capacity_test.go +++ b/services/core/internal/persistence/postgres/workspacepg/capacity_test.go @@ -78,7 +78,7 @@ func (f fixture) bindTenant(t *testing.T, tenant string, capacity uint32) (works VALUES ($1, $2, 'codex', $3, 'hash', '{"environment":{"type":"openai_hosted"}}')`, session, tenant, uuid.NewString()); err != nil { t.Fatal(err) } - if _, err := f.pool.Exec(t.Context(), `INSERT INTO environments(id, session_id) VALUES ($1, $2)`, environment, session); err != nil { + if _, err := f.pool.Exec(t.Context(), `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned) SELECT $1, tenant_id, configuration->'environment', true FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, environment, session); err != nil { t.Fatal(err) } record, err := f.execution.Bind(t.Context(), tenant, environment, *f.active, capacity) @@ -127,11 +127,11 @@ func TestTenantCapacityAdmission(t *testing.T) { } var creating, ready workspaces.Record var session string - if err := f.pool.QueryRow(t.Context(), `SELECT e.session_id FROM environment_workspaces w JOIN environments e ON e.id=w.environment_id JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1 LIMIT 1`, tenant).Scan(&session); err != nil { + if err := f.pool.QueryRow(t.Context(), `SELECT s.id FROM environment_workspaces w JOIN environments e ON e.id=w.environment_id JOIN sessions s ON s.environment_id=e.id WHERE s.tenant_id=$1 LIMIT 1`, tenant).Scan(&session); err != nil { t.Fatal(err) } var environment string - if err := f.pool.QueryRow(t.Context(), `SELECT id FROM environments WHERE session_id=$1`, session).Scan(&environment); err != nil { + if err := f.pool.QueryRow(t.Context(), `SELECT environment_id FROM sessions WHERE id=$1`, session).Scan(&environment); err != nil { t.Fatal(err) } creating, err := f.store.Get(t.Context(), tenant, environment) diff --git a/services/core/internal/persistence/postgres/workspacepg/store.go b/services/core/internal/persistence/postgres/workspacepg/store.go index 9126fe12c..8d30e339e 100644 --- a/services/core/internal/persistence/postgres/workspacepg/store.go +++ b/services/core/internal/persistence/postgres/workspacepg/store.go @@ -17,6 +17,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspaces" ) @@ -189,12 +191,7 @@ func (e *Execution) Bind(ctx context.Context, tenant, environment, configuration return workspaces.Record{}, workspacefs.ErrInvalid } var result workspaces.Record - err = e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - deleted, err := lockEnvironment(ctx, q, tenant, environment) - if err != nil { - return err - } + err = e.withEnvironment(ctx, tenant, environment, func(ctx context.Context, q *sqlc.Queries, deleted bool) error { if deleted { return workspaces.ErrConflict } @@ -321,12 +318,7 @@ func (e *Execution) change(ctx context.Context, ref workspacefs.Reference, apply if err := ref.Validate(); err != nil { return err } - return e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - deleted, err := lockEnvironment(ctx, q, ref.TenantID, ref.EnvironmentID) - if err != nil { - return err - } + return e.withEnvironment(ctx, ref.TenantID, ref.EnvironmentID, func(ctx context.Context, q *sqlc.Queries, deleted bool) error { current, err := get(ctx, q, e.credentials, ref.TenantID, ref.EnvironmentID) if err != nil { return err @@ -338,16 +330,16 @@ func (e *Execution) change(ctx context.Context, ref workspacefs.Reference, apply }) } -func lockEnvironment(ctx context.Context, q *sqlc.Queries, tenant, environment string) (bool, error) { - id, err := q.LockWorkspaceEnvironment(ctx, sqlc.LockWorkspaceEnvironmentParams{TenantID: pgunit.PathID(tenant), ID: pgunit.PathID(environment)}) - if errors.Is(err, pgx.ErrNoRows) { - return false, workspaces.ErrNotFound - } - if err != nil { - return false, err +// withEnvironment runs apply under the Environment lock, after the attached +// Session's, and reports whether a Session-owned Environment lost its Session. +func (e *Execution) withEnvironment(ctx context.Context, tenant, environment string, apply func(context.Context, *sqlc.Queries, bool) error) error { + err := sessionpg.WithEnvironment(ctx, e.lease, pgunit.PathID(tenant), pgunit.PathID(environment), func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession, _ pgtype.UUID) error { + return apply(ctx, q, locked.Deleted) + }) + if errors.Is(err, sessions.ErrNotFound) { + return workspaces.ErrNotFound } - // Read after acquiring the Environment lock with a fresh statement snapshot. - return q.WorkspaceEnvironmentReclaimed(ctx, id) + return err } func get(ctx context.Context, q *sqlc.Queries, c credentials, tenant, environment string) (workspaces.Record, error) { diff --git a/services/core/internal/persistence/postgres/workspacepg/store_test.go b/services/core/internal/persistence/postgres/workspacepg/store_test.go index cf093863d..507565fde 100644 --- a/services/core/internal/persistence/postgres/workspacepg/store_test.go +++ b/services/core/internal/persistence/postgres/workspacepg/store_test.go @@ -125,7 +125,7 @@ func (f fixture) environment(t *testing.T) (string, string, string) { VALUES ($1, $2, 'codex', 'key', 'hash', '{"environment":{"type":"openai_hosted"}}')`, session, tenant); err != nil { t.Fatal(err) } - if _, err := f.pool.Exec(t.Context(), `INSERT INTO environments(id, session_id) VALUES ($1, $2)`, environment, session); err != nil { + if _, err := f.pool.Exec(t.Context(), `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned) SELECT $1, tenant_id, configuration->'environment', true FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, environment, session); err != nil { t.Fatal(err) } return tenant, session, environment diff --git a/services/core/tests/integration/sandbox_reset_test.go b/services/core/tests/integration/sandbox_reset_test.go index b20812d44..fdd747330 100644 --- a/services/core/tests/integration/sandbox_reset_test.go +++ b/services/core/tests/integration/sandbox_reset_test.go @@ -323,7 +323,7 @@ func TestSandboxResetPaginationSkipsBusyPrefixAndPreservesSelfHosted(t *testing. // IDs are immutable API identities, so seed this bounded scheduling fixture // directly instead of modifying an existing Session primary key. runtimeSuspensionSQL(t, s.pool, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) SELECT $1::uuid,tenant_id,engine,$1::text,$1::text,configuration FROM sessions WHERE id=$2`, id, session.ID) - runtimeSuspensionSQL(t, s.pool, `INSERT INTO environments(id,session_id) VALUES($1,$2)`, uuid.NewString(), id) + runtimeSuspensionSQL(t, s.pool, `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned) SELECT $1, tenant_id, configuration->'environment', true FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, uuid.NewString(), id) if i <= 34 { runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'in_progress','{}')`, uuid.NewString(), id) } From 0f724196efc228ca630890803ec410b281c89ef2 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 10:36:17 +0000 Subject: [PATCH 5/9] Document the Environment-owned lifecycle and the Harness check at executor preparation --- contracts/agents-api/environments.md | 7 +++---- contracts/agents-api/session-diagnostics.md | 2 +- contracts/agents-api/zh/environments.md | 9 ++++----- contracts/agents-api/zh/session-diagnostics.md | 4 ++-- docs/sandbox-provider.md | 2 +- docs/zh/sandbox-provider.md | 4 ++-- services/core/IMPLEMENTATION.md | 8 ++++---- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 96a7c3973..83755e73c 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -54,7 +54,7 @@ stateDiagram-v2 Session environment events carry Session and Environment identity and an optional Turn identity, and never configuration, credentials, registration IDs or revisions. A Session's `required_actions` can contain `{type: "environment_connection", environment_id}`, separate from function calls. Environment connection, Session status and Turn status are independent. -Core creates the Environment record in the Session creation transaction; the Session upsert picks the retry winner, and retries never create or repair an Environment. The Environment derives its Project and immutable configuration from the owning Session. Its first state is `pending`; a `self_hosted` Environment reads `disconnected` until its first connection. After the Session is deleted, reads hide the Environment while Core keeps the record for settlement and cleanup. A Session with `none` has no Environment. +Core creates the Environment record in the Session creation transaction; the Session upsert picks the retry winner, and retries never create or repair an Environment. The Environment row holds its own Project, immutable configuration and preparation input, and the Session names it; at most one live Session is attached to an Environment. Its first state is `pending`; a `self_hosted` Environment reads `disconnected` until its first connection. After the Session is deleted, reads hide the Environment while Core keeps the record for settlement and cleanup. A Session with `none` has no Environment. A managed outer Environment must exclude broader application credentials and other tenants’ secrets. @@ -135,7 +135,7 @@ stateDiagram-v2 - Deadlines are checked after taking the Session lock. Expiration and targeted cancellation keep their terminal identity, and a retry of a terminal reservation cannot affect a later reservation or Turn. - Initial reservations fail the Session when they expire; later ones return the Session to idle. Failure events capture the settled activity and Usage atomically. A late connection or a creation retry cannot reset or replay expired input. -A Runtime `failed` result with `preparation_failed` and no Run settles the pending input at once with `runtime_preparation_failed`. So does a rejection of the current `execution_prepare` with no Run and code `invalid_configuration`, `unsupported_configuration` or `unsupported_preparation`. Core records a safe Session failure before any Turn exists and releases the input gate; after the local cause is fixed, new input is accepted. Transport loss, capacity rejection and unconfirmed cleanup stay retryable within the original deadline. Core uses these common control states, never Harness-specific error text. +A Runtime `failed` result with `preparation_failed` and no Run settles the pending input at once with `runtime_preparation_failed`. So does a rejection of the current `execution_prepare` with no Run and code `invalid_configuration`, `unsupported_configuration` or `unsupported_preparation`. Core records a safe Session failure before any Turn exists and releases the input gate; after the local cause is fixed, new input is accepted. Transport loss, capacity rejection and unconfirmed cleanup stay retryable within the original deadline. A Runtime that lacks the attached Session's Harness fails that input the same way when its executor is prepared; initialization checks no Harness. Core uses these common control states, never Harness-specific error text. ### Activity and required actions @@ -230,7 +230,7 @@ stateDiagram-v2 ``` - The Worker's initialization scheduler scans 32 Environments at a time, wraps at the end and bounds concurrent preparations by execution concurrency, independently of Provider maintenance. -- A missing socket does not consume a pending attempt. An unavailable Harness fails before installation. Each operation rechecks current authority and the original socket; completion rechecks the exact binding. +- A missing socket does not consume a pending attempt. Initialization needs no Harness. Each operation rechecks current authority and the original socket; completion rechecks the exact binding. - Each file, Skill or Plugin transfer has a two-minute staging budget. Configure, package installation, setup commands and snapshot finalization share the whole initialization’s 30-minute budget, whose remaining duration Core carries on each operation and the Runtime enforces. Initial input keeps its five-minute admission deadline, so large installations should start from an idle Session. - A running initialization whose owner is lost, including across a Core restart, fails as unconfirmed; nothing is replayed. A completed Environment never reinstalls on reconnect or native recovery, so later user changes survive. - Failure is terminal for the Session but destroys neither compute nor files. @@ -245,7 +245,6 @@ A failed initialization records the events and Session state that [Environment i | npm packages, confirmed exit 1–255 | `Failed to provision environment: script "npm package installation" failed with exit code N` | | Initial file installation, confirmed failure | `Failed to provision environment: initial file installation failed` | | Skill preparation, confirmed failure | `Failed to provision environment: Skill installation failed` | -| Harness not installed on the Runtime | `Failed to prepare environment: the selected Harness is unavailable. Install the supported Harness version on the Runtime and create a new Session.` | | Anything else: timeouts, unknown effects, missing or malformed receipts, Plugin installation, snapshot finalization, bootstrap rejection, Core restart | `Failed to provision environment: initialization did not complete` | Every initialization operation returns a typed `rejected`, `failed` or `unknown` outcome, and the daemon confirms process exit and I/O settlement first. Core composes the reason from a fixed label and integers, so commands, env values, package names, paths and process output never reach the reason, events, logs or responses. The failed step is not retried and later steps do not run. Confidential env and setup snapshots are encrypted separately from ordinary metadata. Initial files use the atomic replacing writer and anchored workspace paths on every platform; Files API creation keeps its own no-overwrite rule. diff --git a/contracts/agents-api/session-diagnostics.md b/contracts/agents-api/session-diagnostics.md index 63687fa8d..5ad923b6c 100644 --- a/contracts/agents-api/session-diagnostics.md +++ b/contracts/agents-api/session-diagnostics.md @@ -25,7 +25,7 @@ The object is `core.session_diagnostics`, with `session_id`, the official Sessio A hosted provisioning failure takes precedence over input activity, and input activity over the latest root Turn; the public Session response uses the same precedence. Private outcome text, native messages, provider bodies, command text, paths and credentials are never included. Unknown outcome codes become `internal_error`; no prefix matching or reason-text parsing is used. -Provisioning parameters come from the confirmed structured receipt, persisted in the failure transaction that settles the Environment and its input and records the events. `step` is `setup`, `python`, `npm`, `file`, `skill`, `harness` or null. `index` is a nonnegative JSON-safe integer for setup only, otherwise null. `exit_code` is 1 to 255 for script steps, otherwise null. Details that were not recorded remain null. These private fields do not change the public failure reason or SSE. +Provisioning parameters come from the confirmed structured receipt, persisted in the failure transaction that settles the Environment and its input and records the events. `step` is `setup`, `python`, `npm`, `file`, `skill` or null. `index` is a nonnegative JSON-safe integer for setup only, otherwise null. `exit_code` is 1 to 255 for script steps, otherwise null. Details that were not recorded remain null. These private fields do not change the public failure reason or SSE. `category` is set only when `step` is null, for a failure whose public reason is the generic `initialization did not complete`, and is otherwise null: diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 743e5f6d3..a513e48d5 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: a9d64e2e8a51a00f11071e1f5255814f6340c355554e6a0a3c9262ca9415e5df +source_hash: 34671da036d80359e11db48fb32d1613b8b1a290fb9dd16108fcfd6e2681eeaa --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -56,7 +56,7 @@ stateDiagram-v2 Session environment 事件携带 Session 和 Environment 的身份标识以及可选的 Turn 身份标识,绝不携带配置、凭据、注册 ID 或修订号。Session 的 `required_actions` 可以包含 `{type: "environment_connection", environment_id}`,这与函数调用相互独立。Environment 连接、Session 状态和 Turn 状态相互独立。 -Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会选出重试的胜者,而重试绝不创建或修复 Environment。Environment 从所属 Session 派生其项目和不可变配置。其首个状态为 `pending`;`self_hosted` Environment 在首次连接前读作 `disconnected`。Session 删除后,读取会隐藏 Environment,而 Core 仍保留该记录,用于结算和清理。Session 为 `none` 时没有 Environment。 +Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会选出重试的胜者,而重试绝不创建或修复 Environment。Environment 记录自己持有项目、不可变配置和准备输入,由 Session 引用它;一个 Environment 至多挂载一个存活的 Session。其首个状态为 `pending`;`self_hosted` Environment 在首次连接前读作 `disconnected`。Session 删除后,读取会隐藏 Environment,而 Core 仍保留该记录,用于结算和清理。Session 为 `none` 时没有 Environment。 托管的外层 Environment 必须排除权限范围更广的应用程序凭据和其他租户的机密。 @@ -137,7 +137,7 @@ stateDiagram-v2 - 截止时间在取得 Session 锁后检查。过期和定向取消会保留其终止身份;终止预留的重试不能影响后续预留或 Turn。 - 初始预留过期时会使 Session 失败;后续预留则会让 Session 回到空闲状态。失败事件会原子地捕获已结算的活动和 Usage。迟到的连接或创建重试不能重置或重播已过期的输入。 -当 Runtime 返回 `failed` 结果、带有 `preparation_failed` 且没有 Run 时,会立即以 `runtime_preparation_failed` 结算待处理输入。当当前 `execution_prepare` 在没有 Run 且代码为 `invalid_configuration`、`unsupported_configuration` 或 `unsupported_preparation` 时被拒绝,也同样如此。在任何 Turn 存在之前,Core 会记录安全的 Session 失败并释放输入门控;修复本地原因后即可接受新输入。传输丢失、容量拒绝和未确认的清理在原始期限内保持可重试。Core 使用这些通用控制状态,绝不使用 Harness 特定的错误文本。 +当 Runtime 返回 `failed` 结果、带有 `preparation_failed` 且没有 Run 时,会立即以 `runtime_preparation_failed` 结算待处理输入。当当前 `execution_prepare` 在没有 Run 且代码为 `invalid_configuration`、`unsupported_configuration` 或 `unsupported_preparation` 时被拒绝,也同样如此。在任何 Turn 存在之前,Core 会记录安全的 Session 失败并释放输入门控;修复本地原因后即可接受新输入。传输丢失、容量拒绝和未确认的清理在原始期限内保持可重试。Runtime 缺少所挂载 Session 的 Harness 时,会在其 executor 准备阶段以同样方式使该输入失败;初始化不检查 Harness。Core 使用这些通用控制状态,绝不使用 Harness 特定的错误文本。 ### 活动与必需操作 {#activity-and-required-actions} @@ -232,7 +232,7 @@ stateDiagram-v2 ``` - Worker 的初始化调度器每次扫描 32 个 Environment,在末尾循环回绕,并依据执行并发度限制并发准备,且独立于提供商维护。 -- 缺少套接字不会消耗一次 pending 尝试。Harness 不可用时,会在安装前失败。每个操作都会重新检查当前权限和原始套接字;完成时还会重新检查精确绑定。 +- 缺少套接字不会消耗一次 pending 尝试。初始化不需要 Harness。每个操作都会重新检查当前权限和原始套接字;完成时还会重新检查精确绑定。 - 每个文件、Skill 或 Plugin 传输的暂存预算为两分钟。configure、软件包安装、设置命令和快照最终确定共享整个初始化过程的 30 分钟预算,Core 在每项操作中携带剩余时长,由 Runtime 执行。初始输入仍保留其五分钟接纳期限,因此大型安装应从空闲 Session 开始。 - 正在运行且所有权丧失的初始化,包括跨 Core 重启丧失所有权,会作为未确认而失败;不会重播任何内容。已完成的 Environment 在重连或原生恢复时绝不会重新安装,因此用户后续更改会保留下来。 - 失败对 Session 而言是终止状态,但不会销毁计算资源或文件。 @@ -247,7 +247,6 @@ stateDiagram-v2 | npm 软件包,已确认退出 1–255 | `Failed to provision environment: script "npm package installation" failed with exit code N` | | 初始文件安装,确认失败 | `Failed to provision environment: initial file installation failed` | | Skill 准备,确认失败 | `Failed to provision environment: Skill installation failed` | -| Runtime 上未安装 Harness | `Failed to prepare environment: the selected Harness is unavailable. Install the supported Harness version on the Runtime and create a new Session.` | | 其他情况:超时、未知效果、回执缺失或格式错误、Plugin 安装、快照最终确定、bootstrap 拒绝、Core 重启 | `Failed to provision environment: initialization did not complete` | 每个初始化操作都会返回有类型的 `rejected`、`failed` 或 `unknown` 结果,并且 daemon 会先确认进程退出和 I/O 结算完成。Core 使用固定标签和整数组成原因,因此命令、env 值、软件包名称、路径和进程输出绝不会进入原因、事件、日志或响应。失败步骤不会重试,后续步骤也不会运行。机密 env 和设置快照会与普通元数据分开加密。初始文件在所有平台上都使用原子替换写入器和工作区锚定路径;Files API 创建则保留其自己的不覆盖规则。 diff --git a/contracts/agents-api/zh/session-diagnostics.md b/contracts/agents-api/zh/session-diagnostics.md index c260d4ffc..423c9598e 100644 --- a/contracts/agents-api/zh/session-diagnostics.md +++ b/contracts/agents-api/zh/session-diagnostics.md @@ -1,7 +1,7 @@ --- title: "Session 诊断" source: contracts/agents-api/session-diagnostics.md -source_hash: 33b3be90cb3059fd281e318274e8101602787e25fcd19072cf355f9054eff907 +source_hash: a71cc9514a3ba5537752d24f2aa8656604046b0d1b95d9745325bafd25f059e2 --- 管理员通过两个需要 Core 密钥的路由读取 Session 或 Turn 失败的原因,它们报告失败类别和 Item 接收时间。 @@ -27,7 +27,7 @@ source_hash: 33b3be90cb3059fd281e318274e8101602787e25fcd19072cf355f9054eff907 托管配置失败优先于输入活动,输入活动优先于最新根 Turn;公开 Session 响应使用相同优先级。不会包含私有结果文本、原生消息、提供商响应体、命令文本、路径或凭据。未知结果代码变为 `internal_error`;不使用前缀匹配或原因文本解析。 -配置参数来自已确认的结构化回执,在结算 Environment 及其输入并记录事件的失败事务中持久化。`step` 为 `setup`、`python`、`npm`、`file`、`skill`、`harness` 或 null。`index` 仅对 setup 为 JSON 安全的非负整数,其他情况为 null。`exit_code` 对脚本步骤为 1 至 255,其他情况为 null。未记录的详情保持 null。这些私有字段不会改变公开失败原因或 SSE。 +配置参数来自已确认的结构化回执,在结算 Environment 及其输入并记录事件的失败事务中持久化。`step` 为 `setup`、`python`、`npm`、`file`、`skill` 或 null。`index` 仅对 setup 为 JSON 安全的非负整数,其他情况为 null。`exit_code` 对脚本步骤为 1 至 255,其他情况为 null。未记录的详情保持 null。这些私有字段不会改变公开失败原因或 SSE。 `category` 仅在 `step` 为 null 时设置,对应公开原因为通用的 `initialization did not complete` 的失败,其他情况为 null: diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index c673119a5..0c7b21117 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -175,7 +175,7 @@ An adapter implements none of this, but it relies on it. The code-level mechanic - Core releases an allocation only after confirmed cleanup of its compute and volumes and proof that the original `Create` settled. Until then it keeps cleanup ownership, also after an absence observation, and bounded scans continue to catch late resources without another `Create`. This applies after public Session deletion too. - Core never releases an allocation on an offline node, a timeout or an empty read, and takes no lifecycle action on a node that is not [online](../contracts/agents-api/node-generation-protocol.md#connection). Retained ownership needs exact provider evidence: a socket path, a missing instance or an empty listing never proves cleanup or authorizes a replacement. - Placement is automatic and callers cannot choose a node. An active allocation keeps its original node, even while it is offline. After confirmed release, eligible retained Sessions can reserve compatible capacity again, including on another node. Source teardown must be confirmed before active capacity is released, and unknown operations keep their reservations. The [deployment contract](../contracts/agents-api/sandbox-deployment.md#generation-ownership-and-rollout) owns waiting, ordering and generation selection. -- Between Turns, Core observes that connected compute is still its Session's running allocation. The check changes nothing. Running compute never expires, and an unreachable, stopped or missing instance without [confirmation](#confirmed-compute-loss) never authorizes discarding retained workspace or history or selecting replacement compute. +- While a Session is attached, Core observes between Turns that connected compute is still the Environment's running allocation. The check changes nothing. Running compute never expires, and an unreachable, stopped or missing instance without [confirmation](#confirmed-compute-loss) never authorizes discarding retained workspace or history or selecting replacement compute. - Core suspends the idle allocations of every Provider that declares checkpoint support, under one [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy). - Disabling the provider stops new hosted admission and bootstrap but never blocks cancellation, function results or input retry outcomes of existing Sessions. diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 2d26ae6bd..458650a23 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "沙箱提供商协议" source: docs/sandbox-provider.md -source_hash: 9884514f37032ccc7c97fe0a0a0ddced652e55dfe59d98a55a72ff4e0b75a810 +source_hash: 5b4a649929c3df402d9adb589854b63fbd2f2233bf04cf0872f50f1b85991084 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox、E2B 和 OpenSandbox 实现了该协议。 @@ -177,7 +177,7 @@ adapter 不实现这些行为,但依赖它们。代码层面的机制见 [Core - Core 只有在确认清理 allocation 的计算资源与 volume,并证明原始 `Create` 已结算后才释放 allocation。在此之前它保留清理归属,包括观察到资源不存在之后,有界扫描继续捕获迟到的资源,不再次 `Create`。公开删除 Session 之后同样适用。 - Core 从不在离线 node、超时或空读取时释放 allocation,也不对不[在线](../../contracts/agents-api/zh/node-generation-protocol.md#connection)的 node 执行 lifecycle 操作。保留的归属需要精确的提供商证据:socket 路径、实例缺失或空列表都不证明已清理,也不授权替代。 - Placement 是自动的,调用方不能选择 node。活动 allocation 即使离线也保留原 node。确认释放后,符合条件的保留 Session 可以再次预约兼容容量,包括在其他 node 上。释放 active 容量前必须确认 source 已拆除,未知操作保留其预约。等待、排序和 generation 选择由[部署契约](../../contracts/agents-api/zh/sandbox-deployment.md#generation-ownership-and-rollout)负责。 -- 在 Turn 之间,Core 观察已连接的计算资源仍是其 Session 的 running allocation。该检查不改变任何状态。运行中的计算资源从不到期,没有[确认](#confirmed-compute-loss)的不可达、已停止或缺失实例不授权丢弃保留的工作区或历史,也不授权选择替代计算资源。 +- 挂载了 Session 时,Core 在 Turn 之间观察已连接的计算资源仍是该 Environment 的 running allocation。该检查不改变任何状态。运行中的计算资源从不到期,没有[确认](#confirmed-compute-loss)的不可达、已停止或缺失实例不授权丢弃保留的工作区或历史,也不授权选择替代计算资源。 - Core 在一个[暂停策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)下,暂停每个声明 checkpoint 支持的提供商的空闲 allocation。 - 禁用提供商会停止新的托管准入和引导,但从不阻塞既有 Session 的取消、函数结果或 input 重试结果。 diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 10ff1ad47..dd2a5c989 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -20,7 +20,7 @@ Shared vocabulary has one owner each, and domains use it rather than copy it. `i `internal/sessions` owns the Session vocabulary: Sessions, Turns, inputs, Environments and their provisioning failures, function calls, Item and Artifact reads, executor credentials, and the errors Session operations return, which `api` maps in `writeSessionsError`. It also owns the Session change vocabulary and its decisions: the public changes that report Turn and Session transitions, what a Turn that ends settles, measured Turn usage and the Session activity a change reports. Sequences that several Session operations share are `sessions` procedures, each over the transaction interface it declares: `CancelTurn` and `CancelWork` cancel work, `TrackInputActivity` reports the input activity a write changes, `FailEnvironment` and `TerminateEnvironment` settle an Environment that failed or whose managed compute ended, `CreateEnvironmentDevice` creates and binds a hosted Environment's dedicated Runtime device, `CheckComputeAdmission`, `CheckFileWriteGate` and `CheckInputStart` gate new work, `AdmitInputs` admits a validated input batch in order, each input steering the active Turn, starting a Turn, requesting a cancellation or joining a function result to its call's Turn, `AdmitFunctionResult` saves a submitted function result for its Turn's call, `LoadRequiredActions` lists the calls a Turn waits on, and `TransitionTurn` moves a root Turn's status as its current status allows and settles a Turn that ends. `AppendTurnEvents` records a batch of a Turn's execution observations, which `NewJournalBatch` validates, and `AppendTurnEvent` records its terminal outcome; both project the new entries. `ProjectSource` projects one journal entry or admitted input into Items, the root Turn's usage and Subagents, and `ProjectInput` projects an admitted input. `ValidTransition` decides the Turn status transitions, and the Subagent projection decides each Subagent's identity, lifecycle, child Turn and child Item changes. `sessions.ExecutionOperations` runs the Session writes only the execution owner makes; each function-call and Turn operation loads the Turn in one leased Session transaction, decides in `sessions`, then applies in `sessionpg`, and `AppendTurnEvents` records and projects a Turn's journal batch in one leased Session transaction. `internal/items` owns public Items: it projects observations, merges them into stored Items and builds the ordered events that report each Item change. `internal/persistence/postgres/sessionpg` loads the facts those decisions read and applies them inside the caller's Session transaction, under the Session lock: it allocates event sequence positions, event IDs, Item positions, output indexes and Subagent IDs, writes the change journal, each Turn's execution journal and its counters, Items, Turn usage, Subagent bindings, child Turns and child Items, and Artifact settlement, and prunes the change journal. It decides nothing. `WithSession` runs a Session operation's transaction on the pool or the lease: it locks the Session, applies the operation, then prunes the change journal. `BindSession` binds one Session to a transaction's queries as a `SessionTx`, which implements the procedures' interfaces. -Other adapters call only these `sessionpg` participants, on their own transaction's queries: `LockSession`, which orders a write against the Session's Turns; `BindSession`, which runs a Session procedure, such as `CreateEnvironmentDevice` or `TransitionTurn`, in that transaction; and `LoadEnvironment`, which reads the tenant's Environment of a live Session. In turn, Session creation's transaction in `sessionpg` calls the `placementpg`, `skillpg` and `filepg` participants and `auditpg.RecordWriteAudit` on its own queries. `tests/fixtures` is test tooling that composes procedures over a pooled `WithSession`, which is why `WithSession` stays exported; no production code may do this. `deploymentpg` may import `sessionpg`, and `sessionpg` never imports `deploymentpg`; `placementpg`, which loads the placement facts and reserves placements, sits below both, so either may call it and it imports neither. Likewise `deployment` may compose the `sessions` procedures, and `sessions` never imports `deployment`. +Other adapters call only these `sessionpg` participants, on their own transaction's queries: `LockSession`, which orders a write against the Session's Turns; `WithEnvironment`, which locks the attached Session (if any) and then the Environment, always in that order; `BindSession`, which runs a Session procedure, such as `CreateEnvironmentDevice` or `TransitionTurn`, in that transaction; and `LoadEnvironment`, which reads the tenant's Environment of a live Session. In turn, Session creation's transaction in `sessionpg` calls the `placementpg`, `skillpg` and `filepg` participants and `auditpg.RecordWriteAudit` on its own queries. `tests/fixtures` is test tooling that composes procedures over a pooled `WithSession`, which is why `WithSession` stays exported; no production code may do this. `deploymentpg` may import `sessionpg`, and `sessionpg` never imports `deploymentpg`; `placementpg`, which loads the placement facts and reserves placements, sits below both, so either may call it and it imports neither. Likewise `deployment` may compose the `sessions` procedures, and `sessions` never imports `deployment`. Adapters' execution repositories require a `*pgunit.Lease` at construction, and their public repositories expose no execution operation. @@ -76,7 +76,7 @@ Capture bytes into private large objects without a Session admission lock. Befor ## Scheduling, preparation and pending input -The leased Worker's common preparation scheduler owns Environment initialization, independently of any allocation; managed and enrolled connections use the same frozen snapshots and typed Runtime operations. Preparation has bounded concurrency separate from Turn scheduling, and a blocked Runtime never blocks cleanup or other connections. Check Harness availability before installing. A persisted running initialization whose owner is lost fails without replaying side effects; completion requires the same authorized Environment and device binding, and failure settles pending input while keeping compute ownership and user files. A connection observation never implies completion. +The leased Worker's common preparation scheduler owns Environment initialization, independently of any allocation; managed and enrolled connections use the same frozen snapshots and typed Runtime operations. Preparation has bounded concurrency separate from Turn scheduling, and a blocked Runtime never blocks cleanup or other connections. Initialization checks no Harness, which belongs to the attached Session; its absence from the Runtime fails that Session's input with `runtime_preparation_failed` when the executor is prepared. A persisted running initialization whose owner is lost fails without replaying side effects; completion requires the same authorized Environment and device binding, and failure settles pending input while keeping compute ownership and user files. A connection observation never implies completion. A committed input sends a coalesced hint to the Worker scheduler, which keeps its lease, capacity, cursor fairness and per-Session ownership checks; a hint admits nothing by itself. If capacity is occupied, keep one rescan for completion without turning a failed preparation into a busy retry loop. HTTP readiness waits and active input delivery subscribe before reading state, wake after committed promotion or input and recheck storage after each hint. Polling remains the fallback for external writers, expiry and lost hints; notifications carry no execution authority and no input data. @@ -96,9 +96,9 @@ When next-Turn input is pending on a completed managed allocation that is suspen `services/core/internal/runtimegateway` is Core's daemon connection implementation; its persistence interfaces use `services/core/internal/runtimedevice`, and the frames and validators live in the shared `internal/agentdaemon/proto`. It is a single-process registry: connectivity comes from the live registry, never a persisted online flag, and `last_seen_at` is diagnostic only. Session-to-device bindings are tenant-scoped and immutable. Revocation denies new connections and binding reads at once, and an open connection closes at its next heartbeat. -A dedicated self-hosted device is bound to exactly one Environment's Session and is excluded from general device selection, even within the tenant. Enrollment creates or recovers the device and binding atomically under the Session lock; the frozen workspace and capability directories come from the Session configuration and must match the local binding. Core rechecks the persisted Environment and device binding for preparation and active reads; capability discovery never selects or authorizes a device for this placement. +A dedicated self-hosted device is bound to exactly one Environment's Session and is excluded from general device selection, even within the tenant. Enrollment creates or recovers the device and binding atomically under the Environment lock; the frozen workspace and capability directories come from the Environment configuration and must match the local binding. Core rechecks the persisted Environment and device binding for preparation and active reads; capability discovery never selects or authorizes a device for this placement. -Connection observations use the execution lease and the Session lock. A separate `environment_connections` row holds the current generation and revision, and `environments.status` commits together with its Session Environment event. The producer serializes replacements and numbers socket observations within each generation; duplicate or older revisions and superseded generations are inert, and a replacement retires the previous connected observation before registering the new one. Registration alone creates no `connected` event. Event payloads carry only public Environment identity, type, status and nullable error, never configuration, credentials, registration IDs or revisions, and have no Turn association. `connected` and `disconnected` are distinct from native readiness: never cast resource `expired` into this vocabulary or emit `ready` for a self-hosted connection. On restart the Worker reconciles old observations before admitting new ones, and a failed or stale observation never establishes a connection. +Connection observations use the execution lease and the Environment lock. A separate `environment_connections` row holds the current generation and revision, and `environments.status` commits together with its Session Environment event. The producer serializes replacements and numbers socket observations within each generation; duplicate or older revisions and superseded generations are inert, and a replacement retires the previous connected observation before registering the new one. Registration alone creates no `connected` event. Event payloads carry only public Environment identity, type, status and nullable error, never configuration, credentials, registration IDs or revisions, and have no Turn association. `connected` and `disconnected` are distinct from native readiness: never cast resource `expired` into this vocabulary or emit `ready` for a self-hosted connection. On restart the Worker reconciles old observations before admitting new ones, and a failed or stale observation never establishes a connection. The sandbox node Hub's global mutex protects only in-memory connection state. Authentication, ownership and `deployment` callbacks run synchronously outside it, respect cancellation and have five seconds; database writes are never detached. Closing the Hub cancels opening and live connections without waiting for database callbacks, and each node reservation lasts until its fenced disconnect cleanup finishes. `deploymentpg` registers presence in an explicit transaction, so a canceled statement cannot publish it later through autocommit. Disconnect cleanup first locks the node row, then applies the connection and epoch fence with a fresh READ COMMITTED statement so an in-flight commit is not missed. These transactions never take the deployment-wide manager lock, and online-state writes compare the handshake epoch atomically so a stale Core cannot publish readiness for a new owner. From 9d54e2972f4bcdb9f4155444506905f994ae9d7f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 10:37:11 +0000 Subject: [PATCH 6/9] Test the Environment migration on populated data --- .../first_class_environments_test.go | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 services/core/migrations/first_class_environments_test.go diff --git a/services/core/migrations/first_class_environments_test.go b/services/core/migrations/first_class_environments_test.go new file mode 100644 index 000000000..a6906f8fd --- /dev/null +++ b/services/core/migrations/first_class_environments_test.go @@ -0,0 +1,85 @@ +package migrations_test + +import ( + "context" + "database/sql" + "os" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/lock" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/migrations" +) + +// The Environment migration carries a populated database forward: the Session +// keeps its Environment, which takes over the Project, configuration and +// preparation input the Session held. +func TestFirstClassEnvironmentsMigrationKeepsPopulatedData(t *testing.T) { + admin := pgtest.Open(t) + name := "oac_migration_" + uuid.NewString()[:8] + "_tests" + quoted := pgx.Identifier{name}.Sanitize() + if _, err := admin.Exec(t.Context(), "CREATE DATABASE "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Exec(context.Background(), "DROP DATABASE "+quoted+" WITH (FORCE)") }) + cfg := admin.Config().ConnConfig.Copy() + cfg.Database = name + dsn := stdlib.RegisterConnConfig(cfg) + defer stdlib.UnregisterConnConfig(dsn) + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + locker, err := lock.NewPostgresSessionLocker() + if err != nil { + t.Fatal(err) + } + // The same version table Apply uses, over the same migration files. + provider, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("."), + goose.WithTableName("oac_schema_version"), goose.WithSessionLocker(locker)) + if err != nil { + t.Fatal(err) + } + if _, err := provider.UpTo(t.Context(), 13); err != nil { + t.Fatal(err) + } + + tenant, session, environment := uuid.NewString(), uuid.NewString(), uuid.NewString() + for _, statement := range []string{ + `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash, configuration) VALUES ('` + session + `', '` + tenant + `', 'codex', 'key', 'hash', '{"environment":{"type":"openai_hosted"},"model":"m"}')`, + `INSERT INTO environments(id, session_id) VALUES ('` + environment + `', '` + session + `')`, + `INSERT INTO environment_setups(session_id, contents) VALUES ('` + session + `', '\x01')`, + `INSERT INTO initial_environment_files(id, session_id, position, path, size_bytes, contents) VALUES ('` + uuid.NewString() + `', '` + session + `', 0, 'a.txt', 1, '\x02')`, + } { + if _, err := db.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err, statement) + } + } + if err := migrations.Apply(t.Context(), dsn); err != nil { + t.Fatal(err) + } + + var attached, kind string + var sessionOwned bool + var stored string + if err := db.QueryRowContext(t.Context(), `SELECT s.environment_id::text, e.tenant_id::text || (e.configuration->>'type'), e.session_owned, s.configuration::text + FROM sessions s JOIN environments e ON e.id = s.environment_id WHERE s.id = $1`, session).Scan(&attached, &kind, &sessionOwned, &stored); err != nil { + t.Fatal(err) + } + if attached != environment || kind != tenant+"openai_hosted" || !sessionOwned || stored != `{"model": "m"}` { + t.Fatal(attached, kind, sessionOwned, stored) + } + var setups, files int + if err := db.QueryRowContext(t.Context(), `SELECT (SELECT count(*) FROM environment_setups WHERE environment_id = $1), (SELECT count(*) FROM initial_environment_files WHERE environment_id = $1)`, environment).Scan(&setups, &files); err != nil { + t.Fatal(err) + } + if setups != 1 || files != 1 { + t.Fatal("preparation input did not follow the Environment", setups, files) + } +} From c2ec362a2ca13320ed003948484d55ad4a7cf778 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 10:57:47 +0000 Subject: [PATCH 7/9] Update integration fixtures to Environment-owned rows; test initialization and input failure without a Harness --- .../environment_connections_test.go | 2 +- .../environment_initialization_test.go | 14 ++++---- .../integration/environment_plugins_test.go | 4 +-- .../environment_runtime_fixture_test.go | 2 +- .../integration/environment_setup_test.go | 14 ++++++-- .../tests/integration/environments_test.go | 4 +-- .../integration/initial_files_http_test.go | 2 +- .../integration/session_deletion_test.go | 2 +- .../skill_version_deletion_test.go | 4 +-- .../template_composition_public_test.go | 14 ++++---- .../template_null_selection_public_test.go | 14 ++++---- .../worker_preparation_failure_test.go | 35 +++++++++++++++++++ 12 files changed, 77 insertions(+), 34 deletions(-) diff --git a/services/core/tests/integration/environment_connections_test.go b/services/core/tests/integration/environment_connections_test.go index dbffb4ce5..a9ec57ff2 100644 --- a/services/core/tests/integration/environment_connections_test.go +++ b/services/core/tests/integration/environment_connections_test.go @@ -28,7 +28,7 @@ func connectionSnapshot(t *testing.T, pool *pgxpool.Pool, id string) string { t.Helper() var value string err := pool.QueryRow(t.Context(), `SELECT jsonb_build_object('environment',to_jsonb(e),'observation',to_jsonb(c),'sequence',s.event_sequence)::text - FROM environments e JOIN sessions s ON s.id=e.session_id LEFT JOIN environment_connections c ON c.environment_id=e.id WHERE e.id=$1`, id).Scan(&value) + FROM environments e JOIN sessions s ON s.environment_id=e.id LEFT JOIN environment_connections c ON c.environment_id=e.id WHERE e.id=$1`, id).Scan(&value) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/environment_initialization_test.go b/services/core/tests/integration/environment_initialization_test.go index c801e327d..4fec2afc8 100644 --- a/services/core/tests/integration/environment_initialization_test.go +++ b/services/core/tests/integration/environment_initialization_test.go @@ -47,6 +47,8 @@ func awaitInitialization(t *testing.T, s *Store, tenant, environment, state stri func TestUserManagedPreparationUsesAuthenticatedRuntimeWithoutAllocation(t *testing.T) { for _, outcome := range []string{"completed", "failed", "unknown", "unavailable", "revoked"} { + // Initialization needs no Harness: a Runtime that reports it unavailable still completes. + completes := outcome == "completed" || outcome == "unavailable" t.Run(outcome, func(t *testing.T) { s, _ := newManagedTestStore(t) principal := FixtureExecutorPrincipal(t, s, uuid.NewString()) @@ -109,7 +111,7 @@ func TestUserManagedPreparationUsesAuthenticatedRuntimeWithoutAllocation(t *test } return completedInitialization(request, data) } - if outcome != "completed" { + if !completes { return proto.RuntimePrepareResultPayload{Outcome: outcome, ErrorCode: "runtime_preparation_unconfirmed"} } return completedInitialization(request, data) @@ -123,14 +125,14 @@ func TestUserManagedPreparationUsesAuthenticatedRuntimeWithoutAllocation(t *test t.Fatal(err) } want := "complete" - if outcome != "completed" { + if !completes { want = "failed" } awaitInitialization(t, s, principal.TenantID, environment.ID, want) if _, err := deploymentStore(s).EnvironmentAllocation(t.Context(), deployment.AllocationKey{TenantID: principal.TenantID, EnvironmentID: environment.ID}); !errors.Is(err, deployment.ErrNotFound) { t.Fatal("self-hosted preparation fabricated allocation", err) } - if outcome == "completed" { + if completes { mu.Lock() got := strings.Join(actions, ",") mu.Unlock() @@ -145,11 +147,7 @@ func TestUserManagedPreparationUsesAuthenticatedRuntimeWithoutAllocation(t *test t.Fatal("reconnect reinstalled snapshot", peer.writes.Load()) } } else { - expected := int32(1) - if outcome == "unavailable" { - expected = 0 - } - if peer.writes.Load() != expected { + if peer.writes.Load() != 1 { t.Fatal("failed, unavailable or revoked effect replayed", peer.writes.Load()) } value, err := sessionAdapter(s).GetSession(t.Context(), principal.TenantID, session.ID) diff --git a/services/core/tests/integration/environment_plugins_test.go b/services/core/tests/integration/environment_plugins_test.go index 1e13c745b..abeb3e8dc 100644 --- a/services/core/tests/integration/environment_plugins_test.go +++ b/services/core/tests/integration/environment_plugins_test.go @@ -51,7 +51,7 @@ func TestPluginsFrozenInSession(t *testing.T) { if err = json.Unmarshal(session.Configuration, &cfg); err != nil || len(cfg.Environment.Plugins) != 1 || !reflect.DeepEqual(cfg.Environment.Directories, setup.CapabilityDirectories) { t.Fatal("frozen public metadata", err) } - frozen, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), tenant, session.ID) + frozen, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), tenant, session.Environment.ID) if err != nil || !reflect.DeepEqual(frozen.Plugins, setup.Plugins) || !reflect.DeepEqual(frozen.CapabilityDirectories, setup.CapabilityDirectories) { t.Fatal("frozen snapshot changed", err) } @@ -59,7 +59,7 @@ func TestPluginsFrozenInSession(t *testing.T) { if err != nil || retry.ID != session.ID { t.Fatal("retry", err) } - if _, err = sessionAdapter(s).ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, sessions.ErrNotFound) { + if _, err = sessionAdapter(s).ReadEnvironmentSetup(t.Context(), foreign, session.Environment.ID); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("foreign snapshot", err) } } diff --git a/services/core/tests/integration/environment_runtime_fixture_test.go b/services/core/tests/integration/environment_runtime_fixture_test.go index cd5411bb4..020d8e4e7 100644 --- a/services/core/tests/integration/environment_runtime_fixture_test.go +++ b/services/core/tests/integration/environment_runtime_fixture_test.go @@ -104,7 +104,7 @@ func assertNoRuntimeAllocation(t *testing.T, h *dispatchHarness) { t.Helper() _, pool := testStore(t) var count int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM runtime_allocations WHERE environment_id IN (SELECT id FROM environments WHERE session_id=$1)", h.session.ID).Scan(&count); err != nil || count != 0 { + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM runtime_allocations WHERE environment_id IN (SELECT environment_id FROM sessions WHERE id=$1)", h.session.ID).Scan(&count); err != nil || count != 0 { t.Fatal("self-hosted fixture allocated managed compute", count, err) } } diff --git a/services/core/tests/integration/environment_setup_test.go b/services/core/tests/integration/environment_setup_test.go index 23bfe6f0a..ff609e5a1 100644 --- a/services/core/tests/integration/environment_setup_test.go +++ b/services/core/tests/integration/environment_setup_test.go @@ -25,11 +25,11 @@ func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { if bytes.Contains(session.Configuration, []byte("canary")) { t.Fatal("plaintext Session metadata") } - frozen, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), tenant, session.ID) + frozen, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), tenant, session.Environment.ID) if err != nil || !reflect.DeepEqual(frozen, setup) { t.Fatal("Session did not freeze setup", err) } - if _, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, sessions.ErrNotFound) { + if _, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), foreign, session.Environment.ID); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("foreign Session initialization", err) } retry, err := s.CreateSession(t.Context(), tenant, input) @@ -44,3 +44,13 @@ func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { t.Fatal("uninitialized execution admitted", err) } } + +// environmentOf returns the ID of the Environment the Session names. +func environmentOf(t *testing.T, s *Store, tenant, session string) string { + t.Helper() + current, err := sessionAdapter(s).GetSession(t.Context(), tenant, session) + if err != nil || current.Environment == nil { + t.Fatal("Session without an Environment", err) + } + return current.Environment.ID +} diff --git a/services/core/tests/integration/environments_test.go b/services/core/tests/integration/environments_test.go index edca342ed..38a5c4131 100644 --- a/services/core/tests/integration/environments_test.go +++ b/services/core/tests/integration/environments_test.go @@ -148,7 +148,7 @@ func TestEnvironmentCreationWinnerOwnsSnapshotAndIdentity(t *testing.T) { t.Fatal("creation winners", received, created) } var associations, reservations int - if err := pool.QueryRow(ctx, "SELECT count(*) FROM environments WHERE session_id=$1", first.creation.Session.ID).Scan(&associations); err != nil || associations != 1 { + if err := pool.QueryRow(ctx, "SELECT count(*) FROM environments e JOIN sessions s ON s.environment_id=e.id WHERE s.id=$1", first.creation.Session.ID).Scan(&associations); err != nil || associations != 1 { t.Fatal(associations, err) } if err := pool.QueryRow(ctx, "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1 AND is_initial", first.creation.Session.ID).Scan(&reservations); err != nil || reservations != 1 { @@ -257,7 +257,7 @@ func TestEnvironmentDeletionHidesWithoutDestroyingOwnership(t *testing.T) { t.Fatal("deleted retry resurrected ownership", err) } var retained int - if err := pool.QueryRow(ctx, "SELECT count(*) FROM environments WHERE id=$1 AND session_id=$2", environment.ID, session.ID).Scan(&retained); err != nil || retained != 1 { + if err := pool.QueryRow(ctx, "SELECT count(*) FROM environments e JOIN sessions s ON s.environment_id=e.id WHERE e.id=$1 AND s.id=$2", environment.ID, session.ID).Scan(&retained); err != nil || retained != 1 { t.Fatal(retained, err) } } diff --git a/services/core/tests/integration/initial_files_http_test.go b/services/core/tests/integration/initial_files_http_test.go index 1ca13914a..312697ee9 100644 --- a/services/core/tests/integration/initial_files_http_test.go +++ b/services/core/tests/integration/initial_files_http_test.go @@ -52,7 +52,7 @@ func TestInitialFilesHTTPInlineLimitsAndRetry(t *testing.T) { id = response.ID } for position := range files { - _, actual, err := sessionAdapter(s).ReadInitialEnvironmentFile(t.Context(), tenant, id, position) + _, actual, err := sessionAdapter(s).ReadInitialEnvironmentFile(t.Context(), tenant, environmentOf(t, s, tenant, id), position) if err != nil || !bytes.Equal(actual, data) { t.Fatal("large HTTP snapshot differs", err) } diff --git a/services/core/tests/integration/session_deletion_test.go b/services/core/tests/integration/session_deletion_test.go index 13f8f8020..e6315a6cb 100644 --- a/services/core/tests/integration/session_deletion_test.go +++ b/services/core/tests/integration/session_deletion_test.go @@ -373,7 +373,7 @@ func TestSessionDeletionKeepsProvisioningInputPlacementUntilSettled(t *testing.T t.Helper() var current state if err := s.pool.QueryRow(ctx, `SELECT s.deleted_at, p.released_at FROM sessions s - JOIN environments e ON e.session_id=s.id JOIN runtime_placements p ON p.environment_id=e.id + JOIN environments e ON e.id=s.environment_id JOIN runtime_placements p ON p.environment_id=e.id WHERE s.id=$1 AND p.node_id=$2`, session.ID, d.NodeID).Scan(¤t.deleted, ¤t.released); err != nil { t.Fatal("missing placement", err) } diff --git a/services/core/tests/integration/skill_version_deletion_test.go b/services/core/tests/integration/skill_version_deletion_test.go index be27ff14d..47af90aeb 100644 --- a/services/core/tests/integration/skill_version_deletion_test.go +++ b/services/core/tests/integration/skill_version_deletion_test.go @@ -30,7 +30,7 @@ func TestSoleSkillVersionDeletionKeepsFrozenSetup(t *testing.T) { if err != nil { t.Fatal(err) } - frozen, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), tenant, session.ID) + frozen, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), tenant, session.Environment.ID) if err != nil || len(frozen.Skills) != 1 || frozen.Skills[0].Metadata.Version != "1" || !bytes.Equal(frozen.Skills[0].Archive, archive) { t.Fatal("fixture Session did not freeze the sole version", err) } @@ -38,7 +38,7 @@ func TestSoleSkillVersionDeletionKeepsFrozenSetup(t *testing.T) { t.Fatal(err) } - after, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), tenant, session.ID) + after, err := sessionAdapter(s).ReadEnvironmentSetup(t.Context(), tenant, session.Environment.ID) if err != nil || !reflect.DeepEqual(after.Skills, frozen.Skills) { t.Fatal("frozen Session installation changed", err) } diff --git a/services/core/tests/integration/template_composition_public_test.go b/services/core/tests/integration/template_composition_public_test.go index 7ea86f959..497dcfa44 100644 --- a/services/core/tests/integration/template_composition_public_test.go +++ b/services/core/tests/integration/template_composition_public_test.go @@ -99,7 +99,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { } // A reader built after the requests reads the frozen setup and files. current := sessionAdapter(s) - setup, err := current.ReadEnvironmentSetup(t.Context(), tenant, id) + setup, err := current.ReadEnvironmentSetup(t.Context(), tenant, environmentOf(t, s, tenant, id)) if err != nil || !reflect.DeepEqual(setup.Env, env) || !reflect.DeepEqual(setup.PackageMetadata(), packages) || len(setup.Commands) != len(commands) { t.Fatalf("%s durable setup differs: %v", label, err) } @@ -108,15 +108,15 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { t.Fatalf("%s command order differs", label) } } - if _, err := current.ReadEnvironmentSetup(t.Context(), foreignTenant, id); !errors.Is(err, sessions.ErrNotFound) { + if _, err := current.ReadEnvironmentSetup(t.Context(), foreignTenant, environmentOf(t, s, tenant, id)); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("%s foreign setup read: %v", label, err) } for position, want := range contents { - metadata, body, err := current.ReadInitialEnvironmentFile(t.Context(), tenant, id, position) + metadata, body, err := current.ReadInitialEnvironmentFile(t.Context(), tenant, environmentOf(t, s, tenant, id), position) if err != nil || string(body) != want || metadata.Path != paths[position] || metadata.SizeBytes == nil || *metadata.SizeBytes != int64(len(want)) { t.Fatalf("%s frozen file %d differs: %v", label, position, err) } - if _, _, err := current.ReadInitialEnvironmentFile(t.Context(), foreignTenant, id, position); err == nil { + if _, _, err := current.ReadInitialEnvironmentFile(t.Context(), foreignTenant, environmentOf(t, s, tenant, id), position); err == nil { t.Fatalf("%s foreign file read succeeded", label) } var encrypted []byte @@ -125,7 +125,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { } } var encrypted, configuration []byte - if err := pool.QueryRow(t.Context(), "SELECT e.contents,s.configuration FROM environment_setups e JOIN sessions s ON s.id=e.session_id WHERE s.id=$1", id).Scan(&encrypted, &configuration); err != nil || bytes.Contains(encrypted, []byte(marker)) || bytes.Contains(configuration, []byte(marker)) { + if err := pool.QueryRow(t.Context(), "SELECT e.contents,s.configuration FROM environment_setups e JOIN sessions s ON s.environment_id=e.environment_id WHERE s.id=$1", id).Scan(&encrypted, &configuration); err != nil || bytes.Contains(encrypted, []byte(marker)) || bytes.Contains(configuration, []byte(marker)) { t.Fatalf("%s plaintext setup storage: %v", label, err) } } @@ -136,8 +136,8 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { want int }{ {"SELECT count(*) FROM sessions WHERE tenant_id=$1", 6}, - {"SELECT count(*) FROM environments e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 6}, - {"SELECT count(*) FROM environment_setups e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 6}, + {"SELECT count(*) FROM environments e JOIN sessions s ON s.environment_id=e.id WHERE s.tenant_id=$1", 6}, + {"SELECT count(*) FROM environment_setups e JOIN sessions s ON s.environment_id=e.environment_id WHERE s.tenant_id=$1", 6}, {"SELECT count(*) FROM initial_environment_files f JOIN sessions s ON s.id=f.session_id WHERE s.tenant_id=$1", 8}, {"SELECT count(*) FROM turns t JOIN sessions s ON s.id=t.session_id WHERE s.tenant_id=$1", 0}, {"SELECT count(*) FROM environment_input_reservations e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 0}, diff --git a/services/core/tests/integration/template_null_selection_public_test.go b/services/core/tests/integration/template_null_selection_public_test.go index 44d021084..1736df52e 100644 --- a/services/core/tests/integration/template_null_selection_public_test.go +++ b/services/core/tests/integration/template_null_selection_public_test.go @@ -90,7 +90,7 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { } // A reader built after the requests reads the frozen setup and files. current := sessionAdapter(s) - setup, err := current.ReadEnvironmentSetup(t.Context(), tenant, id) + setup, err := current.ReadEnvironmentSetup(t.Context(), tenant, environmentOf(t, s, tenant, id)) if err != nil { t.Fatalf("%s frozen setup: %v", label, err) } @@ -114,18 +114,18 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { t.Fatalf("%s frozen Plugin bytes changed", label) } } - if _, err := current.ReadEnvironmentSetup(t.Context(), foreignTenant, id); !errors.Is(err, sessions.ErrNotFound) { + if _, err := current.ReadEnvironmentSetup(t.Context(), foreignTenant, environmentOf(t, s, tenant, id)); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("%s foreign setup read: %v", label, err) } - file, body, err := current.ReadInitialEnvironmentFile(t.Context(), tenant, id, 0) + file, body, err := current.ReadInitialEnvironmentFile(t.Context(), tenant, environmentOf(t, s, tenant, id), 0) if err != nil || string(body) != marker+"-source" || file.Path != "/workspace/source.txt" { t.Fatalf("%s frozen source file changed: %v", label, err) } - if _, _, err := current.ReadInitialEnvironmentFile(t.Context(), foreignTenant, id, 0); err == nil { + if _, _, err := current.ReadInitialEnvironmentFile(t.Context(), foreignTenant, environmentOf(t, s, tenant, id), 0); err == nil { t.Fatalf("%s foreign initial file read: %v", label, err) } var encryptedSetup, encryptedFile, configuration []byte - err = pool.QueryRow(t.Context(), "SELECT e.contents,f.contents,s.configuration FROM environment_setups e JOIN sessions s ON s.id=e.session_id JOIN initial_environment_files f ON f.session_id=s.id WHERE s.id=$1", id).Scan(&encryptedSetup, &encryptedFile, &configuration) + err = pool.QueryRow(t.Context(), "SELECT e.contents,f.contents,s.configuration FROM environment_setups e JOIN sessions s ON s.environment_id=e.environment_id JOIN initial_environment_files f ON f.environment_id=s.environment_id WHERE s.id=$1", id).Scan(&encryptedSetup, &encryptedFile, &configuration) if err != nil || len(encryptedSetup) == 0 || len(encryptedFile) == 0 || bytes.Contains(encryptedSetup, []byte(marker)) || bytes.Contains(encryptedFile, []byte(marker)) || bytes.Contains(configuration, []byte(marker)) { t.Fatalf("%s confidential initialization storage: %v", label, err) @@ -137,8 +137,8 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { want int }{ {"SELECT count(*) FROM sessions WHERE tenant_id=$1", 9}, - {"SELECT count(*) FROM environments e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 9}, - {"SELECT count(*) FROM environment_setups e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 9}, + {"SELECT count(*) FROM environments e JOIN sessions s ON s.environment_id=e.id WHERE s.tenant_id=$1", 9}, + {"SELECT count(*) FROM environment_setups e JOIN sessions s ON s.environment_id=e.environment_id WHERE s.tenant_id=$1", 9}, {"SELECT count(*) FROM initial_environment_files f JOIN sessions s ON s.id=f.session_id WHERE s.tenant_id=$1", 9}, {"SELECT count(*) FROM turns t JOIN sessions s ON s.id=t.session_id WHERE s.tenant_id=$1", 0}, {"SELECT count(*) FROM environment_input_reservations e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 0}, diff --git a/services/core/tests/integration/worker_preparation_failure_test.go b/services/core/tests/integration/worker_preparation_failure_test.go index 9d1223560..519a73f20 100644 --- a/services/core/tests/integration/worker_preparation_failure_test.go +++ b/services/core/tests/integration/worker_preparation_failure_test.go @@ -166,3 +166,38 @@ func TestWorkerPreparationRejectionPreservesCancellationAndNewerInput(t *testing } h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "complete"}) } + +// The Environment is prepared without a Harness; a connected Runtime that +// lacks the Session's Harness fails the Session's input instead. +func TestWorkerFailsInputWhenTheRuntimeLacksTheSessionsHarness(t *testing.T) { + h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), false) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: false, Capabilities: workerEnvironmentCapabilities()}}}) + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "Harness status", func() bool { + peer, err := h.registry.LookupDevice(h.device.ID) + if err != nil { + return false + } + _, _, known := peer.AgentKindStatus("codex") + return known + }) + frames := workerFrames(t, h) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "first", []sessions.Input{messageInput("first")}) + if err != nil { + t.Fatal(err) + } + _, stop := startEnvironmentExpiryWorker(t, h.s, h.d) + defer stop() + awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "failed reservation settlement", func() bool { + current, err := sessionAdapter(h.s).GetEnvironmentInputReservation(t.Context(), h.tenant, h.session.ID, pending.ID) + return err == nil && current.State == sessions.EnvironmentInputFailed + }) + session, err := sessionAdapter(h.s).GetSession(t.Context(), h.tenant, h.session.ID) + if err != nil || session.EnvironmentInputActivity == nil || session.EnvironmentInputActivity.Failure != "runtime_preparation_failed" { + t.Fatal("the missing Harness did not fail the input", err) + } + select { + case frame := <-frames: + t.Fatal("a Runtime without the Harness was asked to prepare", frame.Type) + case <-time.After(600 * time.Millisecond): + } +} From a6a7667d6e2a52527cc21c3e75d5f009d30c3a04 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 11:11:36 +0000 Subject: [PATCH 8/9] Fix the remaining integration fixtures for Environment-owned rows --- services/core/tests/integration/remote_mcp_test.go | 2 +- services/core/tests/integration/sandbox_reset_test.go | 2 +- services/core/tests/integration/session_configuration_test.go | 2 +- .../tests/integration/session_diagnostics_cancellation_test.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/core/tests/integration/remote_mcp_test.go b/services/core/tests/integration/remote_mcp_test.go index c7d0ad2de..c35f6ad05 100644 --- a/services/core/tests/integration/remote_mcp_test.go +++ b/services/core/tests/integration/remote_mcp_test.go @@ -106,7 +106,7 @@ func assertSelfHostedMCPRejectionHasNoWrites(t *testing.T, pool *pgxpool.Pool, t for _, table := range []string{"sessions", "environments", "turns", "turn_inputs", "session_items", "session_events", "environment_input_reservations"} { var count int where := "session_id IN (SELECT id FROM sessions WHERE tenant_id=$1)" - if table == "sessions" { + if table == "sessions" || table == "environments" { where = "tenant_id=$1" } if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM "+table+" WHERE "+where, tenant).Scan(&count); err != nil || count != 0 { diff --git a/services/core/tests/integration/sandbox_reset_test.go b/services/core/tests/integration/sandbox_reset_test.go index fdd747330..03a3f9c21 100644 --- a/services/core/tests/integration/sandbox_reset_test.go +++ b/services/core/tests/integration/sandbox_reset_test.go @@ -323,7 +323,7 @@ func TestSandboxResetPaginationSkipsBusyPrefixAndPreservesSelfHosted(t *testing. // IDs are immutable API identities, so seed this bounded scheduling fixture // directly instead of modifying an existing Session primary key. runtimeSuspensionSQL(t, s.pool, `INSERT INTO sessions(id,tenant_id,engine,idempotency_key,request_hash,configuration) SELECT $1::uuid,tenant_id,engine,$1::text,$1::text,configuration FROM sessions WHERE id=$2`, id, session.ID) - runtimeSuspensionSQL(t, s.pool, `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned) SELECT $1, tenant_id, configuration->'environment', true FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, uuid.NewString(), id) + runtimeSuspensionSQL(t, s.pool, `WITH e AS (INSERT INTO environments(id, tenant_id, configuration, session_owned) SELECT $1, tenant_id, COALESCE(configuration->'environment', '{"type":"openai_hosted"}'), true FROM sessions WHERE id=$2 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$2`, uuid.NewString(), id) if i <= 34 { runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'in_progress','{}')`, uuid.NewString(), id) } diff --git a/services/core/tests/integration/session_configuration_test.go b/services/core/tests/integration/session_configuration_test.go index 8a1206213..ff931ba5f 100644 --- a/services/core/tests/integration/session_configuration_test.go +++ b/services/core/tests/integration/session_configuration_test.go @@ -78,7 +78,7 @@ func TestEmptyConfigurationCanonicalizationPreservesCreator(t *testing.T) { for _, configuration := range [][]byte{nil, []byte(`{}`), []byte(` { } `)} { input.Configuration = configuration got, err := s.CreateSession(t.Context(), tenant, input) - if err != nil || got.ID != first.ID || string(got.Configuration) != "{}" || got.Creator == nil || *got.Creator != FixtureCreator() { + if err != nil || got.ID != first.ID || string(got.Configuration) != `{"environment":{"type":"none"}}` || got.Creator == nil || *got.Creator != FixtureCreator() { t.Fatal("canonical retry changed identity or creator", err) } } diff --git a/services/core/tests/integration/session_diagnostics_cancellation_test.go b/services/core/tests/integration/session_diagnostics_cancellation_test.go index f99e59811..aa9812b00 100644 --- a/services/core/tests/integration/session_diagnostics_cancellation_test.go +++ b/services/core/tests/integration/session_diagnostics_cancellation_test.go @@ -41,7 +41,7 @@ func TestDiagnosticSnapshotCancellationReleasesTransaction(t *testing.T) { }() var readerPID int for readerPID == 0 { - err = pool.QueryRow(t.Context(), `SELECT COALESCE((SELECT pid FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND backend_xmin IS NOT NULL AND query LIKE $1 LIMIT 1),0)`, "%FROM "+table+"%").Scan(&readerPID) + err = pool.QueryRow(t.Context(), `SELECT COALESCE((SELECT pid FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND backend_xmin IS NOT NULL AND query LIKE $1 LIMIT 1),0)`, "%"+table+"%").Scan(&readerPID) if err != nil { t.Fatal(err) } From e065d19609dae38b2abf4d90e86fa4d4a6359aec Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 11:21:29 +0000 Subject: [PATCH 9/9] Let failed-placement release run after the Session's deletion --- services/core/internal/execution/runtime_lost_compute_test.go | 2 +- .../internal/persistence/postgres/deploymentpg/allocations.go | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/services/core/internal/execution/runtime_lost_compute_test.go b/services/core/internal/execution/runtime_lost_compute_test.go index 572fed5d9..5183d5bb2 100644 --- a/services/core/internal/execution/runtime_lost_compute_test.go +++ b/services/core/internal/execution/runtime_lost_compute_test.go @@ -371,7 +371,7 @@ func TestLostComputeReleaseSerializesWithConcurrentIntent(t *testing.T) { }{ {name: "compute_revision", statement: `UPDATE runtime_allocations SET compute_revision=compute_revision+1 WHERE environment_id=$1`, want: deployment.ErrAllocationConflict, status: "disconnected"}, {name: "archive", statement: `UPDATE environments SET status='expired' WHERE id=$1`, want: deployment.ErrAllocationConflict, status: "expired"}, - {name: "deletion", statement: `UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=(SELECT session_id FROM environments WHERE id=$1)`, want: sessions.ErrNotFound}, + {name: "deletion", statement: `UPDATE sessions SET deleted_at=clock_timestamp() WHERE environment_id=$1 AND deleted_at IS NULL`, want: sessions.ErrNotFound}, } { t.Run(test.name, func(t *testing.T) { f := newLostComputeFixture(t) diff --git a/services/core/internal/persistence/postgres/deploymentpg/allocations.go b/services/core/internal/persistence/postgres/deploymentpg/allocations.go index d0fd93cf7..4d0bc7538 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/allocations.go +++ b/services/core/internal/persistence/postgres/deploymentpg/allocations.go @@ -77,9 +77,6 @@ func (e *Execution) WithReservation(ctx context.Context, key deployment.Allocati return err } return translate(sessionpg.WithEnvironment(ctx, e.lease, tenant, environment, func(ctx context.Context, q *sqlc.Queries, locked sessions.LockedSession, session pgtype.UUID) error { - if locked.Deleted { - return sessions.ErrNotFound - } t := &reservationTx{SessionTx: sessionpg.BindSession(q, tenant, session), ctx: ctx, q: q, tenant: tenant, environment: environment, session: session} return apply(locked, t) }))