diff --git a/.github/workflows/api-acceptance.yml b/.github/workflows/api-acceptance.yml index 97e01f758..667066a52 100644 --- a/.github/workflows/api-acceptance.yml +++ b/.github/workflows/api-acceptance.yml @@ -64,7 +64,7 @@ jobs: run: | python services/core/tests/official_schema_test.py python services/core/tests/official_client.py - go test ./services/core/tests/integration -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestAutomationsOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient)$' -count=1 + go test ./services/core/tests/integration -run '^(TestFunctionStateOfficialClientReadsAndLiveEvents|TestSavedReferenceRetryOfficialClient|TestAgentUpdateOfficialClient|TestAgentDeletionOfficialClient|TestAutomationsOfficialClient|TestSessionAgentFilterOfficialClient|TestSessionDeletionOfficialClient|TestEnvironmentInitialFailureOfficialClient|TestSelfHostedInitialCreationOfficialClient|TestSelfHostedCancellationOfficialClient|TestStandaloneEnvironmentOfficialClientPostgres)$' -count=1 - uses: ./.github/actions/e2b-provider if: inputs.container - name: Verify the distribution's Core image diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 28011b250..c36085fda 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -418,7 +418,7 @@ Settings is the single platform configuration entry, with one tab per [group](.. The hierarchy is page, tab, section, row. The page header loses its rule to an underline tab row: 40px text tabs in Graphite 13px/500, the current one Ink with a 2px Ink underline. Below 640px the tabs wrap onto a second row rather than scroll out of sight. The body is a left-aligned column at most 880px wide with 32px between sections. A section is the shared Section heading (14px/600, help tip, section-wide outline actions on the right) over one card of label/value rows (`settings.css`): a 200px Graphite label column with its help tip and an Ink value column, 8px by 16px padding, 32px minimum line height, Faint Rules between rows; below 640px the label sits above the value. An action that changes one value sits at the end of its row (Edit tenant capacity; Save beside the idle-seconds field, whose error appears beneath it). Read-only status is a row too (Configuration rollout, Snapshot retention); tables sit in one framed card. Settings never repeats the KPI strips of Nodes or the metrics pages. The irreversible operation closes the page in its own section: a red-ringed card with one Ink line stating the consequence and a red-text outline button. -Sandbox and scheduling contains sandbox setup or Deployment provider (Provider, Each sandbox, the E2B connection rows and the rollout row, with Change resources in the section header), then Automatic suspension (the deployment-wide idle policy and read-only snapshot retention, shown only when the deployment declares suspension), then Reset. While a reset runs, its progress section leads the page with Force reset now and Cancel reset in its header. +Sandbox and scheduling contains sandbox setup or Deployment provider (Provider, Each sandbox, the E2B connection rows and the rollout row, with Change resources in the section header), then the idle setting, always shown: Automatic suspension with checkpoint support (Idle before suspension (seconds) and read-only snapshot retention) or Idle timeout without it (Idle before expiry (seconds)), then Reset. While a reset runs, its progress section leads the page with Force reset now and Cancel reset in its header. Workspace storage shows the selected configuration's adapter and tenant capacity (in bytes units, or No tenant capacity limit) under Storage configuration, with Select new configuration in the section header and Edit tenant capacity on the capacity row. The configuration ID and parameters JSON sit in a closed Configuration details disclosure at the end of the card. Explain adapter preparation and existing-workspace behavior behind help tips, with a link to the owning configuration document; keep required form labels and validation visible. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 3e81bb143..0bec6426b 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -36,7 +36,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Online sandbox configuration** changes the same backend's resources or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation. - **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write. - **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting or changing, and the size of each) instead of node capacity, with no Add node action; a sandbox's dialog adds its disk use. -- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics' Hosted Runtimes has one Status column: Core's lifecycle record of each sandbox (Active, Suspended, Changing state, Waiting for allocation or Stopped), which Core derives from the allocation's recorded phase. The Session page's Runtime state reads the same way. When an active sandbox has no sample, its CPU and Memory cells name why. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. +- With checkpoint support, Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling always shows the idle setting: **Automatic suspension** with checkpoint support (deployment-wide idle seconds are editable; snapshot retention remains read-only) and **Idle timeout** without it (idle seconds only). These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics' Hosted Runtimes has one Status column: Core's lifecycle record of each sandbox (Active, Suspended, Changing state, Waiting for allocation or Stopped), which Core derives from the allocation's recorded phase. The Session page's Runtime state reads the same way. When an active sandbox has no sample, its CPU and Memory cells name why. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. - **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment), a default model provider on the default Harness (on any enabled Harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. - Terminology: Empty lists read "No … yet" ("还没有…"). Sandbox provider readiness is "Provider not ready / 沙箱提供商未就绪"; revoked credentials and keys use "Revoked / 已撤销". A default model provider is a service address and write-only key; the application's Agent `model` chooses the provider-supported model name. A sandbox is Core-managed compute; Runtime names Core's execution observations, and Environment is the Session's API execution environment. These are distinct counts and resources, not synonyms. diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 5337a0ae1..286e84715 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -82,11 +82,11 @@ function nodeRollout(previous = 0) { } function unconfiguredDeployment(generation = 0, ownerEpoch = 3) { - return { installation_id: INSTALLATION_ID, provider: "", credential_configured: false, core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, running: 0, pending: 0 }, suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "", credential_configured: false, core_url: publicUrl(), reset: null, rollout: noNodeRollout(), owner_epoch: ownerEpoch, generation, mode: "", resources: { allocations: 0, running: 0, pending: 0 }, idle_seconds: 300, suspension: null }; } function configuredDeployment() { - return { installation_id: INSTALLATION_ID, provider: "microsandbox", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, network: { public: true, domains: [] } }, specification_digest: "fixture", suspension: { idle_seconds: 300, retention_seconds: 86400 } }; + return { installation_id: INSTALLATION_ID, provider: "microsandbox", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, network: { public: true, domains: [] } }, specification_digest: "fixture", idle_seconds: 300, suspension: { retention_seconds: 86400 } }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; @@ -382,9 +382,9 @@ async function sandboxRoute(request, response, path, url) { } if (path === "/deployment" && request.method === "PATCH") { const input = await body(request); - if (!state.deployment.suspension || state.deployment.reset) return error(response, 409, "Suspension policy is unavailable.", "sandbox_deployment_conflict"); + if (!state.deployment.provider || state.deployment.reset) return error(response, 409, "Idle policy is unavailable.", "sandbox_deployment_conflict"); if (!Number.isInteger(input.idle_seconds) || input.idle_seconds < 30 || input.idle_seconds > 86400) return error(response, 400, "Invalid idle seconds.", "invalid_request_error"); - state.deployment.suspension.idle_seconds = input.idle_seconds; + state.deployment.idle_seconds = input.idle_seconds; return send(response, 200, state.deployment); } if (path === "/deployment" && (request.method === "POST" || request.method === "PUT")) { @@ -428,7 +428,7 @@ async function sandboxRoute(request, response, path, url) { rollout: e2b ? { ...noNodeRollout(), previous_generation_sandboxes: held.allocations + held.pending } : nodeRollout(held.allocations + held.pending), specification, ...(e2b ? { configuration: { template: input.configuration?.template ?? "", api_url: input.configuration?.api_url ?? state.deployment.configuration?.api_url ?? "https://api.e2b.app", domain: input.configuration?.domain ?? state.deployment.configuration?.domain ?? "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } } : { configuration: {}, metadata: {}, credential_configured: false }), - suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, + idle_seconds: previous.idle_seconds, suspension: input.provider === "microsandbox" ? { retention_seconds: 86400 } : null, }; return send(response, 200, state.deployment); } diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 634ddde43..9e5685126 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -287,7 +287,7 @@ test("edits only the saved backend, preserving a custom size", async ({ page, re const current = { resources: { cpus: 7, memory_mib: 8192, root_disk_mib: 10240 }, network: { public: true, domains: [] }, workspace: { attachment: "host_directory", user_xattr: true, capacity_quota: false } }; let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "microsandbox", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64), - suspension: null }; + idle_seconds: 300, suspension: null }; let submitted: Record | null = null; await page.route("**/core/v1/sandbox/deployment", async (route) => { if (route.request().method() === "PUT") { diff --git a/apps/web/e2e/sandbox-lifecycle.spec.ts b/apps/web/e2e/sandbox-lifecycle.spec.ts index 3e8d7430d..cc0ca0579 100644 --- a/apps/web/e2e/sandbox-lifecycle.spec.ts +++ b/apps/web/e2e/sandbox-lifecycle.spec.ts @@ -7,7 +7,7 @@ test("shows each node's retained limit and elapsed phase time without inventing await page.route("**/core/v1/sandbox/deployment", async (route) => { const response = await route.fetch(); const deployment = await response.json(); - await route.fulfill({ response, json: { ...deployment, provider: "microsandbox", suspension: { idle_seconds: 300, retention_seconds: 86400 }, specification: { ...deployment.specification, resources: { ...deployment.specification.resources, root_disk_mib: 8192 } } } }); + await route.fulfill({ response, json: { ...deployment, provider: "microsandbox", suspension: { retention_seconds: 86400 }, specification: { ...deployment.specification, resources: { ...deployment.specification.resources, root_disk_mib: 8192 } } } }); }); await page.route("**/core/v1/sandbox/nodes", async (route) => { const response = await route.fetch(); @@ -43,7 +43,7 @@ test("edits deployment idle time without changing generation", async ({ page, re const nodeReads: string[] = []; page.on("request", (sent) => { if (new URL(sent.url()).pathname.startsWith("/core/v1/sandbox/nodes")) nodeReads.push(sent.url()); }); await openConsole(page, request, "settings?id=sandbox"); - const deployment = await setDeployment(request, { suspension: { idle_seconds: 300, retention_seconds: 86400 } }); + const deployment = await setDeployment(request, { idle_seconds: 300, suspension: { retention_seconds: 86400 } }); await setDeployment(request, { provider: "microsandbox", specification: { ...deployment.specification!, resources: { ...deployment.specification!.resources, root_disk_mib: 8192 } } }); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); const area = page.getByRole("region", { name: "Automatic suspension" }); @@ -57,7 +57,8 @@ test("edits deployment idle time without changing generation", async ({ page, re await area.getByRole("button", { name: "Save", exact: true }).click(); const saved = await (await response).json(); expect(saved.generation).toBe(deployment.generation); - expect(saved.suspension).toEqual({ idle_seconds: 60, retention_seconds: 86400 }); + expect(saved.idle_seconds).toBe(60); + expect(saved.suspension).toEqual({ retention_seconds: 86400 }); await expect(idle).toHaveValue("60"); await page.reload(); await expect(idle).toHaveValue("60"); diff --git a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx index 994113c5e..07ca93c21 100644 --- a/apps/web/src/features/fleet/SandboxResetNotice.test.tsx +++ b/apps/web/src/features/fleet/SandboxResetNotice.test.tsx @@ -6,7 +6,7 @@ import { SandboxResetNotice } from "./SandboxResetNotice"; const reset: SandboxReset = { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 2, on_offline_nodes: 1, offline_nodes: [{ node_id: "n1", name: "Node 1", resources: 1 }] } }; -const deployment = (reset: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", reset, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 3, running: 3, pending: 0 }, suspension: null }); +const deployment = (reset: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", reset, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 3, running: 3, pending: 0 }, idle_seconds: 300, suspension: null }); const render = (value: SandboxDeployment | undefined, failed = false) => renderToStaticMarkup( {}} />); describe("reset notices on read-only surfaces", () => { diff --git a/apps/web/src/features/fleet/fleet-queries.test.ts b/apps/web/src/features/fleet/fleet-queries.test.ts index f7f04995e..b480eb72b 100644 --- a/apps/web/src/features/fleet/fleet-queries.test.ts +++ b/apps/web/src/features/fleet/fleet-queries.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxScope } from "../sandbox/sandbox-queries"; import { fleetQuery } from "./fleet-queries"; -const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, running: 1, pending: 0 }, suspension: null, +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, running: 1, pending: 0 }, idle_seconds: 300, suspension: null, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index 4f978a739..b47344438 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -10,7 +10,7 @@ import { fleetQuery, type FleetSnapshot } from "./fleet-queries"; import { FleetReadNotice } from "./FleetReadNotice"; import { fleetSnapshot, useSandboxFleet } from "./use-sandbox-fleet"; -const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, running: 1, pending: 0 }, suspension: null }; +const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, running: 1, pending: 0 }, idle_seconds: 300, suspension: null }; function Probe() { const { state, deployment } = useSandboxFleet(); diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index e46950b4f..f5ebe2a1b 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -7,7 +7,7 @@ import { node, project } from "./test-fixtures"; const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", - resources: { allocations: 0, running: 0, pending: 0 }, suspension: null, ...overrides, + resources: { allocations: 0, running: 0, pending: 0 }, idle_seconds: 300, suspension: null, ...overrides, }); const fleet = (value: SandboxDeployment, nodes = [node("n1")]): FleetState => ({ status: "ready", snapshot: { deployment: value, nodes, allocations: [], loadedAt: 0 }, targetGeneration: value.generation, refreshing: false, error: null }); const sandboxes = (state: FleetState) => gettingStartedSteps({ sandboxReset: false, fleet: state, localOnly: false, projects: [], sessions: 0, harnesses: [] }).sandboxes; diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index e0875ebd6..f82108bd4 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -40,7 +40,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat {t("One provider serves this deployment. Reset it before choosing a different backend.")} {deployment.mode === "direct" ? ` ${t("Core creates {{provider}} sandboxes directly. No node enrollment is needed.", { provider: setup?.label })} ${t("Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.")}` : ""}
- {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.suspension.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null} + {deployment.suspension ?
{t("Idle suspension")}
{t("After {{idle}} · kept {{retention}}", { idle: formatPeriod(deployment.idle_seconds, i18n.resolvedLanguage), retention: formatPeriod(deployment.suspension.retention_seconds, i18n.resolvedLanguage) })}
: null}
{t("Allocated resources")}
{deployment.resources?.allocations ?? t("Unknown state")}
{t("Pending environments")}
{deployment.resources?.pending ?? t("Unknown state")}
diff --git a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx index e4330cad9..8649ba158 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.test.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.test.tsx @@ -5,7 +5,7 @@ import { SandboxResetControls } from "./SandboxResetControls"; const reset: SandboxReset = { clear: "auto", requested_at: "2020-01-01T10:00:00Z", deadline_at: "2020-01-01T11:00:00Z", forced_at: null, remaining: { busy: 2, idle: 1, cleanup: 3, on_offline_nodes: 2, offline_nodes: [{ node_id: "offline-a", name: "Offline A", resources: 2 }] } }; -const deployment = (value: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "https://core.example", reset: value, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 5, running: 5, pending: 1 }, suspension: null }); +const deployment = (value: SandboxReset | null): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "https://core.example", reset: value, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 5, running: 5, pending: 1 }, idle_seconds: 300, suspension: null }); const render = (value: SandboxReset | null, stale = false) => renderToStaticMarkup( true} onCancel={async () => true} />); describe("authoritative reset progress", () => { diff --git a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx index 3dcb80fea..c8e0f30d8 100644 --- a/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx +++ b/apps/web/src/features/sandbox/SandboxRolloutSummary.test.tsx @@ -7,7 +7,7 @@ import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; import { NodeRolloutStatus } from "./NodeRolloutStatus"; import { SandboxDeploymentSettings } from "./SandboxDeploymentSettings"; -const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "i", owner_epoch: 1, generation: 4, provider: "microsandbox", mode: "nodes", core_url: "https://core.example", resources: { allocations: 8, running: 8, pending: 2 }, reset: null, suspension: null, +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "i", owner_epoch: 1, generation: 4, provider: "microsandbox", mode: "nodes", core_url: "https://core.example", resources: { allocations: 8, running: 8, pending: 2 }, reset: null, idle_seconds: 300, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 8, nodes: { ready: 1, preparing: 0, failed: 2, unknown: 4 } } }; describe("authoritative configuration rollout", () => { diff --git a/apps/web/src/features/sandbox/SuspensionSettings.test.tsx b/apps/web/src/features/sandbox/SuspensionSettings.test.tsx index f67a766e9..1f4c0a955 100644 --- a/apps/web/src/features/sandbox/SuspensionSettings.test.tsx +++ b/apps/web/src/features/sandbox/SuspensionSettings.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { SuspensionForm } from "./SuspensionSettings"; -const deployment = { suspension: { idle_seconds: 300, retention_seconds: 86400 } } as SandboxDeployment; +const deployment = { idle_seconds: 300, suspension: { retention_seconds: 86400 } } as SandboxDeployment; describe("suspension settings", () => { it("edits the idle time and shows retention", () => { const html = renderToStaticMarkup( true} />); @@ -13,6 +13,9 @@ describe("suspension settings", () => { expect(html).toContain("24 hours"); expect(html).toContain("Edit node"); expect(renderToStaticMarkup( true} />)).not.toContain("Edit node"); - expect(renderToStaticMarkup( true} />)).toBe(""); + const expiring = renderToStaticMarkup( true} />); + expect(expiring).toContain("Idle timeout"); + expect(expiring).toContain('value="300"'); + expect(expiring).not.toContain("Snapshot retention"); }); }); diff --git a/apps/web/src/features/sandbox/SuspensionSettings.tsx b/apps/web/src/features/sandbox/SuspensionSettings.tsx index 15232b677..dc9945518 100644 --- a/apps/web/src/features/sandbox/SuspensionSettings.tsx +++ b/apps/web/src/features/sandbox/SuspensionSettings.tsx @@ -24,7 +24,7 @@ export function SuspensionSettings({ deployment, disabled }: { deployment: Sandb async function save(seconds: number) { if (disabled) return false; try { - return await writeSandboxDeployment(queryClient, (signal) => sandboxAdmin.updateSuspension({ idle_seconds: seconds }, { signal })) !== null; + return await writeSandboxDeployment(queryClient, (signal) => sandboxAdmin.updateIdleSeconds({ idle_seconds: seconds }, { signal })) !== null; } catch (error) { toast.show(t(sandboxWriteUncertain(error) ? "Couldn't confirm the sandbox change" : "Core rejected the sandbox change"), { tone: "error", detail: sandboxRequestError(error, locale), key: "sandbox-write" }); return false; @@ -33,25 +33,26 @@ export function SuspensionSettings({ deployment, disabled }: { deployment: Sandb return ; } -/** The idle time form and Core's read-only snapshot retention. */ +/** The idle time form for every Provider, and Core's read-only snapshot retention where the Provider suspends. */ export function SuspensionForm({ deployment, disabled, onSave }: { deployment: SandboxDeployment; disabled: boolean; onSave: (seconds: number) => Promise }) { const { t, i18n } = useTranslation("sandbox"); const policy = deployment.suspension; const [draft, setDraft] = useState(null); - if (!policy) return null; - const idle = draft ?? String(policy.idle_seconds); + const idle = draft ?? String(deployment.idle_seconds); const valid = /^\d+$/.test(idle) && Number(idle) >= 30 && Number(idle) <= 86400; - return
+ return
{ event.preventDefault(); if (valid && !disabled && await onSave(Number(idle))) setDraft(null); }}> -
{t("Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged.")}{deployment.mode === "direct" ? null : <> {t("Capacity pressure may suspend an idle sandbox after 15 seconds.")}}
+
{policy + ? <>{t("Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged.")}{deployment.mode === "direct" ? null : <> {t("Capacity pressure may suspend an idle sandbox after 15 seconds.")}} + : t("Deployment-wide: 30–86,400 seconds. This Provider does not suspend sandboxes. A standalone Environment with no Session attached expires after this idle time; sandboxes with a Session keep running.")}
setDraft(event.target.value)} aria-invalid={!valid} aria-describedby={!valid ? "sandbox-idle-error" : undefined} /> - + {!valid ? {t("Enter a whole number from 30 to 86,400.")} : null}
- }>{formatSpan(policy.retention_seconds, i18n.resolvedLanguage)} + {policy ? }>{formatSpan(policy.retention_seconds, i18n.resolvedLanguage)} : null}
; } diff --git a/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts b/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts index 13485840d..359b9c72f 100644 --- a/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts +++ b/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts @@ -5,7 +5,7 @@ import { sandboxAdmin, sandboxDeploymentQuery } from "./sandbox-queries"; import { writeSandboxDeployment } from "./sandbox-deployment-write"; import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; -const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", owner_epoch: 1, generation: 1, provider: "microsandbox", core_url: "http://core", mode: "nodes", reset: null, resources: { allocations: 0, running: 0, pending: 0 }, suspension: null }; +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", owner_epoch: 1, generation: 1, provider: "microsandbox", core_url: "http://core", mode: "nodes", reset: null, resources: { allocations: 0, running: 0, pending: 0 }, idle_seconds: 300, suspension: null }; function deferred() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx index fa7086132..a517ef31c 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -18,7 +18,7 @@ function cache(provider: SandboxDeployment["provider"]) { const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false, gcTime: Infinity } } }); const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "install", owner_epoch: 1, generation: 2, provider, core_url: "https://core.example", - mode: provider === "" ? "" : provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, running: 0, pending: 0 }, suspension: null, + mode: provider === "" ? "" : provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, running: 0, pending: 0 }, idle_seconds: 300, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; const config: SandboxConsoleConfig = { node_installer: false, node_installer_sha256: "", node_artifacts: [] }; diff --git a/apps/web/src/features/sandbox/sandbox-queries.test.ts b/apps/web/src/features/sandbox/sandbox-queries.test.ts index 30a12ae13..b638ee4a0 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.test.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.test.ts @@ -10,7 +10,7 @@ const reset: SandboxReset = { }; const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "installation-a", provider: "microsandbox", core_url: "https://core.example", reset, - owner_epoch: 1, generation: 2, mode: "nodes", resources: { allocations: 2, running: 2, pending: 0 }, suspension: null, ...overrides, + owner_epoch: 1, generation: 2, mode: "nodes", resources: { allocations: 2, running: 2, pending: 0 }, idle_seconds: 300, suspension: null, ...overrides, }); const clients: QueryClient[] = []; const cache = () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); clients.push(client); return client; }; diff --git a/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx b/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx index 0977b8533..9370952a8 100644 --- a/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx +++ b/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx @@ -6,7 +6,7 @@ import { node } from "../overview/test-fixtures"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxSnapshotQuery } from "./sandbox-queries"; import { useSandboxPageState } from "./use-sandbox-page-state"; -const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, running: 1, pending: 0 }, suspension: null }; +const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "microsandbox", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, running: 1, pending: 0 }, idle_seconds: 300, suspension: null }; const resetting: SandboxDeployment = { ...configured, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }; const clients: QueryClient[] = []; function cache() { diff --git a/apps/web/src/i18n/locales/en/diagnostics.ts b/apps/web/src/i18n/locales/en/diagnostics.ts index 257fc576e..5fd2bbc61 100644 --- a/apps/web/src/i18n/locales/en/diagnostics.ts +++ b/apps/web/src/i18n/locales/en/diagnostics.ts @@ -63,7 +63,7 @@ export const diagnostics = { exitCode: "Exit code {{code}}.", httpStatus: "HTTP {{status}}.", unreachableHost: "Then check that the URL's host resolves and is reachable from the Session's sandbox, or from the host of a self-hosted Session.", - step: { setup: "setup", python: "Python dependencies", npm: "npm dependencies", file: "file preparation", skill: "Skill preparation", harness: "Harness preparation", plugin: "Plugin preparation", configure: "environment configuration", finalize: "finalization" }, + step: { setup: "setup", python: "Python dependencies", npm: "npm dependencies", file: "file preparation", skill: "Skill preparation", plugin: "Plugin preparation", configure: "environment configuration", finalize: "finalization" }, category: { timeout: "The preparation timed out.", runtime_disconnected: "The Runtime disconnected.", diff --git a/apps/web/src/i18n/locales/zh-CN/diagnostics.ts b/apps/web/src/i18n/locales/zh-CN/diagnostics.ts index 8bf3309dc..dc154e194 100644 --- a/apps/web/src/i18n/locales/zh-CN/diagnostics.ts +++ b/apps/web/src/i18n/locales/zh-CN/diagnostics.ts @@ -63,7 +63,7 @@ export const diagnostics = { exitCode: "退出码 {{code}}。", httpStatus: "HTTP {{status}}。", unreachableHost: "再确认 Session 的沙箱(自托管 Session 则为其主机)能解析并访问该 URL 的主机。", - step: { setup: "设置", python: "Python 依赖", npm: "npm 依赖", file: "文件准备", skill: "Skill 准备", harness: "Harness 准备", plugin: "Plugin 准备", configure: "环境配置", finalize: "收尾" }, + step: { setup: "设置", python: "Python 依赖", npm: "npm 依赖", file: "文件准备", skill: "Skill 准备", plugin: "Plugin 准备", configure: "环境配置", finalize: "收尾" }, category: { timeout: "准备超时。", runtime_disconnected: "Runtime 已断开连接。", diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index 3c182b537..441a61d78 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -22,6 +22,9 @@ export const sandbox = { "Capacity pressure may suspend an idle sandbox after 15 seconds.": "容量紧张时,沙箱空闲 15 秒后可能提前挂起。", "Enter a whole number from 30 to 86,400.": "请输入 30 到 86,400 之间的整数。", "Automatic suspension": "自动挂起", + "Idle timeout": "空闲超时", + "Idle before expiry (seconds)": "空闲多久后过期(秒)", + "Deployment-wide: 30–86,400 seconds. This Provider does not suspend sandboxes. A standalone Environment with no Session attached expires after this idle time; sandboxes with a Session keep running.": "整个部署统一生效:30–86,400 秒。此提供商不挂起沙箱;没有挂载 Session 的独立 Environment 空闲超过该时间后过期,有 Session 的沙箱继续运行。", "Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume.": "这是新挂起任务采用的当前策略,不是现有快照的到期倒计时。下一轮 Turn 会请求恢复。", "The idle policy applies to the whole deployment.": "空闲策略对整个部署生效。", "Snapshot retention": "快照保留期", diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 9a3f7ac49..6dea7b673 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -34,7 +34,7 @@ Paths are relative to `/core/v1`. | `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials of a `self_hosted` Environment | [Executor credentials](./environment-executor-credentials.md#core-key-routes) | | `projects/{project_id}/resource-owners`, `projects/{project_id}/write-operations` | Which API key created a resource and each key's writes | [Write provenance](#write-provenance) | | `harnesses`, `harnesses/{harness}/model-configuration` | Enabled Harnesses and each Harness's deployment default model | [Deployment defaults](./model-execution.md#deployment-defaults) | -| `sandbox/deployment`, `sandbox/deployment/reset`, `sandbox/providers/{provider}/discovery` | The sandbox provider, resources and Runtime, idle suspension policy, reset, and provider configuration discovery such as E2B templates | [Sandbox deployment](./sandbox-deployment.md#routes) | +| `sandbox/deployment`, `sandbox/deployment/reset`, `sandbox/providers/{provider}/discovery` | The sandbox provider, resources and Runtime, idle timeout, reset, and provider configuration discovery such as E2B templates | [Sandbox deployment](./sandbox-deployment.md#routes) | | `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Node enrollment tokens, nodes and their allocations and host history | [Add and manage nodes](../../docs/getting-started/nodes.md), [sandbox deployment](./sandbox-deployment.md), [node host history](./runtime-observability-api.md#node-host-observations-and-history) | | `summary` | Session counts and usage by Project, Agent or key | [Summary](#summary) | | `metrics` | Core's own process, execution, database and job metrics | [Core metrics](./core-metrics.md) | diff --git a/contracts/agents-api/automations.md b/contracts/agents-api/automations.md index f5b18b315..970f83f3a 100644 --- a/contracts/agents-api/automations.md +++ b/contracts/agents-api/automations.md @@ -39,7 +39,7 @@ If Core was down when occurrences were due, it records only the latest one. `target.type` is `new_session` or `existing_session`. An update keeps the type; another type returns 400 with param `target.type`. -`new_session` takes the fields of the Session create body, plus `type`: `environment` and `input` (an array of user messages) are required, exactly one of `agent` and `agent_id` must be given, and `metadata` and `vault_ids` are optional. Create and update run the checks Session create runs before it creates anything: the request shape, the Environment Template, the saved Agent, the model and its provider, the Vault bindings, and the Harness's support for the configuration, so an unsupported tool such as `automations` returns 400 at create. The rejections are Session create's, with their params prefixed `target.`: `environment.environment_id`, `container_size`, `desktop.enabled`, `packages.system` and `blocked_domains` are rejected. Deployment readiness, admission and leases are checked at each delivery. Beyond that: +`new_session` takes the fields of the Session create body, plus `type`: `environment` and `input` (an array of user messages) are required, exactly one of `agent` and `agent_id` must be given, and `metadata` and `vault_ids` are optional. Create and update run the checks Session create runs before it creates anything: the request shape, the Environment Template, the saved Agent, the model and its provider, the Vault bindings, and the Harness's support for the configuration, so an unsupported tool such as `automations` returns 400 at create. The rejections are Session create's, with their params prefixed `target.`: `container_size`, `desktop.enabled`, `packages.system` and `blocked_domains` are rejected. `environment.environment_id` names a [standalone Environment](./environments.md#standalone-environments): create and update check only that it exists in the Project (404), and each delivery attaches it, so a delivery while another Session holds it fails with the attach conflict. Deployment readiness, admission and leases are checked at each delivery. Beyond that: - An inline `agent` follows Session create: `model` is required unless a deployment default model applies. - `x_agents_core` is rejected anywhere in the target. diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index c2b659c7d..9933241af 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1723,12 +1723,9 @@ definitions: - RolloutPreparing deployment.Suspension: properties: - idle_seconds: - type: integer retention_seconds: type: integer required: - - idle_seconds - retention_seconds type: object deployment.View: @@ -1742,6 +1739,9 @@ definitions: type: boolean generation: type: integer + idle_seconds: + description: Idle timeout for every Provider. With checkpoint support an idle sandbox suspends; without it an idle unattached standalone Environment expires. + type: integer installation_id: type: string metadata: @@ -1767,12 +1767,13 @@ definitions: suspension: allOf: - $ref: '#/definitions/deployment.Suspension' - description: Idle suspension policy; null unless the selected Provider declares checkpoint support. + description: Snapshot retention; null unless the selected Provider declares checkpoint support. x-nullable: true required: - core_url - credential_configured - generation + - idle_seconds - installation_id - mode - owner_epoch @@ -7079,7 +7080,7 @@ paths: patch: consumes: - application/json - description: Changes the Core idle timeout for every running allocation without a restart or generation rollout. Requires checkpoint support and no active reset. Already-started suspensions and snapshot expiry are unchanged. Capacity pressure can still suspend quiet idle compute earlier. + description: Changes the Core idle timeout for every running allocation without a restart or generation rollout. Requires a selected Provider and no active reset. With checkpoint support an idle sandbox suspends; without it an idle unattached standalone Environment expires. Already-started suspensions and snapshot expiry are unchanged. Capacity pressure can still suspend quiet idle compute earlier. parameters: - description: Idle timeout in seconds in: body @@ -7116,7 +7117,7 @@ paths: $ref: '#/definitions/api.CoreErrorResponse' security: - DeploymentAdminAuth: [] - summary: Update automatic suspension policy + summary: Update idle timeout tags: - Sandbox Manager post: diff --git a/contracts/agents-api/environment-files.md b/contracts/agents-api/environment-files.md index bbab8f1d4..88b55b116 100644 --- a/contracts/agents-api/environment-files.md +++ b/contracts/agents-api/environment-files.md @@ -11,6 +11,7 @@ The routes follow the SDK pinned in [upstream.json](https://github.com/MiniMax-A - Environment files work on `openai_hosted` and `self_hosted` Environments. A `none` Environment returns 503 `execution_unavailable`. - Public paths start at `/workspace`, which stands for the Environment's workspace directory wherever it is on the machine. - The Environment is looked up in the caller's Project first; a missing or foreign ID returns 404. +- On a standalone Environment that no Session is attached to, both operations return 409 `conflict_error`, "Attach a session to this environment before using its files." - On an `openai_hosted` Environment that is still `pending`, both operations return 400 `the hosted environment is still provisioning; wait until it is connected before accessing files`. This check runs after request validation and before any source File lookup. Any other state proceeds, and an unreachable Runtime returns 503. - File operations wait within their request deadline for Environment initialization to complete; `connected` alone does not imply readiness, and failed initialization or exhausted execution capacity still returns 503. - Listing and writing never start a Turn or send input to the model. diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 83755e73c..3cea28ea5 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -2,7 +2,7 @@ title: "Environments and Templates" --- -An Environment is the execution resource of a Session: the machine, workspace and prepared capabilities that a Harness runs in. A Session creates its Environment through its `environment` configuration; there is no standalone create call. An Environment Template is reusable preparation configuration that a Session resolves when it is created. This contract covers both resources, the two placements, input admission, capability preparation, Skills, Plugins and MCP connection origins. +An Environment is the execution resource of a Session: the machine, workspace and capabilities that a Harness runs in. A Session creates its Environment through its `environment` configuration, or attaches one that `POST /agents/environments` created earlier ([standalone Environments](#standalone-environments)). An Environment Template is reusable preparation configuration that a Session resolves when it is created. This contract covers both resources, the two placements, input admission, capability preparation, Skills, Plugins and MCP connection origins. Related owners: @@ -18,12 +18,12 @@ Paths follow the SDK resource methods, before the service's `/v1` prefix. The li | Resource | Operations | Rules | | --- | --- | --- | -| [Environment](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/resources/beta/agents/environments/environments.py) | `GET /agents/environments/{id}` | Created through Session configuration. Returns `id`, `type`, `status` and the `files`, `plugins` and `skills` recorded at Session creation. | -| Environment collection | `POST`, `GET /agents/environments` | Not supported: 404 `unsupported_operation` ([unsupported operations](./wire-semantics.md#unsupported-operations)). Environments are created only through Session configuration. | +| [Environment](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/resources/beta/agents/environments/environments.py) | `GET /agents/environments/{id}` | Created through Session configuration or `POST /agents/environments`. Returns `id`, `type`, `status` and the `files`, `plugins` and `skills` recorded at creation. | +| Environment collection | `POST`, `GET /agents/environments` | See [Standalone Environments](#standalone-environments). | | [Template](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/resources/beta/agents/environments/templates.py) | `POST`, `GET /agents/environments/templates`; `GET`, `POST`, `DELETE /agents/environments/templates/{id}` | See [Templates](#templates). | | [Files](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/resources/beta/agents/environments/files.py) | `POST`, `GET /agents/environments/{id}/files` | See [Environment files](./environment-files.md). | -An Environment read joins its live owning Session within the caller's Project and returns the durable connection status. It needs no live Runtime, starts no native work and changes no connection state. The installation arrays list API-managed files, Plugins and Skills for both placements: files as `{id, type, path, file_id, size_bytes}` without content, Skills as `{type, name, description, skill_id, version}` and Plugins as `{type, name, description}`. Capabilities discovered in local capability directories are not listed. +An Environment read stays within the caller's Project, hides an Environment whose owning Session was deleted, and returns the durable connection status. It needs no live Runtime, starts no native work and changes no connection state. The installation arrays list API-managed files, Plugins and Skills for both placements: files as `{id, type, path, file_id, size_bytes}` without content, Skills as `{type, name, description, skill_id, version}` and Plugins as `{type, name, description}`. Capabilities discovered in local capability directories are not listed. [Session environment input](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/types/beta/environment_param.py) and [output](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/types/beta/environment.py) have different shapes: @@ -36,11 +36,16 @@ An Environment read joins its live owning Session within the caller's Project an | [Environment resource](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/types/beta/agents/environment_info.py) | `pending`, `ready`, `connected`, `disconnected`, `suspended`, `expired`, `failed` | | [Session environment event](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/types/beta/agent_session_environment_state.py) | The same seven states, with a nullable error | -Core emits `ready` only in events. It never reports `suspended`, even for an idle hosted Environment it checkpoints and stops, and it emits no `agent.session.environment.suspended`, `.expired` or `.reset` event; an Environment whose retention ends reads `expired` without the event. The resource moves as follows, and a failed or expired Environment is final: +Core emits `ready` in events, and the resource reads `ready` for a prepared standalone Environment that no Session is attached to ([List and retrieve](#standalone-environments)). It never reports `suspended`, even for an idle hosted Environment it checkpoints and stops, and it emits no `agent.session.environment.suspended`, `.expired` or `.reset` event; an Environment whose retention ends reads `expired` without the event. The resource moves as follows, and a failed or expired Environment is final: ```mermaid stateDiagram-v2 [*] --> pending + pending --> ready + ready --> connected + connected --> ready + disconnected --> ready + ready --> expired pending --> connected connected --> disconnected disconnected --> connected @@ -54,10 +59,35 @@ 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 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. +Core creates the Environment record in the Session creation transaction; the Session upsert picks the retry winner, and retries never create or repair a Session-owned 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 of a Session-owned Environment is deleted, reads hide the Environment while Core keeps the record for settlement and cleanup. A standalone Environment outlives its Sessions. A Session with `none` has no Environment. A managed outer Environment must exclude broader application credentials and other tenants’ secrets. +## Standalone Environments + +`POST /agents/environments` creates and initializes an `openai_hosted` Environment before any Session exists, so that a later Session starts on warm compute. A standalone Environment is reusable: one Session at a time attaches to it, and when that Session is deleted the next one can attach. The Environment keeps its compute, workspace and preparation across Sessions. + +**Create.** The body is `{environment, vault_ids}`. `environment` takes the `openai_hosted` member of the [Session `environment`](#placements) without `environment_id`, and may name a Template; Core resolves and applies the Template as it does for a Session. `vault_ids` may be null or `[]`; a nonempty list returns 400. The `Idempotency-Key` is 1–256 bytes and scoped to the Project. Every Project key authenticates as the Project, so the creator adds no further scope: the list, retrieve and the key reach the Project's Environments. A retry with the same key and the same body as sent returns the Environment in its current state, `pending` included, even if the named Template was deleted since; creation completes in one transaction, so a retry never sees an incomplete one; a different body returns 409 `idempotency_conflict`. Core validates the admission that a hosted Session needs for its Environment (deployment, workspace storage, network policy), freezes Skills, seals the setup and initial files, and returns 201 with the Environment in status `pending`. Provisioning then starts and needs no caller action. + +**List and retrieve.** `GET /agents/environments` lists the Project's standalone Environments, including `failed` and `expired` ones, in the official pagination with `type=openai_hosted` as the only value; [Page size](./wire-semantics.md#page-size) owns `limit`. `GET /agents/environments/{id}` serves both kinds. An Environment with no attached Session whose initialization is complete reads `ready` where it would read `pending`, `connected` or `disconnected`; once a Session is attached it reads the connection status, and `failed` and `expired` always read as such. + +**Attach.** `POST /agents/sessions` with `environment: {"type": "openai_hosted", "environment_id": "…"}` (9–256 characters) runs the Session on that Environment, with its frozen configuration. Any other member beside `environment_id` returns 400 with the member as `param` (`environment.packages`, …), as does `x_agents_core.environment`. The Harness, model and tools come from the Session. Core returns: + +| Case | Response | +| --- | --- | +| Missing, another Project's, or a deleted Session-owned Environment | 404 `not_found_error` | +| Attached to a live Session | 409 `conflict_error`, "Environment is attached to another session.", `param` `environment.environment_id` | +| `failed` or `expired` | 409 `conflict_error`, "Environment has failed or expired and cannot take a session.", `param` `environment.environment_id` | +| The Environment's network policy cannot reach the Session's model provider | 400 `model_provider_unreachable`, `param` `environment.environment_id` | + +A `pending` Environment is accepted: the Session's first input waits for preparation like any hosted input, and a suspended or released allocation wakes. Attach repeats no workspace admission and checks the Session's model provider against the Environment's frozen network policy (the last row above). It also applies the checks that depend on the Session, such as the Harness's native history requirement, and the deployment must admit new hosted work: a pending reset or closed admission rejects the attach like an inline create. Two concurrent attaches have exactly one winner. + +**Detach.** Deleting the Session is the only detach. The Environment is attachable at once and keeps its workspace and preparation. + +**Files.** [Environment files](./environment-files.md#where-files-work) owns the files routes while no Session is attached. + +**Lifecycle without a Session.** An unattached Environment provisions and initializes without a Harness, and its lifetime depends on the Provider. With checkpoint support it suspends after the deployment's idle timeout and expires when snapshot retention ends; [Suspension policy](./sandbox-deployment.md#suspension-policy) owns both values. Without checkpoint support it cannot be suspended, so it expires after the idle timeout without activity, counted from its last activity: the end of its initialization, a detach or later activity. A failed Environment releases its compute at once. On any Provider it also expires after confirmed compute loss, including compute released while a Session held it once that Session is deleted, because Core never replaces it on new compute: nothing identifies the work to replay. A [sandbox reset](./sandbox-deployment.md#reset) expires it as well. An expired Environment cannot be attached. An ended (`expired` or `failed`) unattached Environment releases its placement and workspace. + ## Placements Both placements run the same Runtime: the daemon, the selected Harness, native tools and the workspace run together on one machine. They differ only in who owns that machine. @@ -79,7 +109,7 @@ The deployment's configured Sandbox Provider (microsandbox, E2B or OpenSandbox, - Omitted or null `network` means enabled; `disabled` and `restricted` need a hosted Environment on a sandbox Provider that can restrict ([network policy](#restricted-network)). - A Core restart keeps the allocation, workspace and native identity and never replays uncertain work. - Terminal cleanup revokes authority and settles pending input in one transaction before the Provider reclaims compute. New input on a terminal Environment is rejected. -- Deleting the Session reclaims its Environment; deleting a Template does not. +- Deleting the Session reclaims a Session-owned Environment; it never reclaims a standalone one, and deleting a Template reclaims neither. ### Self-hosted (`self_hosted`) @@ -135,7 +165,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. 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. +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. Core uses these common control states, never Harness-specific error text. ### Activity and required actions @@ -230,11 +260,11 @@ 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. Initialization needs no Harness. Each operation rechecks current authority and the original socket; completion rechecks the exact binding. +- A missing socket does not consume a pending attempt. 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. -- Every `openai_hosted` Environment starts `pending`, including one with no files or setup, and journals `agent.session.environment.pending` in the transaction that starts its initialization and `agent.session.environment.ready` in the one that completes it, each exactly once. Core journals `agent.session.environment.connected` when it observes the Runtime's connection, before or after `pending`. A `self_hosted` Environment emits neither `pending` nor `ready`, and starts `complete` unless it has files or setup. +- Every `openai_hosted` Environment starts `pending`, including one with no files or setup, and journals `agent.session.environment.pending` in the transaction that starts its initialization and `agent.session.environment.ready` in the one that completes it, each exactly once. Core journals `agent.session.environment.connected` when it observes the Runtime's connection, before or after `pending`. These are Session events, journaled only while a Session is attached: an Environment prepared before attach emits no `pending` or `ready` to the Session that attaches later. A `self_hosted` Environment emits neither `pending` nor `ready`, and starts `complete` unless it has files or setup. A failed initialization records the events and Session state that [Environment initialization failure](./sessions-events.md#environment-initialization-failure) defines. The reason names only the step and its exit status: @@ -251,7 +281,7 @@ Every initialization operation returns a typed `rejected`, `failed` or `unknown` ## Templates -A Template is Project-owned configuration for `openai_hosted` Sessions and for `x_agents_core.environment`. It holds no running workspace and is unrelated to Provider images such as E2B templates. Every Session that references it gets its own Environment through the same preparation as inline configuration. Template parsing, storage and resolution never select a Harness or Provider or depend on native tool names or private Harness paths, so a new Harness or Provider needs no Template change. +A Template is Project-owned configuration for `openai_hosted` Sessions, for `POST /agents/environments` and for `x_agents_core.environment`. It holds no running workspace and is unrelated to Provider images such as E2B templates. Every Session that references it gets its own Environment through the same preparation as inline configuration. Template parsing, storage and resolution never select a Harness or Provider or depend on native tool names or private Harness paths, so a new Harness or Provider needs no Template change. ```python from openai import OpenAI diff --git a/contracts/agents-api/go-bindings.json b/contracts/agents-api/go-bindings.json index 605695b91..01b1dc5b6 100644 --- a/contracts/agents-api/go-bindings.json +++ b/contracts/agents-api/go-bindings.json @@ -182,6 +182,9 @@ "sources": ["#/components/schemas/PublicEnvironmentResource"], "order": ["id", "object", "type", "status", "files", "plugins", "skills"] }, + "EnvironmentInfoList": { + "sources": ["#/components/schemas/AgentEnvironmentListResource"] + }, "EnvironmentNetwork": { "sources": ["#/components/schemas/NetworkPolicyResource"] }, diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 7fe863379..30cac32ed 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -33,7 +33,7 @@ Evidence for a status comes from the pinned official SDK and raw HTTP against th | Items | list | Partial: messages, commands, MCP calls, functions, web search, reasoning and Subagent coordination Items; other native variants are not projected | [Turns and Items](./sessions-events.md#turns-and-items) | | Artifacts | retrieve, list, delete, content | Implemented | [Environment files and Artifacts](./environment-files.md) | | Subagents | retrieve, list; Items; Turns retrieve and list; Turn Items | Partial: read-only child work; no live child progress or optional native operations | [Subagents](./subagents.md) | -| Environments | retrieve | Implemented; Session-scoped ([ownership](./environments.md#resources-and-states)). Core rejects `environment.environment_id`, so an existing hosted Environment cannot be attached to another Session. Workspaces shared across Sessions belong to the application ([Projects own assets](../../docs/concepts.md#projects-own-assets)) | [Environments](./environments.md) | +| Environments | create, retrieve, list | Implemented for `openai_hosted`; a Session attaches a standalone Environment with `environment.environment_id`, one Session at a time | [Standalone Environments](./environments.md#standalone-environments) | | Environment files | create, list | Implemented; the list is not recursive | [Environment files and Artifacts](./environment-files.md) | | Environment Templates | create, retrieve, update, list, delete | Implemented; execution limits are listed under [known gaps](#known-gaps) | [Environment Templates](./environments.md#templates) | | Vaults | create, retrieve, update, list, delete | Implemented | [Vaults and Credentials](./vaults.md) | @@ -41,7 +41,6 @@ Evidence for a status comes from the pinned official SDK and raw HTTP against th | Files | create, retrieve, list, delete, content | Implemented for `purpose=user_data`; content download is rejected | [Files and Skills](./source-files.md) | | Skills and Skill versions | create, retrieve, update, list, delete, content | Implemented | [Files and Skills](./source-files.md) | | Automations and invocations | create, retrieve, update, list, delete, pause, resume; invocation create, retrieve, list | Implemented; `created_by` is always `developer` | [Automations](./automations.md) | -| Environments collection | create, list (`/agents/environments`) | Not supported: 404 `unsupported_operation`; Core's Environments are Session-scoped | [Unsupported operations](./wire-semantics.md#unsupported-operations) | | Session traces | list | Not supported: 404 `unsupported_operation` | [Unsupported operations](./wire-semantics.md#unsupported-operations) | Which operation each Harness supports on each placement is in the [Harness capabilities](./harness-capabilities.md). [API behavior](./wire-semantics.md) holds the rules that apply across resources: requests, errors and lists. @@ -64,6 +63,16 @@ Each item is Core's deliberate or native behavior where the official service beh - The Files routes keep a local `unsupported_parameter` code for a repeated query key. - Core has no CORS handling; the official service sends CORS headers. +**Standalone Environments** ([standalone Environments](./environments.md#standalone-environments)) + +- Core has no prewarming-beta gate; the official create and list require access to it. +- A standalone Environment takes no `vault_ids` ([Create](./environments.md#standalone-environments)); credentials reach an Environment only through its Session. +- A standalone Environment is created with its own network policy and is not widened for a later Session. An attach whose model provider the policy cannot reach returns 400 `model_provider_unreachable`; the official service has no such error. +- The `Idempotency-Key` of `POST /agents/environments` never expires, where the official service deduplicates for 24 hours within the organization, project and creator. Core scopes it to the Project. A reused key with different parameters returns 409, as the pin states; only the error code `idempotency_conflict` is unverified against the official service. +- The pin says an incomplete hosted creation returns 409. Core completes a creation in one transaction, so a retry with the same key and body returns the Environment in its current state, `pending` included. +- The Environment list's `limit` bounds and its 404 for an unresolved `after` ([Page size](./wire-semantics.md#page-size), [Cursors](./wire-semantics.md#cursors)) are Core's behavior and have no official sample. +- An unattached Environment that [expires](./environments.md#standalone-environments) is never replaced on new compute. + **Lists** ([lists](./wire-semantics.md#lists)) - A deleted Agent used as a cursor returns 404; the official service still pages from it. @@ -200,6 +209,7 @@ Each item is Core's deliberate or native behavior where the official service beh ### Unverified against the official service +- Standalone Environments: the `not` constraint of `environment.environment_id` reports the first offending member in schema order, and its message text is not sampled; a member the schema does not list returns the unknown-parameter error. Core reports `ready` for an unattached Environment whose initialization is complete, and before that its connection status: `pending` after creation, then `connected` or `disconnected` once the Runtime connects. Core journals the Session Environment events only while a Session is attached, so a Session that attaches to an Environment that is already `ready` sees no `pending` or `ready`; the official events for that case are unverified. How the official service handles an attach to an Environment that is still `pending`, whether it lists `failed` and `expired` Environments, and the file routes before attach (Core returns 409) have no official sample. - When a Session update binds to a queued Turn: Core binds each root Turn's settings when it accepts the input that creates the Turn, so a Turn queued before an update keeps the earlier values. - The meaning of "automatic review is not enabled" in a Session response: Core omits `review` unless it is `auto_review`. - Automations, where the pinned sources are silent ([Automations](./automations.md)): diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 8df9c16a1..ea02e5307 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -13,7 +13,7 @@ Every route requires the Core key. [Web's console server](../../docs/web/console | Route | Effect | | --- | --- | | `GET /core/v1/sandbox/deployment` | Read the safe active configuration, rollout, reset and resource counts | -| `PATCH /core/v1/sandbox/deployment` | Update deployment-wide idle suspension without changing the configuration generation | +| `PATCH /core/v1/sandbox/deployment` | Update the deployment-wide idle timeout without changing the configuration generation | | `POST /core/v1/sandbox/deployment` | Select the initial provider and resources | | `PUT /core/v1/sandbox/deployment` | Change the same provider's target online | | `POST /core/v1/sandbox/deployment/reset` | Start or escalate a durable clear of hosted resources | @@ -94,10 +94,10 @@ E2B declares `template` as discovered. An empty `path` lists the key's templates ## Safe response -GET and successful writes return `installation_id`, `provider`, `core_url` (read-only: the installation public URL, present even before configuration), `mode`, `generation`, `owner_epoch`, `reset`, `rollout`, `suspension`, `resources` and `credential_configured`. A configured deployment also returns `specification`, `specification_digest`, `configuration` and `metadata`: the adapter's public projection of its selectors and of the observations it recorded, never raw stored values or secrets. E2B returns `configuration.template`, `configuration.api_url`, `configuration.domain` and, once recorded, `metadata.template_build`. OpenSandbox returns `configuration.api_url`, `configuration.image` and an empty `metadata` object. microsandbox returns empty `configuration` and `metadata` objects and `credential_configured: false`; an unconfigured deployment has neither object. +GET and successful writes return `installation_id`, `provider`, `core_url` (read-only: the installation public URL, present even before configuration), `mode`, `generation`, `owner_epoch`, `reset`, `rollout`, `idle_seconds`, `suspension`, `resources` and `credential_configured`. A configured deployment also returns `specification`, `specification_digest`, `configuration` and `metadata`: the adapter's public projection of its selectors and of the observations it recorded, never raw stored values or secrets. E2B returns `configuration.template`, `configuration.api_url`, `configuration.domain` and, once recorded, `metadata.template_build`. OpenSandbox returns `configuration.api_url`, `configuration.image` and an empty `metadata` object. microsandbox returns empty `configuration` and `metadata` objects and `credential_configured: false`; an unconfigured deployment has neither object. - `metadata.template_build` is `{status, resources: {cpus, memory_mib, root_disk_mib}}`: the build as Core read it through the pinned SDK when the selection was saved. GET never calls E2B, so it stays cheap during an E2B outage. Validation admits only a `ready` build whose CPU count and memory equal the selected values; `root_disk_mib` is the build's native disk size, which Core does not enforce. Unknown values are null, `metadata: {}` means no observation was recorded, and an identical PUT without a credential does not refresh it. -- `suspension` is `{idle_seconds, retention_seconds}`, Core's [suspension policy](../../docs/sandbox-provider.md#suspension), when the selected Provider declares checkpoint support; any other deployment, including an unconfigured one, returns null. +- `idle_seconds` is the deployment's idle timeout, for every Provider. `suspension` is `{retention_seconds}`, Core's [suspension policy](../../docs/sandbox-provider.md#suspension), when the selected Provider declares checkpoint support; any other deployment, including an unconfigured one, returns null. - Request `resources` and response `specification.resources` are per-sandbox limits. Response `resources.allocations` and `resources.pending` count unreleased allocations and pending hosted Environments without an allocation; `resources.running` counts the unreleased allocations whose compute is running, so it excludes suspended ones. - An unconfigured deployment has an empty provider and no specification. microsandbox uses `mode: nodes`; E2B and OpenSandbox use `mode: direct`, without a synthetic node. - `generation` identifies the saved selection. `owner_epoch` fences the execution owner and node connections; it does not replace `expected_generation`. @@ -133,9 +133,11 @@ The request rules are those of E2B key replacement. The candidate key must authe ## Suspension policy -`PATCH /core/v1/sandbox/deployment` accepts `{"idle_seconds": 300}`. `idle_seconds` is a required integer from 30 to 86400 seconds, defaulting to 300, stored in Core's database. Only configured Providers declaring checkpoint support accept this change; unconfigured, unsupported or resetting deployments reject it. Success returns the deployment view with the saved `suspension.idle_seconds`. +`PATCH /core/v1/sandbox/deployment` accepts `{"idle_seconds": 300}`. `idle_seconds` is a required integer from 30 to 86400 seconds, defaulting to 300, stored in Core's database. Every configured Provider accepts this change; unconfigured or resetting deployments reject it. Success returns the deployment view with the saved `idle_seconds`. -Changes apply to existing and new running allocations on the next lifecycle pass, without a Core restart, generation change or node rollout. Suspension already in progress is not cancelled; existing snapshots and the 24-hour retention period are unchanged. [Capacity-pressure reclamation](../../docs/sandbox-provider.md#suspension) can still suspend an idle allocation earlier and, when retained slots are full, [end an eligible checkpoint's retention early](../../docs/sandbox-provider.md#cold-replacement-after-checkpoint-retention). Web manages this deployment-wide policy in **Settings** → **Sandbox and scheduling** → **Automatic suspension**; each node's capacity limits stay node-owned and are edited on **Nodes** with **Edit node**. +With checkpoint support the idle timeout starts [suspension](../../docs/sandbox-provider.md#suspension). Without it, an allocation that a Session holds keeps running; [Lifecycle without a Session](./environments.md#standalone-environments) owns what the timeout does to an unattached Environment. + +Changes apply to existing and new running allocations on the next lifecycle pass, without a Core restart, generation change or node rollout. Suspension already in progress is not cancelled; existing snapshots and the 24-hour retention period are unchanged. [Capacity-pressure reclamation](../../docs/sandbox-provider.md#suspension) can still suspend an idle allocation earlier and, when retained slots are full, [end an eligible checkpoint's retention early](../../docs/sandbox-provider.md#cold-replacement-after-checkpoint-retention). Web manages this deployment-wide setting in **Settings** → **Sandbox and scheduling** → **Automatic suspension**, or **Idle timeout** when the Provider has no checkpoint support; each node's capacity limits stay node-owned and are edited on **Nodes** with **Edit node**. ## Generation ownership and rollout @@ -178,7 +180,7 @@ To change backend, start a reset: {"expected_generation": 7, "clear": "auto", "deadline_seconds": 3600} ``` -`clear` is required: `auto` or `force`. `deadline_seconds` applies to `auto`, defaults to 3600 and accepts 300 to 86400; `force` must omit it. Core persists an absolute deadline and the requester's audit provenance before it closes fresh hosted admission, and the execution owner advances the clear after the request ends and across restarts. `auto` archives idle hosted Sessions, including queued or pending work and suspended sandboxes, and waits for root and Subagent Turns that are in progress or waiting and for pending file writes, rechecking under the Session lock. At the persisted deadline it escalates to `force` durably. `force` uses the ordinary archive cancellation and cleanup path for every eligible hosted Session. Self-hosted Sessions are never touched. +`clear` is required: `auto` or `force`. `deadline_seconds` applies to `auto`, defaults to 3600 and accepts 300 to 86400; `force` must omit it. Core persists an absolute deadline and the requester's audit provenance before it closes fresh hosted admission, and the execution owner advances the clear after the request ends and across restarts. `auto` archives idle hosted Sessions, including queued or pending work and suspended sandboxes, and waits for root and Subagent Turns that are in progress or waiting and for pending file writes, rechecking under the Session lock. At the persisted deadline it escalates to `force` durably. `force` uses the ordinary archive cancellation and cleanup path for every eligible hosted Session. Self-hosted Sessions are never touched. A reset also expires every unattached [standalone Environment](./environments.md#standalone-environments) and requests its cleanup. ```mermaid stateDiagram-v2 @@ -245,7 +247,7 @@ Some fields keep one name across providers but differ in meaning, or do not appl | Deployment `specification.network` | `public` and `domains` | `public` only; `domains` must be empty and private networks are unreachable | `public` only; `domains` must be empty | | Deployment `specification.resources` | `cpus`, `memory_mib`, `root_disk_mib` and, as a quota request, `environment_disk_mib` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`, applied as resource limits; no disk fields | | Deployment `metadata.template_build` | Absent: `metadata` is empty | The build as Core read it when the selection was saved | Absent: `metadata` is empty | -| Deployment `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`; Core pauses an idle E2B sandbox with the SDK's native pause | `null`: no checkpoint support, so an idle sandbox keeps running | +| Deployment `suspension` | `{retention_seconds}` | `{retention_seconds}`; Core pauses an idle E2B sandbox with the SDK's native pause | `null`: no checkpoint support; see [Lifecycle without a Session](./environments.md#standalone-environments) | | Deployment `resources.allocations`, `resources.pending` | Totals across all nodes | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Core's unreleased OpenSandbox sandboxes, and hosted Environments waiting for one | | Enrollment-token `max_active`, `max_retained` | Both limits apply; an omitted limit is derived at enrollment | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; OpenSandbox has no nodes | | Node list and detail | Enrolled nodes | Empty list; detail returns 404 | Empty list; detail returns 404 | diff --git a/contracts/agents-api/v1/official.gen.go b/contracts/agents-api/v1/official.gen.go index 0fb796a9f..fab9848f3 100644 --- a/contracts/agents-api/v1/official.gen.go +++ b/contracts/agents-api/v1/official.gen.go @@ -518,6 +518,15 @@ type EnvironmentInfo struct { Skills []json.RawMessage `json:"skills" binding:"required" swaggertype:"array,object"` } +// EnvironmentInfoList projects AgentEnvironmentListResource. +type EnvironmentInfoList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []EnvironmentInfo `json:"data" binding:"required"` + FirstID *string `json:"first_id" binding:"required" extensions:"x-nullable"` + LastID *string `json:"last_id" binding:"required" extensions:"x-nullable"` + HasMore bool `json:"has_more" binding:"required"` +} + // EnvironmentNetwork projects NetworkPolicyResource. type EnvironmentNetwork struct { Access string `json:"access" binding:"required" enums:"enabled,disabled,restricted"` diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index caf2d4c56..cf7147995 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -23,9 +23,9 @@ The pinned OpenAI Python SDK ([upstream.json](https://github.com/MiniMax-AI/Open ### Unsupported operations -`POST` and `GET /agents/environments` and `GET /agents/sessions/{session_id}/traces` are registered, so they pass the Beta and authentication checks like every pinned route, then answer 404 with code `unsupported_operation` and the message "This API operation is not supported." Environments exist only inside a Session ([ownership](./environments.md#resources-and-states)), and Core has no trace store. +`GET /agents/sessions/{session_id}/traces` is registered, so it passes the Beta and authentication checks like every pinned route, then answers 404 with code `unsupported_operation` and the message "This API operation is not supported." Core has no trace store. -Creating an Agent, Vault, Credential, Environment Template, Environment file or Session returns 201, also for a streamed Session creation. An empty update body on an Agent or Environment Template advances `updated_at` and changes nothing else; timestamps have one-second precision. +Creating an Agent, Vault, Credential, Environment, Environment Template, Environment file or Session returns 201, also for a streamed Session creation. An empty update body on an Agent or Environment Template advances `updated_at` and changes nothing else; timestamps have one-second precision. ### Headers @@ -124,7 +124,7 @@ Vault and Credential lists accept `status` as a scalar, as `status[]` entries, o | Lists | Default | Accepted | Other values | | --- | --- | --- | --- | | Agents, Sessions, Items, Turn Items, Environment Templates, Automations, Automation invocations, Vaults, Credentials, Subagent Items, Subagent Turn Items | 20 | 1–100 | 0 becomes 1; above 100 becomes 100 | -| Turns, Subagents, Subagent Turns, Artifacts | 20 | 1–100 | 400 `invalid_request_error`, "limit must be between 1 and 100" | +| Turns, Environments, Subagents, Subagent Turns, Artifacts | 20 | 1–100 | 400 `invalid_request_error`, "limit must be between 1 and 100" | | Skills, Skill versions | 20 | 0–100 | 0 returns an empty page whose `has_more` reports whether a resource follows the cursor. Negative: 400 `integer_below_min_value`, param `limit`. Above 100: 400 `integer_above_max_value`, param `limit` | | Files | 10000 | 1–10000 | 400 with a null code, "limit must be between 1 and 10000." | @@ -136,7 +136,7 @@ A `limit` that is not a decimal integer, including an empty value, returns 400 ` | Lists | Response | | --- | --- | -| Agents, Sessions, Turns, Environment Templates, Vaults, Credentials | 404, type and code `not_found_error`, "Resource not found." | +| Agents, Sessions, Turns, Environments, Environment Templates, Vaults, Credentials | 404, type and code `not_found_error`, "Resource not found." | | Session Items, Turn Items, Subagent Items, Subagent Turn Items | 400 `invalid_request_error`, null param, "Invalid session item ID in `after`" | | Subagents, Subagent Turns | 400 `invalid_request_error`, null param, "Invalid resource ID in `after`" | | Session Artifacts | 400 `invalid_request_error`, null param, "after is not a valid artifact ID" | @@ -196,11 +196,11 @@ These official fields have no Core meaning. A request that sets one returns 400 | Field | Param | | --- | --- | | Session update `agent.tools` | `agent.tools` | -| `environment.environment_id` | `environment_id` | | `environment.container_size`; responses carry `container_size: null` | `container_size` | | `desktop.enabled: true`; responses carry `desktop: {"enabled": false}` | `desktop.enabled` | | A nonempty `network.blocked_domains` | `network.blocked_domains` | | Credential `auth.type` `environment_variable` | `auth.type` | +| A nonempty `vault_ids` on `POST /agents/environments` (Core's choice, [recorded](./index.md)) | `vault_ids` | | The Session input `agent.session.input.computer_use_approval_request_result` | `events[N].type` | ### Session admission diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index e4ff6ebd5..bba37ce0f 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: 32e834142d199c6ba6ec276e6cd4365c551235e9d2233d296f3f18ab90e86046 +source_hash: 7fdb71b28ca0132d219056f9ddea1d3c1500f5e833b4ef788cbb930c9c40d4e0 --- Core 管理 API(`/core/v1`)用于管理安装实例:项目及其 API 密钥、项目资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -36,7 +36,7 @@ Core 管理 API(`/core/v1`)用于管理安装实例:项目及其 API 密 | `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | `self_hosted` Environment 的执行器凭据 | [执行器凭据](environment-executor-credentials.md#core-key-routes) | | `projects/{project_id}/resource-owners`、`projects/{project_id}/write-operations` | 创建资源的 API 密钥以及每个密钥执行的写入 | [写入溯源](#write-provenance) | | `harnesses`、`harnesses/{harness}/model-configuration` | 已启用的 Harness 及各 Harness 的部署默认模型 | [部署默认值](model-execution.md#deployment-defaults) | -| `sandbox/deployment`、`sandbox/deployment/reset`、`sandbox/providers/{provider}/discovery` | 沙箱提供商、资源和 Runtime、空闲挂起策略、重置,以及提供商配置发现,例如 E2B 模板 | [沙箱部署](sandbox-deployment.md#routes) | +| `sandbox/deployment`、`sandbox/deployment/reset`、`sandbox/providers/{provider}/discovery` | 沙箱提供商、资源和 Runtime、空闲超时、重置,以及提供商配置发现,例如 E2B 模板 | [沙箱部署](sandbox-deployment.md#routes) | | `sandbox/enrollment-tokens`、`sandbox/nodes[/{node_id}[/allocations]]` | 节点注册令牌、节点及其分配和主机历史 | [添加和管理节点](../../../docs/zh/getting-started/nodes.md)、[沙箱部署](sandbox-deployment.md)、[节点主机历史](runtime-observability-api.md#node-host-observations-and-history) | | `summary` | 按项目、Agent 或密钥统计的 Session 数量和使用情况 | [汇总](#summary) | | `metrics` | Core 自身的进程、执行、数据库和作业指标 | [Core 指标](core-metrics.md) | diff --git a/contracts/agents-api/zh/automations.md b/contracts/agents-api/zh/automations.md index 14f1661b6..d4611a913 100644 --- a/contracts/agents-api/zh/automations.md +++ b/contracts/agents-api/zh/automations.md @@ -1,7 +1,7 @@ --- title: "Automations" source: contracts/agents-api/automations.md -source_hash: 4644f72c63abb5381898a794a5421bbe968bf5ab418237ea40d111dee850a515 +source_hash: 3408eb6d69fde360c3a22accfc96deab62e4455a27ca31c9ac0cc725061c530c --- Automation 保存一个计划和一个目标。计划触发时,Core 投递目标:创建新 Session,或向已有 Session 发送指令。锁定版本 SDK 的全部十个操作都在 `/v1/agents/automations` 下实现,并与其他 Beta 路由一样需要项目身份验证和 `OpenAI-Beta: agents=v1`。Automation 属于其密钥所在的项目;不存在的 ID 与其他项目的 ID 都返回 404。 @@ -41,7 +41,7 @@ Automation 保存一个计划和一个目标。计划触发时,Core 投递目 `target.type` 为 `new_session` 或 `existing_session`。更新保持类型不变;另一种类型返回 400,param 为 `target.type`。 -`new_session` 采用 Session 创建请求体的字段,外加 `type`:`environment` 和 `input`(用户消息数组)必填,`agent` 与 `agent_id` 必须恰好提供其中一个,`metadata` 和 `vault_ids` 可选。创建和更新会执行 Session 创建在创建任何东西之前执行的检查:请求形状、Environment Template、已保存的 Agent、模型及其 provider、Vault 绑定,以及 Harness 对该配置的支持,因此不受支持的工具(如 `automations`)在创建时返回 400。拒绝规则与 Session 创建相同,param 加上前缀 `target.`:`environment.environment_id`、`container_size`、`desktop.enabled`、`packages.system` 和 `blocked_domains` 会被拒绝。部署就绪、准入和租约在每次投递时检查。此外: +`new_session` 采用 Session 创建请求体的字段,外加 `type`:`environment` 和 `input`(用户消息数组)必填,`agent` 与 `agent_id` 必须恰好提供其中一个,`metadata` 和 `vault_ids` 可选。创建和更新会执行 Session 创建在创建任何东西之前执行的检查:请求形状、Environment Template、已保存的 Agent、模型及其 provider、Vault 绑定,以及 Harness 对该配置的支持,因此不受支持的工具(如 `automations`)在创建时返回 400。拒绝规则与 Session 创建相同,param 加上前缀 `target.`:`container_size`、`desktop.enabled`、`packages.system` 和 `blocked_domains` 会被拒绝。`environment.environment_id` 指向一个[独立 Environment](environments.md#standalone-environments):创建和更新只检查它存在于项目中(404),每次投递都会挂载它,因此在另一个 Session 占用它时投递会因挂载冲突而失败。部署就绪、准入和租约在每次投递时检查。此外: - 内联 `agent` 与 Session 创建一致:除非适用部署默认模型,否则 `model` 必填。 - 目标中任何位置的 `x_agents_core` 都会被拒绝。 diff --git a/contracts/agents-api/zh/environment-files.md b/contracts/agents-api/zh/environment-files.md index 69c107e24..612198d73 100644 --- a/contracts/agents-api/zh/environment-files.md +++ b/contracts/agents-api/zh/environment-files.md @@ -1,7 +1,7 @@ --- title: "Environment 文件与 Artifact" source: contracts/agents-api/environment-files.md -source_hash: 0ed7d7e903ead70f209808a991c15e6156749f7abb111b145b1de1f58d3037d8 +source_hash: 95d56a1da2027c85e05130f00e8712247682d65365a483c6563c1d10a4c70363 --- Session 工作区保存由 agent 及其工具修改的实时文件。`/agents/environments/{environment_id}/files` 列出一个工作区目录,并在其中创建文件。Turn 完成时,Core 将工作区 `outputs/` 目录中的文件复制为不可变 Artifact,通过 `/agents/sessions/{session_id}/artifacts` 读取。Artifact 的生命周期长于 Environment;工作区文件则不是。 @@ -13,6 +13,7 @@ Session 工作区保存由 agent 及其工具修改的实时文件。`/agents/en - Environment 文件适用于 `openai_hosted` 和 `self_hosted` Environment。`none` Environment 返回 503 `execution_unavailable`。 - 公开路径从 `/workspace` 开始,代表 Environment 在机器上实际位置的工作区目录。 - 首先在调用方项目查找 Environment;不存在或属于其他范围的 ID 返回 404。 +- 未挂载任何 Session 的独立 Environment,两种操作均返回 409 `conflict_error`,“Attach a session to this environment before using its files.” - 仍为 `pending` 的 `openai_hosted` Environment,两种操作均返回 400 `the hosted environment is still provisioning; wait until it is connected before accessing files`。此检查在请求验证之后、任何源 File 查询之前执行。其他状态继续处理,Runtime 不可达时返回 503。 - 文件操作在现有请求期限内等待 Environment 初始化完成;`connected` 本身不表示已就绪,初始化失败或执行容量耗尽仍返回 503。 - 列表和写入不启动 Turn,也不向模型发送输入。 diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index a513e48d5..07d90a21d 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,10 +1,10 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: 34671da036d80359e11db48fb32d1613b8b1a290fb9dd16108fcfd6e2681eeaa +source_hash: 5a65935b1ac297109a14c525de83a05734587fe50593b2500e8c920507d16e87 --- -Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 +Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及能力。Session 通过其 `environment` 配置创建 Environment,或挂载由 `POST /agents/environments` 预先创建的 Environment([独立 Environment](#standalone-environments))。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 相关职责归属: @@ -20,12 +20,12 @@ Environment 是 Session 的执行资源,包括 Harness 运行所在的机器 | 资源 | 操作 | 规则 | | --- | --- | --- | -| [Environment](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/resources/beta/agents/environments/environments.py) | `GET /agents/environments/{id}` | 通过 Session 配置创建。返回 `id`、`type`、`status`,以及 Session 创建时记录的 `files`、`plugins` 和 `skills`。 | -| Environment 集合 | `POST`、`GET /agents/environments` | 不支持:404 `unsupported_operation`([不支持的操作](wire-semantics.md#unsupported-operations))。Environment 只能通过 Session 配置创建。 | +| [Environment](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/resources/beta/agents/environments/environments.py) | `GET /agents/environments/{id}` | 通过 Session 配置或 `POST /agents/environments` 创建。返回 `id`、`type`、`status`,以及创建时记录的 `files`、`plugins` 和 `skills`。 | +| Environment 集合 | `POST`、`GET /agents/environments` | 参见[独立 Environment](#standalone-environments)。 | | [模板](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/resources/beta/agents/environments/templates.py) | `POST`、`GET /agents/environments/templates`;`GET`、`POST`、`DELETE /agents/environments/templates/{id}` | 请参阅 [模板](#templates)。 | | [Files](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/resources/beta/agents/environments/files.py) | `POST`、`GET /agents/environments/{id}/files` | 请参阅 [Environment files](environment-files.md)。 | -读取 Environment 时,Core 会在调用方的项目范围内与其实时所属的 Session 联查,并返回持久化的连接状态。它不需要活跃的 Runtime,不会启动任何原生工作,也不会改变连接状态。对于两种放置方式,安装项数组都会列出由 API 管理的文件、Plugins 和 Skills:文件使用 `{id, type, path, file_id, size_bytes}` 且不含内容,Skills 使用 `{type, name, description, skill_id, version}`,Plugins 使用 `{type, name, description}`。在本地能力目录中发现的 Capabilities 不会列出。 +读取 Environment 时,Core 限定在调用方的项目范围内,隐藏所属 Session 已删除的 Environment,并返回持久化的连接状态。它不需要活跃的 Runtime,不会启动任何原生工作,也不会改变连接状态。对于两种放置方式,安装项数组都会列出由 API 管理的文件、Plugins 和 Skills:文件使用 `{id, type, path, file_id, size_bytes}` 且不含内容,Skills 使用 `{type, name, description, skill_id, version}`,Plugins 使用 `{type, name, description}`。在本地能力目录中发现的 Capabilities 不会列出。 [Session environment input](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/types/beta/environment_param.py) 与 [output](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/types/beta/environment.py) 的结构不同: @@ -38,11 +38,16 @@ Environment 是 Session 的执行资源,包括 Harness 运行所在的机器 | [Environment resource](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/types/beta/agents/environment_info.py) | `pending`、`ready`、`connected`、`disconnected`、`suspended`、`expired`、`failed` | | [Session environment event](https://github.com/openai/openai-python/blob/9b3458a2ef563493111a0a4265d4e9e7e14fe840/src/openai/types/beta/agent_session_environment_state.py) | 相同的七种状态,并带可为 null 的 error | -Core 只在事件中发出 `ready`。它从不报告 `suspended`,即使是它已做检查点并停止的空闲 hosted Environment;它也不发出 `agent.session.environment.suspended`、`.expired` 或 `.reset` 事件;保留期结束的 Environment 读作 `expired`,但没有对应事件。资源状态按下图变化,失败或过期的 Environment 不再改变: +Core 在事件中发出 `ready`,资源对已准备好且未挂载 Session 的独立 Environment 读作 `ready`(见[列出和检索](#standalone-environments))。它从不报告 `suspended`,即使是它已做检查点并停止的空闲 hosted Environment;它也不发出 `agent.session.environment.suspended`、`.expired` 或 `.reset` 事件;保留期结束的 Environment 读作 `expired`,但没有对应事件。资源状态按下图变化,失败或过期的 Environment 不再改变: ```mermaid stateDiagram-v2 [*] --> pending + pending --> ready + ready --> connected + connected --> ready + disconnected --> ready + ready --> expired pending --> connected connected --> disconnected disconnected --> connected @@ -56,10 +61,35 @@ 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 引用它;一个 Environment 至多挂载一个存活的 Session。其首个状态为 `pending`;`self_hosted` Environment 在首次连接前读作 `disconnected`。Session 删除后,读取会隐藏 Environment,而 Core 仍保留该记录,用于结算和清理。Session 为 `none` 时没有 Environment。 +Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会选出重试的胜者,而重试绝不创建或修复 Session 自有的 Environment。Environment 记录自己持有项目、不可变配置和准备输入,由 Session 引用它;一个 Environment 至多挂载一个存活的 Session。其首个状态为 `pending`;`self_hosted` Environment 在首次连接前读作 `disconnected`。Session 自有 Environment 的 Session 删除后,读取会隐藏该 Environment,而 Core 仍保留该记录,用于结算和清理。独立 Environment 的存续长于其 Session。Session 为 `none` 时没有 Environment。 托管的外层 Environment 必须排除权限范围更广的应用程序凭据和其他租户的机密。 +## 独立 Environment {#standalone-environments} + +`POST /agents/environments` 在任何 Session 存在之前创建并初始化一个 `openai_hosted` Environment,使后续 Session 在已预热的计算上启动。独立 Environment 可复用:同一时刻只有一个 Session 挂载到它,该 Session 被删除后,下一个 Session 即可挂载。Environment 在多个 Session 之间保留其计算、工作区和准备结果。 + +**创建。** 请求体为 `{environment, vault_ids}`。`environment` 取 [Session 的 `environment`](#placements) 中的 `openai_hosted` 成员,不含 `environment_id`,可以指定模板;Core 解析并应用模板的方式与 Session 相同。`vault_ids` 可以为 null 或 `[]`;非空列表返回 400。`Idempotency-Key` 为 1–256 字节,限定在项目范围内。每个项目密钥都以该项目的身份认证,因此创建者不再增加范围:列出、检索和该键都只涉及项目的 Environment。键和发送的正文都相同的重试返回处于当前状态的 Environment(包括 `pending`),即使所指定的模板此后已被删除;创建在一个事务中完成,因此重试不会遇到未完成的创建;不同正文返回 409 `idempotency_conflict`。Core 校验 hosted Session 为其 Environment 所需的接纳(部署、工作区存储、网络策略),冻结 Skills,封存 setup 和初始文件,并返回 201,Environment 状态为 `pending`。随后开始供给,调用方无需操作。 + +**列出和检索。** `GET /agents/environments` 列出项目的独立 Environment,包括 `failed` 和 `expired` 的,使用官方分页,以 `type=openai_hosted` 为唯一取值;`limit` 见[页面大小](wire-semantics.md#page-size)。`GET /agents/environments/{id}` 对两种 Environment 都有效。没有挂载 Session 且初始化已完成的 Environment,读作 `ready`,而不是 `pending`、`connected` 或 `disconnected`;挂载 Session 后读作连接状态,`failed` 和 `expired` 始终如实读出。 + +**挂载。** 带有 `environment: {"type": "openai_hosted", "environment_id": "…"}`(9–256 个字符)的 `POST /agents/sessions` 在该 Environment 上运行 Session,使用其冻结的配置。`environment_id` 旁的其他成员返回 400,`param` 为该成员(`environment.packages` 等),`x_agents_core.environment` 同样如此。Harness、模型和工具来自 Session。Core 的返回如下: + +| 情况 | 响应 | +| --- | --- | +| 不存在、属于其他项目,或已删除的 Session 自有 Environment | 404 `not_found_error` | +| 已挂载到存活的 Session | 409 `conflict_error`,“Environment is attached to another session.”,`param` 为 `environment.environment_id` | +| `failed` 或 `expired` | 409 `conflict_error`,“Environment has failed or expired and cannot take a session.”,`param` 为 `environment.environment_id` | +| Environment 的网络策略无法到达该 Session 的模型提供方 | 400 `model_provider_unreachable`,`param` 为 `environment.environment_id` | + +`pending` 的 Environment 可以挂载:Session 的首个输入像任何 hosted 输入一样等待准备完成,已挂起或已释放的分配会被唤醒。挂载不再重复工作区接纳,并按 Environment 冻结的网络策略检查 Session 的模型提供方(上表最后一行);同时执行依赖 Session 的检查,例如 Harness 的原生历史要求。部署还必须接纳新的 hosted 工作:重置待定或接纳关闭时,挂载与内联创建一样被拒绝。两个并发挂载有且仅有一个成功。 + +**卸载。** 删除 Session 是唯一的卸载方式。Environment 立即可再次挂载,并保留其工作区和准备结果。 + +**文件。** 没有挂载 Session 时的文件路由由 [Environment files](environment-files.md#where-files-work) 规定。 + +**无 Session 时的生命周期。** 未挂载的 Environment 无需 Harness 即可供给和初始化,其存续时间取决于沙箱提供商。支持检查点时,它在部署的空闲超时后挂起,并在快照保留期结束时过期,两者的取值见[挂起策略](sandbox-deployment.md#suspension-policy)。不支持检查点时它无法挂起,因此在空闲超时内没有活动后过期,空闲时间从最近一次活动起算:初始化完成、卸载或之后的活动。失败的 Environment 会立即释放其计算。无论哪种提供商,确认计算丢失后它也会过期(包括 Session 持有期间被释放的计算,在该 Session 被删除后),因为 Core 从不在新计算上替换它:没有任何信息能标识要重放的工作。[沙箱重置](sandbox-deployment.md#reset)同样会让它过期。已过期的 Environment 不能挂载。已结束(`expired` 或 `failed`)的未挂载 Environment 会释放其放置和工作区。 + ## 放置方式 {#placements} 两种放置方式运行相同的 Runtime:daemon、所选 Harness、原生工具和工作区共同在一台机器上运行。二者唯一的差异是由谁拥有该机器。 @@ -81,7 +111,7 @@ Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会 - 省略或传入 null 的 `network` 表示启用;`disabled` 和 `restricted` 需要托管 Environment,且其沙箱 Provider 能限制出站([网络策略](#restricted-network))。 - Core 重启会保留分配、工作区和原生身份,并且绝不重播结果不确定的工作。 - 终止清理会先在一个事务中撤销权限并结算待处理输入,然后提供商才会回收计算资源。向处于终止状态的 Environment 提交新输入会被拒绝。 -- 删除 Session 会回收其 Environment;删除模板则不会。 +- 删除 Session 会回收 Session 自有的 Environment,但绝不回收独立 Environment;删除模板两者都不回收。 ### 自托管(`self_hosted`) {#self-hosted-self-hosted} @@ -137,7 +167,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 失败并释放输入门控;修复本地原因后即可接受新输入。传输丢失、容量拒绝和未确认的清理在原始期限内保持可重试。Runtime 缺少所挂载 Session 的 Harness 时,会在其 executor 准备阶段以同样方式使该输入失败;初始化不检查 Harness。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 准备阶段以同样方式使该输入失败。Core 使用这些通用控制状态,绝不使用 Harness 特定的错误文本。 ### 活动与必需操作 {#activity-and-required-actions} @@ -232,11 +262,11 @@ stateDiagram-v2 ``` - Worker 的初始化调度器每次扫描 32 个 Environment,在末尾循环回绕,并依据执行并发度限制并发准备,且独立于提供商维护。 -- 缺少套接字不会消耗一次 pending 尝试。初始化不需要 Harness。每个操作都会重新检查当前权限和原始套接字;完成时还会重新检查精确绑定。 +- 缺少套接字不会消耗一次 pending 尝试。每个操作都会重新检查当前权限和原始套接字;完成时还会重新检查精确绑定。 - 每个文件、Skill 或 Plugin 传输的暂存预算为两分钟。configure、软件包安装、设置命令和快照最终确定共享整个初始化过程的 30 分钟预算,Core 在每项操作中携带剩余时长,由 Runtime 执行。初始输入仍保留其五分钟接纳期限,因此大型安装应从空闲 Session 开始。 - 正在运行且所有权丧失的初始化,包括跨 Core 重启丧失所有权,会作为未确认而失败;不会重播任何内容。已完成的 Environment 在重连或原生恢复时绝不会重新安装,因此用户后续更改会保留下来。 - 失败对 Session 而言是终止状态,但不会销毁计算资源或文件。 -- 每个 `openai_hosted` Environment 都从 `pending` 开始,包括没有文件和初始化设置的 Environment,并在开始初始化的事务中记录 `agent.session.environment.pending`,在完成初始化的事务中记录 `agent.session.environment.ready`,各恰好一次。Core 在观察到 Runtime 连接时记录 `agent.session.environment.connected`,可能在 `pending` 之前或之后。`self_hosted` Environment 不发出 `pending` 和 `ready`,且除非有文件或初始化设置,否则从 `complete` 开始。 +- 每个 `openai_hosted` Environment 都从 `pending` 开始,包括没有文件和初始化设置的 Environment,并在开始初始化的事务中记录 `agent.session.environment.pending`,在完成初始化的事务中记录 `agent.session.environment.ready`,各恰好一次。Core 在观察到 Runtime 连接时记录 `agent.session.environment.connected`,可能在 `pending` 之前或之后。这些都是 Session 事件,只在挂载了 Session 时记录:在挂载前已准备好的 Environment,不会向之后挂载的 Session 发出 `pending` 或 `ready`。`self_hosted` Environment 不发出 `pending` 和 `ready`,且除非有文件或初始化设置,否则从 `complete` 开始。 初始化失败时记录的事件和 Session 状态由 [Environment 初始化失败](sessions-events.md#environment-initialization-failure)定义。原因只指明步骤及其退出状态: @@ -253,7 +283,7 @@ stateDiagram-v2 ## 模板 {#templates} -模板是由项目拥有、供 `openai_hosted` Session 和 `x_agents_core.environment` 使用的配置。它不包含运行中的工作区,也与 E2B 模板等提供商镜像无关。每个引用它的 Session 都会通过与内联配置相同的准备过程获得自己的 Environment。模板解析、存储和解析过程绝不会选择 Harness 或提供商,也不会依赖原生工具名称或 Harness 私有路径,因此新的 Harness 或提供商无需修改模板。 +模板是由项目拥有、供 `openai_hosted` Session、`POST /agents/environments` 和 `x_agents_core.environment` 使用的配置。它不包含运行中的工作区,也与 E2B 模板等提供商镜像无关。每个引用它的 Session 都会通过与内联配置相同的准备过程获得自己的 Environment。模板解析、存储和解析过程绝不会选择 Harness 或提供商,也不会依赖原生工具名称或 Harness 私有路径,因此新的 Harness 或提供商无需修改模板。 ```python from openai import OpenAI diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index cef69a49d..821765c6a 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 9b3a9fc6f679b7ec35ca8a37978010c76d26d42e3822b4fb0ee42c3ccfdd5f79 +source_hash: 40aa4ceb297c0866fa75cbc20a6c0d7d9b1f2d64aba7d009446b08d0ec299ded --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -36,7 +36,7 @@ Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源 | Items | list | 部分实现:支持消息、命令、MCP 调用、functions、web search、reasoning 和 Subagent 协调 Items;其他原生变体不会被投影 | [Turns and Items](sessions-events.md#turns-and-items) | | Artifacts | retrieve, list, delete, content | 已实现 | [Environment files and Artifacts](environment-files.md) | | Subagents | retrieve, list; Items; Turns retrieve and list; Turn Items | 部分实现:只读子级工作;不支持实时子级进度或可选原生操作 | [Subagents](subagents.md) | -| Environments | retrieve | 已实现;以 Session 为范围([归属](environments.md#resources-and-states))。Core 拒绝 `environment.environment_id`,因此已有 hosted Environment 无法关联到另一个 Session。跨 Session 共享的工作区属于应用([项目拥有资产](../../../docs/zh/concepts.md#projects-own-assets)) | [Environments](environments.md) | +| Environments | create, retrieve, list | 已为 `openai_hosted` 实现;Session 用 `environment.environment_id` 挂载独立 Environment,同一时刻只挂载一个 Session | [独立 Environment](environments.md#standalone-environments) | | Environment files | create, list | 已实现;列表不会递归 | [Environment files and Artifacts](environment-files.md) | | Environment 模板 | create, retrieve, update, list, delete | 已实现;执行限制列于 [known gaps](#known-gaps) | [Environment 模板](environments.md#templates) | | Vaults | create, retrieve, update, list, delete | 已实现 | [Vaults and Credentials](vaults.md) | @@ -44,7 +44,6 @@ Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源 | Files | create, retrieve, list, delete, content | 已支持 `purpose=user_data`;内容下载会被拒绝 | [Files and Skills](source-files.md) | | Skills and Skill versions | create, retrieve, update, list, delete, content | 已实现 | [Files and Skills](source-files.md) | | Automations 和 invocations | 创建、检索、更新、列出、删除、暂停、恢复;invocation 的创建、检索、列出 | 已实现;`created_by` 始终为 `developer` | [Automations](automations.md) | -| Environment 集合 | create, list(`/agents/environments`) | 不支持:404 `unsupported_operation`;Core 的 Environment 以 Session 为范围 | [不支持的操作](wire-semantics.md#unsupported-operations) | | Session traces | list | 不支持:404 `unsupported_operation` | [不支持的操作](wire-semantics.md#unsupported-operations) | 各 Harness 在不同部署位置支持哪些操作,请参阅 [Harness capabilities](harness-capabilities.md)。[API 行为](wire-semantics.md) 包含适用于各项资源的通用规则:请求、错误和列表。 @@ -67,6 +66,16 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 对于重复的查询键,Files 路由会保留本地 `unsupported_parameter` 代码。 - Core 不处理 CORS;官方服务会发送 CORS 标头。 +**独立 Environment**([独立 Environment](environments.md#standalone-environments)) + +- Core 没有预热 beta 的门控;官方的创建和列出需要该 beta 的访问权限。 +- 独立 Environment 不接受 `vault_ids`(见[创建](environments.md#standalone-environments));Credential 只能通过 Session 到达 Environment。 +- 独立 Environment 以自己的网络策略创建,不会为后续 Session 放宽。若该策略无法到达模型提供方,挂载返回 400 `model_provider_unreachable`;官方服务没有此错误。 +- `POST /agents/environments` 的 `Idempotency-Key` 永不过期,而官方服务在组织、项目和创建者范围内去重 24 小时。Core 将它限定在项目范围内。同一个键搭配不同参数时返回 409,与 pin 一致;只有错误码 `idempotency_conflict` 尚未与官方服务核实。 +- pin 规定未完成的 hosted 创建返回 409。Core 在一个事务中完成创建,因此使用相同键和正文的重试会返回处于当前状态的 Environment,包括 `pending`。 +- Environment 列表的 `limit` 范围和对无法解析的 `after` 返回的 404([页面大小](wire-semantics.md#page-size)、[游标](wire-semantics.md#cursors))是 Core 自己的行为,没有官方样本。 +- 过期的未挂载 [Environment](environments.md#standalone-environments) 绝不会在新计算上被替换。 + **列表**([lists](wire-semantics.md#lists)) - 将已删除的 Agent 用作游标时会返回 404;官方服务仍可从该游标继续分页。 @@ -203,6 +212,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh ### 尚未与官方服务核实的内容 {#unverified-against-the-official-service} +- 独立 Environment:`environment.environment_id` 的 `not` 约束按 schema 顺序报告第一个违规成员,其消息文本没有样本;schema 未列出的成员返回未知参数错误。Core 对未挂载且初始化已完成的 Environment 读作 `ready`,在此之前读作连接状态:创建后为 `pending`,Runtime 连接后为 `connected` 或 `disconnected`。Core 只在挂载了 Session 时记录 Session 的 Environment 事件,因此挂载到已经 `ready` 的 Environment 的 Session 看不到 `pending` 或 `ready`;官方在这种情况下的事件未经核实。官方服务如何处理对仍为 `pending` 的 Environment 的挂载、是否列出 `failed` 和 `expired` 的 Environment,以及挂载前的文件路由(Core 返回 409),均没有官方样本。 - Session 更新何时绑定到排队中的 Turn:Core 在接受创建根 Turn 的输入时绑定该 Turn 的设置,因此更新之前已排队的 Turn 保持原有取值。 - Session 响应中“automatic review is not enabled”的含义:除 `auto_review` 外,Core 省略 `review`。 - Automations 中固定版本来源未说明的部分([Automations](automations.md)): diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 5ee7e2c50..15c8bb616 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: c7b69e705061c6c0ee95d9a89d93f82e20304e3f4f12b5ed0442614950867806 +source_hash: b405b74e9869ae6e6d9dc125a2eceb6a8eb1fe87a873e19e0d13884d4754ec7a --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -15,7 +15,7 @@ source_hash: c7b69e705061c6c0ee95d9a89d93f82e20304e3f4f12b5ed0442614950867806 | 路由 | 效果 | | --- | --- | | `GET /core/v1/sandbox/deployment` | 读取安全的当前配置、推出、重置和资源计数 | -| `PATCH /core/v1/sandbox/deployment` | 更新整个部署的空闲挂起策略,不改变配置代次 | +| `PATCH /core/v1/sandbox/deployment` | 更新整个部署的空闲超时,不改变配置代次 | | `POST /core/v1/sandbox/deployment` | 选择初始提供商和资源 | | `PUT /core/v1/sandbox/deployment` | 在线更改同一提供商的目标 | | `POST /core/v1/sandbox/deployment/reset` | 启动托管资源的持久清除或将其升级为持久清除 | @@ -97,10 +97,10 @@ E2B 把 `template` 声明为 discovered。空 `path` 列出该密钥的模板, ## 安全响应 {#safe-response} -GET 和成功的写入操作会返回 `installation_id`、`provider`、`core_url`(只读:安装公开 URL,即使配置前也存在)、`mode`、`generation`、`owner_epoch`、`reset`、`rollout`、`suspension`、`resources` 和 `credential_configured`。已配置的部署还会返回 `specification`、`specification_digest`、`configuration` 和 `metadata`:这是适配器对其选择器及其记录的观测结果所作的公开投影,绝不会包含原始存储值或机密。E2B 返回 `configuration.template`、`configuration.api_url`、`configuration.domain`,并在记录后返回 `metadata.template_build`。OpenSandbox 返回 `configuration.api_url`、`configuration.image` 和空的 `metadata` 对象。microsandbox 返回空的 `configuration` 和 `metadata` 对象以及 `credential_configured: false`;未配置的部署则不含这两个对象。 +GET 和成功的写入操作会返回 `installation_id`、`provider`、`core_url`(只读:安装公开 URL,即使配置前也存在)、`mode`、`generation`、`owner_epoch`、`reset`、`rollout`、`idle_seconds`、`suspension`、`resources` 和 `credential_configured`。已配置的部署还会返回 `specification`、`specification_digest`、`configuration` 和 `metadata`:这是适配器对其选择器及其记录的观测结果所作的公开投影,绝不会包含原始存储值或机密。E2B 返回 `configuration.template`、`configuration.api_url`、`configuration.domain`,并在记录后返回 `metadata.template_build`。OpenSandbox 返回 `configuration.api_url`、`configuration.image` 和空的 `metadata` 对象。microsandbox 返回空的 `configuration` 和 `metadata` 对象以及 `credential_configured: false`;未配置的部署则不含这两个对象。 - `metadata.template_build` 为 `{status, resources: {cpus, memory_mib, root_disk_mib}}`:这是保存选择时 Core 通过固定版本 SDK 读取的构建。GET 绝不会调用 E2B,因此 E2B 中断期间该操作仍保持低成本。验证仅接受 CPU 数量和内存与所选值一致的 `ready` 构建;`root_disk_mib` 是构建的原生磁盘大小,Core 不会强制执行该值。未知值为 null,`metadata: {}` 表示未记录任何观测,在不提供凭据的情况下提交完全相同的 PUT 也不会刷新它。 -- 所选提供商声明 checkpoint 支持时,`suspension` 为 `{idle_seconds, retention_seconds}`,即 Core 的 [suspension policy](../../../docs/zh/sandbox-provider.md#suspension);其他部署(包括未配置的部署)返回 null。 +- `idle_seconds` 是部署的空闲超时,对每个提供商都适用。所选提供商声明 checkpoint 支持时,`suspension` 为 `{retention_seconds}`,即 Core 的 [suspension policy](../../../docs/zh/sandbox-provider.md#suspension);其他部署(包括未配置的部署)返回 null。 - 请求中的 `resources` 和响应中的 `specification.resources` 是每个沙箱的限制。响应中的 `resources.allocations` 和 `resources.pending` 分别计算尚未释放的分配,以及尚未分配沙箱的待处理托管 Environment;`resources.running` 计算计算资源正在运行的未释放分配,因此不含已挂起的分配。 - 未配置的部署具有空的提供商且没有 specification。microsandbox 使用 `mode: nodes`;E2B 和 OpenSandbox 使用 `mode: direct`,且没有合成节点。 - `generation` 标识已保存的选择。`owner_epoch` 用于对执行所有者和节点连接进行栅栏隔离;它不能替代 `expected_generation`。 @@ -136,9 +136,11 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 ## 挂起策略 {#suspension-policy} -`PATCH /core/v1/sandbox/deployment` 接受 `{"idle_seconds": 300}`。`idle_seconds` 是必填整数,范围为 30–86400 秒,默认为 300。该设置保存在 Core 的数据库中。只有已配置且声明 checkpoint 支持的提供商可修改;未配置、不支持或正在重置的部署拒绝写入。成功返回当前部署视图,`suspension.idle_seconds` 反映已保存的值。 +`PATCH /core/v1/sandbox/deployment` 接受 `{"idle_seconds": 300}`。`idle_seconds` 是必填整数,范围为 30–86400 秒,默认为 300。该设置保存在 Core 的数据库中。每个已配置的提供商都可修改;未配置或正在重置的部署拒绝写入。成功返回当前部署视图,`idle_seconds` 反映已保存的值。 -变更从下一轮生命周期检查起作用于现有和新建的运行中分配,不重启 Core、不推进配置代次,也不重新部署节点。已经开始的挂起不撤销;现有快照和 24 小时快照保留期不变。[容量压力回收](../../../docs/zh/sandbox-provider.md#suspension)仍可提前挂起空闲的分配,并在 retained slot 已满时[提前结束符合条件的检查点的保留期](../../../docs/zh/sandbox-provider.md#cold-replacement-after-checkpoint-retention)。Web 在 **设置** → **沙箱与调度** → **自动挂起** 管理此部署级策略;各节点的容量上限仍属于节点,在 **节点** 中通过 **编辑节点** 编辑。 +支持 checkpoint 时,空闲超时触发[挂起](../../../docs/zh/sandbox-provider.md#suspension)。不支持时,Session 持有的分配继续运行;超时对未挂载 Environment 的作用见[无 Session 时的生命周期](environments.md#standalone-environments)。 + +变更从下一轮生命周期检查起作用于现有和新建的运行中分配,不重启 Core、不推进配置代次,也不重新部署节点。已经开始的挂起不撤销;现有快照和 24 小时快照保留期不变。[容量压力回收](../../../docs/zh/sandbox-provider.md#suspension)仍可提前挂起空闲的分配,并在 retained slot 已满时[提前结束符合条件的检查点的保留期](../../../docs/zh/sandbox-provider.md#cold-replacement-after-checkpoint-retention)。Web 在 **设置** → **沙箱与调度** → **自动挂起** 管理此部署级设置,提供商不支持 checkpoint 时为 **空闲超时**;各节点的容量上限仍属于节点,在 **节点** 中通过 **编辑节点** 编辑。 ## 代次所有权与推出 {#generation-ownership-and-rollout} @@ -183,7 +185,7 @@ flowchart TD {"expected_generation": 7, "clear": "auto", "deadline_seconds": 3600} ``` -`clear` 必填,可取 `auto` 或 `force`。`deadline_seconds` 适用于 `auto`,默认值为 3600,接受 300 到 86400;`force` 必须省略该字段。在关闭新的托管准入之前,Core 会持久保存绝对截止时间和请求方的审计来源信息;请求结束后以及跨重启时,执行所有者会继续推进清除。在 `auto` 模式下,Core 会归档空闲的托管 Session,包括排队或待处理工作以及已暂停沙箱,并等待正在执行或等待的 root Turn 和 Subagent Turn 以及待处理的文件写入完成,同时在 Session 锁下重新检查。到达持久保存的截止时间时,Core 会持久地将操作升级为 `force`。`force` 会对每个符合条件的托管 Session 使用常规归档取消和清理路径。绝不触碰自托管 Session。 +`clear` 必填,可取 `auto` 或 `force`。`deadline_seconds` 适用于 `auto`,默认值为 3600,接受 300 到 86400;`force` 必须省略该字段。在关闭新的托管准入之前,Core 会持久保存绝对截止时间和请求方的审计来源信息;请求结束后以及跨重启时,执行所有者会继续推进清除。在 `auto` 模式下,Core 会归档空闲的托管 Session,包括排队或待处理工作以及已暂停沙箱,并等待正在执行或等待的 root Turn 和 Subagent Turn 以及待处理的文件写入完成,同时在 Session 锁下重新检查。到达持久保存的截止时间时,Core 会持久地将操作升级为 `force`。`force` 会对每个符合条件的托管 Session 使用常规归档取消和清理路径。绝不触碰自托管 Session。重置还会让每个未挂载的[独立 Environment](environments.md#standalone-environments)过期,并请求清理它。 ```mermaid stateDiagram-v2 @@ -250,7 +252,7 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。 | 部署 `specification.network` | `public` 和 `domains` | 仅 `public`;`domains` 必须为空,私有网络不可达 | 仅 `public`;`domains` 必须为空 | | 部署 `specification.resources` | `cpus`、`memory_mib`、`root_disk_mib`,以及作为配额请求的 `environment_disk_mib` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | `cpus` 和 `memory_mib`,作为资源限制应用;无磁盘字段 | | 部署 `metadata.template_build` | 不存在:`metadata` 为空 | Core 保存选择时读取到的构建 | 不存在:`metadata` 为空 | -| 部署 `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`;Core 用 SDK 的原生 pause 暂停空闲的 E2B 沙箱 | `null`:不支持 checkpoint,空闲沙箱继续运行 | +| 部署 `suspension` | `{retention_seconds}` | `{retention_seconds}`;Core 用 SDK 的原生 pause 暂停空闲的 E2B 沙箱 | `null`:不支持 checkpoint;见[无 Session 时的生命周期](environments.md#standalone-environments) | | 部署 `resources.allocations`、`resources.pending` | 所有节点的总数 | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment | Core 中尚未释放的 OpenSandbox 沙箱,以及正在等待沙箱的托管 Environment | | 注册令牌 `max_active`、`max_retained` | 两个限制均适用;省略的限制在注册时推导 | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;OpenSandbox 没有节点 | | 节点列表和详情 | 已注册节点 | 空列表;详情返回 404 | 空列表;详情返回 404 | diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 5e7b91f3c..0de791bb5 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: b0e9bfc53d015b5daacfc846ff8e1a27d3f6021f89f84131afdb1a892f2c868e +source_hash: 29a928dc41487b9e5a11cdeff7305d2f006040822caf66b299e504cb8eab9662 --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -25,9 +25,9 @@ source_hash: b0e9bfc53d015b5daacfc846ff8e1a27d3f6021f89f84131afdb1a892f2c868e ### 不支持的操作 {#unsupported-operations} -`POST` 和 `GET /agents/environments`,以及 `GET /agents/sessions/{session_id}/traces` 都已注册,因此与其他固定版本路由一样先通过 Beta 和身份验证检查,然后返回 404、代码 `unsupported_operation`、消息 "This API operation is not supported."。Environment 只存在于 Session 内部([归属](environments.md#resources-and-states)),Core 没有 trace 存储。 +`GET /agents/sessions/{session_id}/traces` 已注册,因此与其他固定版本路由一样先通过 Beta 和身份验证检查,然后返回 404、代码 `unsupported_operation`、消息 "This API operation is not supported."。Core 没有 trace 存储。 -创建 Agent、Vault、Credential、Environment 模板、Environment 文件或 Session 均返回 201,流式 Session 创建也是如此。对 Agent 或 Environment 模板发送空更新正文会推进 `updated_at`,但不会更改其他内容;时间戳精确到秒。 +创建 Agent、Vault、Credential、Environment、Environment 模板、Environment 文件或 Session 均返回 201,流式 Session 创建也是如此。对 Agent 或 Environment 模板发送空更新正文会推进 `updated_at`,但不会更改其他内容;时间戳精确到秒。 ### 标头 {#headers} @@ -126,7 +126,7 @@ Vault 和 Credential 列表接受标量 `status`、`status[]` 条目或两者, | 列表 | 默认值 | 接受范围 | 其他值 | | --- | --- | --- | --- | | Agents、Sessions、Items、Turn Items、Environment 模板、Automations、Automation invocations、Vaults、Credentials、Subagent Items、Subagent Turn Items | 20 | 1–100 | 0 变为 1;大于 100 的值变为 100 | -| Turns、Subagents、Subagent Turns、Artifacts | 20 | 1–100 | 400 `invalid_request_error`,"limit must be between 1 and 100" | +| Turns、Environments、Subagents、Subagent Turns、Artifacts | 20 | 1–100 | 400 `invalid_request_error`,"limit must be between 1 and 100" | | Skills、Skill 版本 | 20 | 0–100 | 0 返回空页面,其 `has_more` 表示游标后是否还有资源。负数:400 `integer_below_min_value`,param 为 `limit`。大于 100:400 `integer_above_max_value`,param 为 `limit` | | Files | 10000 | 1–10000 | 400,code 为 null,"limit must be between 1 and 10000." | @@ -138,7 +138,7 @@ Vault 和 Credential 列表接受标量 `status`、`status[]` 条目或两者, | 列表 | 响应 | | --- | --- | -| Agents、Sessions、Turns、Environment 模板、Vaults、Credentials | 404,`type` 和 `code` 均为 `not_found_error`,"Resource not found." | +| Agents、Sessions、Turns、Environments、Environment 模板、Vaults、Credentials | 404,`type` 和 `code` 均为 `not_found_error`,"Resource not found." | | Session Items、Turn Items、Subagent Items、Subagent Turn Items | 400 `invalid_request_error`,param 为 null,"Invalid session item ID in `after`" | | Subagents、Subagent Turns | 400 `invalid_request_error`,param 为 null,"Invalid resource ID in `after`" | | Session Artifacts | 400 `invalid_request_error`,param 为 null,"after is not a valid artifact ID" | @@ -198,11 +198,11 @@ Session 创建和更新、Vault 创建、Credential 创建和更新、Environmen | 字段 | Param | | --- | --- | | Session 更新 `agent.tools` | `agent.tools` | -| `environment.environment_id` | `environment_id` | | `environment.container_size`;响应携带 `container_size: null` | `container_size` | | `desktop.enabled: true`;响应携带 `desktop: {"enabled": false}` | `desktop.enabled` | | 非空的 `network.blocked_domains` | `network.blocked_domains` | | Credential `auth.type` 为 `environment_variable` | `auth.type` | +| `POST /agents/environments` 的非空 `vault_ids`(Core 的选择,[已记录](index.md)) | `vault_ids` | | Session 输入 `agent.session.input.computer_use_approval_request_result` | `events[N].type` | ### Session 准入 {#session-admission} diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index b06389a46..fa7764420 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -44,12 +44,13 @@ On a shared host, `-H @<(printf 'Authorization: Bearer %s\n' "$OPENAI_API_KEY")` | Resource | Path | What it is | | --- | --- | --- | | [Agents](#agents) | `/agents` | Reusable configuration: model, instructions, tools, Harness | -| [Sessions](#sessions) | `/agents/sessions` | One agent conversation with its own Environment | +| [Sessions](#sessions) | `/agents/sessions` | One agent conversation with its Environment, its own or an attached [standalone Environment](../../contracts/agents-api/environments.md#standalone-environments) | | [Input events](#send-input) | `/agents/sessions/{id}/events` (POST) | Messages, cancellation and tool results | | [Event stream](#stream-events) | `/agents/sessions/{id}/events` (GET) | Live server-sent events | | [Turns and Items](#turns-and-items) | `/agents/sessions/{id}/turns`, `/items` | Durable history | | [Files](#files) | `/files`, `/agents/environments/{id}/files`, `/agents/sessions/{id}/artifacts` | Uploads, workspace files and outputs | | [Skills](#skills) | `/skills` | Versioned capability bundles | +| [Environments](#prewarm-an-environment) | `/agents/environments` | Prepared workspaces that a Session attaches to | | [Environment Templates](#environment-templates) | `/agents/environments/templates` | Reusable workspace setup | | [Automations](#automations) | `/agents/automations` | Scheduled Session creation or instructions | | [Vaults](#vaults) | `/vaults` | Write-only credentials for MCP servers | @@ -130,7 +131,7 @@ See [creation retries](../../contracts/agents-api/wire-semantics.md#creation-ret | Status | Common codes | Meaning | | --- | --- | --- | -| 400 | `invalid_request_error`, `invalid_beta`, `model_provider_required`, `unsupported_or_invalid_configuration` | Fix the request. `invalid_beta` means a missing or wrong `OpenAI-Beta` header | +| 400 | `invalid_request_error`, `invalid_beta`, `model_provider_required`, `model_provider_unreachable`, `unsupported_or_invalid_configuration` | Fix the request. `invalid_beta` means a missing or wrong `OpenAI-Beta` header | | 401 | `invalid_api_key` or none | Wrong or missing key, or a key from another namespace | | 404 | `not_found_error` on Beta routes | Missing, or belongs to another Project. The two look the same | | 405 | `unsupported_operation` | Core doesn't support this operation | @@ -212,7 +213,7 @@ agents "/agents" -d '{ ## Sessions -A Session is one conversation with a fixed configuration and its own Environment. +A Session is one conversation with a fixed configuration and an Environment, its own or an attached [standalone Environment](../../contracts/agents-api/environments.md#standalone-environments). ### Create a Session @@ -536,7 +537,7 @@ curl "$OPENAI_BASE_URL/skills" -H "Authorization: Bearer $OPENAI_API_KEY" -F fil - No Beta header. Select up to 50 Skills per Environment; each archive is at most 5 MiB compressed and 20 MiB expanded. - Attach Skills to a Session through its `environment.skills` or a [template](#environment-templates). -Details: [Files and Skills](../../contracts/agents-api/source-files.md). A Session installs its Skills, Plugins and packages once, when it is prepared; editing the source later doesn't change a running Session. Preparation errors fail the Session before any work runs: fix the cause instead of retrying in a new Session. +Details: [Files and Skills](../../contracts/agents-api/source-files.md). A Session installs its Skills, Plugins and packages once, during initialization; editing the source later doesn't change a running Session. Preparation errors fail the Session before any work runs: fix the cause instead of retrying in a new Session. ## Environment Templates @@ -559,6 +560,32 @@ template = client.beta.agents.environments.templates.create( A Session freezes the template when it starts. Details: [Environment Templates](../../contracts/agents-api/environments.md#templates). +## Prewarm an Environment + +Create an Environment before its Session so the workspace is initialized when the Session starts. It takes the `environment` of a Session and the same Template. Attach a Session with `environment_id`; when the Session is deleted, the next one can attach: + +::: code-group + +```python [Python] +env = client.beta.agents.environments.create( + environment={"type": "openai_hosted", "environment_template_id": template.id}, +) +session = client.beta.agents.sessions.create( + environment={"type": "openai_hosted", "environment_id": env.id}, + agent_id=agent.id, + input="Summarize the data in /workspace.", +) +``` + +```sh [curl] +agents /agents/environments -d "{\"environment\": {\"type\": \"openai_hosted\", \"environment_template_id\": \"$TEMPLATE_ID\"}}" +agents /agents/sessions -d "{\"environment\": {\"type\": \"openai_hosted\", \"environment_id\": \"$ENVIRONMENT_ID\"}, \"agent_id\": \"$AGENT_ID\", \"input\": \"Summarize the data in /workspace.\"}" +``` + +::: + +An unattached Environment expires, so prewarm shortly before use. [Standalone Environments](../../contracts/agents-api/environments.md#standalone-environments) owns its status, files and lifetime. + ## Automations ```python diff --git a/docs/configuration.md b/docs/configuration.md index 20d7662fc..032c675c2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,7 +59,7 @@ Runtime settings live in Core's database. Change them in Web; scripts use the sa | Sandbox size and the E2B or OpenSandbox connection | **Settings** → **Sandbox and scheduling** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the [default size the Provider declares](./sandbox-provider.md#register-the-provider-kind). Existing sandboxes keep their size. The E2B and OpenSandbox keys are write-only and encrypted | | Nodes and their capacity | **Nodes**: **Add node**, **Edit node** and removal | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and [Add and manage nodes](./getting-started/nodes.md) | | Sandbox network cap | **Settings** → **Sandbox and scheduling** → **Network**: **Allow public internet**, **Extra allowed domains** → **Save** | `/core/v1/sandbox/deployment` | `network.public` and `network.domains`; see [Network](../contracts/agents-api/sandbox-deployment.md#network). Existing sandboxes keep the cap they started with. A control the Provider does not support is disabled | -| Idle suspension | **Settings** → **Sandbox and scheduling** → **Automatic suspension** → **Idle before suspension (seconds)** → **Save** | `PATCH /core/v1/sandbox/deployment` | Deployment-wide; see [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) | +| Idle timeout | **Settings** → **Sandbox and scheduling** → **Automatic suspension** → **Idle before suspension (seconds)** → **Save** (**Idle timeout** → **Idle before expiry (seconds)** without checkpoint support) | `PATCH /core/v1/sandbox/deployment` | Deployment-wide; see [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) | | Independent workspace storage and tenant capacity | **Settings → Workspace storage** | `/core/v1/workspace-storage` | See [Set up workspace storage](./getting-started/workspace-storage.md); existing workspaces retain their configuration | | Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests | | Default model per Harness | **Settings** → **Models and credentials**: **Set** | `/core/v1/harnesses/{harness}/model-configuration` | See [Default models](#default-models) | diff --git a/docs/getting-started/sandbox-backends.md b/docs/getting-started/sandbox-backends.md index 85ec0d61d..c11ca6c7a 100644 --- a/docs/getting-started/sandbox-backends.md +++ b/docs/getting-started/sandbox-backends.md @@ -14,7 +14,7 @@ The installer selects no backend. One installation uses one backend. To change i | Nodes to add | Yes | None | None | | You provide | Linux amd64 hosts with systemd and `/dev/kvm` | An E2B API key and a template; a public HTTPS URL for Core | The server's URL and API key and a Runtime image built for it; a Core address its sandboxes reach | | Isolation | Each sandbox has its own kernel | E2B's | OpenSandbox's runtime: containers, or a secure container runtime | -| Idle sandboxes suspend and resume | Yes, with checkpoints | Yes, paused in place by E2B | No; they keep running | +| Idle sandboxes suspend and resume | Yes, with checkpoints | Yes, paused in place by E2B | No; a sandbox with a Session keeps running | | Workspace storage | Required, on NFS | Own disk | Own disk | | Disk limits | Root disk and workspace quota | Set by the template | Set by OpenSandbox's runtime | diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 0c7b21117..8a13ed54b 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -176,7 +176,7 @@ An adapter implements none of this, but it relies on it. The code-level mechanic - 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. - 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). +- Core suspends the idle allocations of every Provider that declares checkpoint support, under one [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy). The idle timeout is the same setting for every Provider; [Standalone Environments](../contracts/agents-api/environments.md#standalone-environments) owns what it does without checkpoint support. - Disabling the provider stops new hosted admission and bootstrap but never blocks cancellation, function results or input retry outcomes of existing Sessions. ```mermaid @@ -304,7 +304,8 @@ Every other observation keeps ownership and its diagnostic: a `paused`, `running | Value | Applies to | On expiry or excess | | --- | --- | --- | | Bounded context | Every Provider call | Ends the caller's wait and proves no rollback, stop, cleanup or absence. The adapter never detaches untracked mutations or replays a timed-out command | -| Idle timeout and snapshot retention | Checkpoint-capable allocations, set by the [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) | At the idle timeout Core begins [suspension](#suspension). When retention ends Core cleans up the snapshot and, for a qualified Session, replaces the compute through [cold replacement](#cold-replacement-after-checkpoint-retention) | +| Idle timeout | Every allocation, set by `idle_seconds` | With checkpoint support Core begins [suspension](#suspension); without it, see [Standalone Environments](../contracts/agents-api/environments.md#standalone-environments) | +| Snapshot retention | Checkpoint-capable allocations, set by the [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) | When retention ends Core cleans up the snapshot and, for a qualified Session, replaces the compute through [cold replacement](#cold-replacement-after-checkpoint-retention) | | 15 s | Inactivity of an idle node allocation before capacity-pressure suspension | Core can suspend it ahead of the idle timeout when compatible waiting demand exists | | 2 min | Inactivity of an idle allocation on a node with no free active slot and a free retained slot | Core can suspend it ahead of the idle timeout without waiting demand, so the next Session finds a free slot | | 50 MiB plus 32 bytes (`MaxCommandInputBytes`) | `Command.Stdin` | The command is invalid | diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index da477ddc7..c3a93cd55 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -96,7 +96,7 @@ The list carries each Harness's configuration, so the console does not read `GET | Operation | Route | Console use | | --- | --- | --- | -| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in Settings leading to Settings → Service and deployment, and leaves nothing to confirm; initialize the deployment with microsandbox `resources`, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. Sandbox metrics shows the size of each E2B sandbox from `specification.resources`; microsandbox's `suspension` (idle and retention seconds) shows in Settings → Sandbox and scheduling | +| Deployment | `GET`, `POST`, `PUT`, `PATCH /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in Settings leading to Settings → Service and deployment, and leaves nothing to confirm; initialize the deployment with microsandbox `resources`, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation; `PATCH` saves only `idle_seconds`. Sandbox metrics shows the size of each E2B sandbox from `specification.resources`; `idle_seconds` shows in Settings → Sandbox and scheduling, as Automatic suspension with `suspension` (snapshot retention seconds) when the Provider has checkpoint support, and as Idle timeout without it | | E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the options of a discovered field with the entered credential, one level at a time. For E2B these are the key's templates, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | | Reset | `POST`, `DELETE /core/v1/sandbox/deployment/reset` | Explicitly clear hosted resources, or cancel the remaining clear at the observed generation; show Core's remaining and offline projection | | Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; each hosted Runtime's node on Sandbox metrics. An online node whose `provider_ready` is false is degraded; the help tip beside its status on the Nodes list and on the node's page says *Sandbox provider unavailable*, and the target preparation reason (`rollout.diagnostic`, a [readiness code](../getting-started/nodes.md#readiness-codes)) shows in its target status. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Edit node** reads the node's `recommended_capacity` from this list. **Add node** follows only the node whose `enrollment_id` equals its command's | diff --git a/docs/zh/api/public-agent-api.md b/docs/zh/api/public-agent-api.md index f980de19f..73e3f4ecd 100644 --- a/docs/zh/api/public-agent-api.md +++ b/docs/zh/api/public-agent-api.md @@ -1,7 +1,7 @@ --- title: "Agents API 指南" source: docs/api/public-agent-api.md -source_hash: 8846a755d119bc752f903715629d7be7c6aefef4498766c00889a714ac11d4b5 +source_hash: 1db2045ded2e2bf523dd94a3db08c5ac6078bf76489f7cb09de092164e89aa8c --- Core 在 `/v1` 提供 OpenAI Agents API。本指南用官方 OpenAI SDK 和普通 HTTP 两种方式演示每项常见任务,并说明 Core 与 OpenAI 的差异。如果尚未启动过 Session,请先阅读[运行第一个 Session](../getting-started/quickstart.md)。 @@ -46,12 +46,13 @@ agents /agents | 资源 | 路径 | 用途 | | --- | --- | --- | | [Agents](#agents) | `/agents` | 可复用配置:模型、指令、工具和 Harness | -| [Sessions](#sessions) | `/agents/sessions` | 一次 Agent 对话及其独立的 Environment | +| [Sessions](#sessions) | `/agents/sessions` | 一次 Agent 对话及其 Environment,自有的或挂载的[独立 Environment](../../../contracts/agents-api/zh/environments.md#standalone-environments) | | [输入事件](#send-input) | `/agents/sessions/{id}/events`(POST) | 消息、取消和工具结果 | | [事件流](#stream-events) | `/agents/sessions/{id}/events`(GET) | 实时的服务器发送事件 | | [Turns 和 Items](#turns-and-items) | `/agents/sessions/{id}/turns`、`/items` | 持久化历史 | | [Files](#files) | `/files`、`/agents/environments/{id}/files`、`/agents/sessions/{id}/artifacts` | 上传内容、工作区文件和输出 | | [Skills](#skills) | `/skills` | 带版本的能力捆绑包 | +| [Environments](#prewarm-an-environment) | `/agents/environments` | 供 Session 挂载的已准备工作区 | | [Environment 模板](#environment-templates) | `/agents/environments/templates` | 可复用的工作区设置 | | [Automations](#automations) | `/agents/automations` | 按计划创建 Session 或发送指令 | | [Vaults](#vaults) | `/vaults` | MCP 服务器的只写凭据 | @@ -132,7 +133,7 @@ client.beta.agents.sessions.create(environment=..., extra_headers={"Idempotency- | 状态 | 常见代码 | 含义 | | --- | --- | --- | -| 400 | `invalid_request_error`、`invalid_beta`、`model_provider_required`、`unsupported_or_invalid_configuration` | 修正请求。`invalid_beta` 表示 `OpenAI-Beta` 请求头缺失或错误 | +| 400 | `invalid_request_error`、`invalid_beta`、`model_provider_required`、`model_provider_unreachable`、`unsupported_or_invalid_configuration` | 修正请求。`invalid_beta` 表示 `OpenAI-Beta` 请求头缺失或错误 | | 401 | `invalid_api_key` 或无代码 | 密钥错误或缺失,或者使用了其他命名空间中的密钥 | | 404 | Beta 路由返回 `not_found_error` | 对象不存在,或属于其他项目。这两种情况看起来完全相同 | | 405 | `unsupported_operation` | Core 不支持此操作 | @@ -214,7 +215,7 @@ agents "/agents" -d '{ ## Sessions {#sessions} -Session 是一次使用固定配置并拥有独立 Environment 的对话。 +Session 是一次使用固定配置的对话,拥有自有的 Environment,或挂载一个[独立 Environment](../../../contracts/agents-api/zh/environments.md#standalone-environments)。 ### 创建 Session {#create-a-session} @@ -538,7 +539,7 @@ curl "$OPENAI_BASE_URL/skills" -H "Authorization: Bearer $OPENAI_API_KEY" -F fil - 不需要 Beta 请求头。每个 Environment 最多可选择 50 个 Skills;每个归档压缩后最大为 5 MiB,展开后最大为 20 MiB。 - 可通过 Session 的 `environment.skills` 或[模板](#environment-templates)为其附加 Skills。 -详细信息请参阅 [文件与 Skill](../../../contracts/agents-api/zh/source-files.md)。Session 会在准备期间一次性安装其 Skills、Plugins 和软件包;之后编辑源文件不会影响正在运行的 Session。准备错误会在任何工作开始前导致 Session 失败:请修复原因,而不是在新 Session 中重试。 +详细信息请参阅 [文件与 Skill](../../../contracts/agents-api/zh/source-files.md)。Session 会在初始化期间一次性安装其 Skills、Plugins 和软件包;之后编辑源文件不会影响正在运行的 Session。准备错误会在任何工作开始前导致 Session 失败:请修复原因,而不是在新 Session 中重试。 ## Environment 模板 {#environment-templates} @@ -561,6 +562,32 @@ template = client.beta.agents.environments.templates.create( Session 启动时会冻结模板。详细信息请参阅 [Environment 模板](../../../contracts/agents-api/zh/environments.md#templates)。 +## 预热 Environment {#prewarm-an-environment} + +在创建 Session 之前先创建 Environment,使工作区在 Session 启动时已初始化。它接受 Session 的 `environment` 和同样的模板。用 `environment_id` 挂载 Session;该 Session 被删除后,下一个 Session 即可挂载: + +::: code-group + +```python [Python] +env = client.beta.agents.environments.create( + environment={"type": "openai_hosted", "environment_template_id": template.id}, +) +session = client.beta.agents.sessions.create( + environment={"type": "openai_hosted", "environment_id": env.id}, + agent_id=agent.id, + input="Summarize the data in /workspace.", +) +``` + +```sh [curl] +agents /agents/environments -d "{\"environment\": {\"type\": \"openai_hosted\", \"environment_template_id\": \"$TEMPLATE_ID\"}}" +agents /agents/sessions -d "{\"environment\": {\"type\": \"openai_hosted\", \"environment_id\": \"$ENVIRONMENT_ID\"}, \"agent_id\": \"$AGENT_ID\", \"input\": \"Summarize the data in /workspace.\"}" +``` + +::: + +未挂载的 Environment 会过期,因此应在使用前不久预热。状态、文件和生命周期见[独立 Environment](../../../contracts/agents-api/zh/environments.md#standalone-environments)。 + ## Automations {#automations} ```python diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 83c2437d7..df1f44d6d 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 1cd0723f9215ffa38fbc155edac30bc7c90a9761b49dcef88668e83e1e0fe720 +source_hash: 3ed5d0a97aa81a5fff41d2f99405d8f7281207410f033885b8a329f4e7bc4faa --- Core 安装的每项设置、其归属位置以及修改方式。安装程序写入初始值;之后请使用本参考。每项设置都恰好只有一个归属位置,分属以下三类: @@ -63,7 +63,7 @@ Core 安装的每项设置、其归属位置以及修改方式。安装程序写 | 沙箱大小,以及 E2B 或 OpenSandbox 连接 | **设置** → **沙箱与调度** → **修改资源** | `/core/v1/sandbox/deployment` | Web 会推荐 [提供商声明的默认大小](sandbox-provider.md#register-the-provider-kind)。现有沙箱会保留其大小。E2B 和 OpenSandbox 密钥仅可写入,并且已加密 | | 节点及其容量 | **节点**:**添加节点**、**编辑节点** 和移除 | `/core/v1/sandbox/enrollment-tokens`、`/core/v1/sandbox/nodes` | 请参阅[节点容量](#node-capacity)和[添加和管理节点](getting-started/nodes.md) | | 沙箱网络上限 | **设置** → **沙箱与调度** → **网络**:**允许访问公网**、**额外允许的域名** → **保存** | `/core/v1/sandbox/deployment` | `network.public` 和 `network.domains`;请参阅[网络](../../contracts/agents-api/zh/sandbox-deployment.md#network)。已有沙箱保持启动时的上限。提供商不支持的控件会被禁用 | -| 空闲挂起 | **设置** → **沙箱与调度** → **自动挂起** → **空闲多久后挂起(秒)** → **保存** | `PATCH /core/v1/sandbox/deployment` | 整个部署统一生效;参阅[挂起策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy) | +| 空闲超时 | **设置** → **沙箱与调度** → **自动挂起** → **空闲多久后挂起(秒)** → **保存**(不支持 checkpoint 时为 **空闲超时** → **空闲多久后过期(秒)**) | `PATCH /core/v1/sandbox/deployment` | 整个部署统一生效;参阅[挂起策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy) | | 独立工作区存储和租户容量 | **设置 → 工作区存储** | `/core/v1/workspace-storage` | 参阅[设置工作区存储](getting-started/workspace-storage.md);现有工作区保留自身配置 | | 项目和 API 密钥 | **项目与密钥**:**创建项目**、**改名**、**签发密钥**、**撤销**、**归档** | `/core/v1/projects` | 密钥只显示一次;Core 存储其摘要 | | 每个 Harness 的默认模型 | **设置** → **模型与凭据**:**设置** | `/core/v1/harnesses/{harness}/model-configuration` | 请参阅[默认模型](#default-models) | diff --git a/docs/zh/getting-started/sandbox-backends.md b/docs/zh/getting-started/sandbox-backends.md index e1720b93f..6d0a8516a 100644 --- a/docs/zh/getting-started/sandbox-backends.md +++ b/docs/zh/getting-started/sandbox-backends.md @@ -1,7 +1,7 @@ --- title: "选择沙箱提供商" source: docs/getting-started/sandbox-backends.md -source_hash: 6ff014aa67c43fc73e2ed049d8ec5ceb65f771511af0ba2ec0d692d6d529191a +source_hash: cbebd45c8d4600aaa102954be4057f0f74f6eed62551a1c7158c629a87695f1c --- 比较 microsandbox、E2B 和 OpenSandbox,然后在 Web 中选择一个。本指南面向已完成[安装 Core 和 Web](./install.md)、需要为 Core 托管的 Session 提供计算资源的运维人员。 @@ -16,7 +16,7 @@ source_hash: 6ff014aa67c43fc73e2ed049d8ec5ceb65f771511af0ba2ec0d692d6d529191a | 需要添加节点 | 是 | 否 | 否 | | 你需要提供 | 带 systemd 和 `/dev/kvm` 的 Linux amd64 主机 | E2B API 密钥和模板;Core 的公共 HTTPS URL | 服务器的 URL 和 API 密钥,以及为其构建的 Runtime 镜像;其沙箱可访问的 Core 地址 | | 隔离 | 每个沙箱有独立内核 | 由 E2B 提供 | 由 OpenSandbox 的 runtime 提供:容器,或安全容器 runtime | -| 空闲沙箱可挂起和恢复 | 是,通过检查点 | 是,由 E2B 原地暂停 | 否;继续运行 | +| 空闲沙箱可挂起和恢复 | 是,通过检查点 | 是,由 E2B 原地暂停 | 否;有 Session 的沙箱继续运行 | | 工作区存储 | 必需,使用 NFS | 自有磁盘 | 自有磁盘 | | 磁盘限制 | 根磁盘和工作区配额 | 由模板设置 | 由 OpenSandbox 的 runtime 设置 | diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 458650a23..7d39fc087 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: 5b4a649929c3df402d9adb589854b63fbd2f2233bf04cf0872f50f1b85991084 +source_hash: b68b8050088b7f46038b70af6c4db4a70cf9560846aae838512ce691b41a0ff2 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox、E2B 和 OpenSandbox 实现了该协议。 @@ -178,7 +178,7 @@ adapter 不实现这些行为,但依赖它们。代码层面的机制见 [Core - 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)负责。 - 挂载了 Session 时,Core 在 Turn 之间观察已连接的计算资源仍是该 Environment 的 running allocation。该检查不改变任何状态。运行中的计算资源从不到期,没有[确认](#confirmed-compute-loss)的不可达、已停止或缺失实例不授权丢弃保留的工作区或历史,也不授权选择替代计算资源。 -- Core 在一个[暂停策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)下,暂停每个声明 checkpoint 支持的提供商的空闲 allocation。 +- Core 在一个[暂停策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)下,暂停每个声明 checkpoint 支持的提供商的空闲 allocation。空闲超时对每个提供商是同一个设置;不支持 checkpoint 时它的作用见[独立 Environment](../../contracts/agents-api/zh/environments.md#standalone-environments)。 - 禁用提供商会停止新的托管准入和引导,但从不阻塞既有 Session 的取消、函数结果或 input 重试结果。 ```mermaid @@ -306,7 +306,8 @@ Core 在释放 allocation 和 placement 归属前,确认原始创建已结算 | 值 | 适用于 | 到期或超出时 | | --- | --- | --- | | 有界 context | 每个提供商调用 | 结束调用方的等待,不证明已回滚、停止、清理或不存在。adapter 从不分离未跟踪的 mutation,也不重放已超时的命令 | -| 空闲超时与快照保留期 | 支持检查点的 allocation,由[暂停策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)设定 | 空闲超时到达时 Core 开始[暂停](#suspension)。保留期结束时 Core 清理快照,并对符合条件的 Session 通过[冷替换](#cold-replacement-after-checkpoint-retention)替换计算资源 | +| 空闲超时 | 每个 allocation,由 `idle_seconds` 设定 | 支持检查点时 Core 开始[暂停](#suspension);不支持时见[独立 Environment](../../contracts/agents-api/zh/environments.md#standalone-environments) | +| 快照保留期 | 支持检查点的 allocation,由[暂停策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)设定 | 保留期结束时 Core 清理快照,并对符合条件的 Session 通过[冷替换](#cold-replacement-after-checkpoint-retention)替换计算资源 | | 15 s | 空闲 node allocation 在容量压力暂停之前的无活动时间 | 存在兼容的等待需求时,Core 可以早于空闲超时暂停它 | | 2 min | 所在 node 没有空闲 active slot 但有空闲 retained slot 时,空闲 allocation 的无活动时间 | Core 可以早于空闲超时、在没有等待需求时暂停它,让下一个 Session 找到空闲 slot | | 50 MiB 加 32 字节(`MaxCommandInputBytes`) | `Command.Stdin` | 命令无效 | diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 5cec7816e..a0dd41e9f 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: 6eb0c8250cd0d5b309ac0261a142051302497f8f77afd0214e13236bca1316f0 +source_hash: 3549b3d3257dbe213705c2b3f74fbf989b32bec35b2dbed890570848eca174d3 --- 本页列出各控制台页面调用的 Core 路由,以及控制台如何限定读取范围。本页面向控制台开发者;运维人员请阅读 [Web 控制台](index.md)。[Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API 命名空间和凭据](../api/index.md) 定义了本文使用的术语。 @@ -98,7 +98,7 @@ source_hash: 6eb0c8250cd0d5b309ac0261a142051302497f8f77afd0214e13236bca1316f0 | 操作 | 路由 | 控制台用途 | | --- | --- | --- | -| 部署 | `GET`、`POST`、`PUT /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(E2B 搭配回环地址形式的 `public_url`)会在设置向导中显示共享客户端固定的安全地址配置消息,并通过 **在设置中管理** 前往 **设置 → 服务与部署**,且无需确认;使用 microsandbox 的 `resources` 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置。**沙箱监控** 用 `specification.resources` 显示每个 E2B Sandbox 的大小;microsandbox 的 `suspension`(空闲和保留秒数)会显示在 **设置 → 沙箱与调度** 中 | +| 部署 | `GET`、`POST`、`PUT`、`PATCH /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(E2B 搭配回环地址形式的 `public_url`)会在设置向导中显示共享客户端固定的安全地址配置消息,并通过 **在设置中管理** 前往 **设置 → 服务与部署**,且无需确认;使用 microsandbox 的 `resources` 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置;`PATCH` 只保存 `idle_seconds`。**沙箱监控** 用 `specification.resources` 显示每个 E2B Sandbox 的大小;`idle_seconds` 显示在 **设置 → 沙箱与调度** 中:提供商支持检查点时为 **自动挂起**,并附带 `suspension`(快照保留秒数);不支持时为 **空闲超时** | | E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导用输入的凭据逐级列出 discovered 字段的选项;E2B 先列出该密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | | 重置 | `POST`、`DELETE /core/v1/sandbox/deployment/reset` | 显式清除托管资源,或在观测到的 generation 处取消剩余清除;显示 Core 的剩余资源和离线预测 | | 节点 | `GET /core/v1/sandbox/nodes` | **节点** 页面;**概览** 上的机群;**沙箱监控** 中每个托管 Runtime 所在的节点。`provider_ready` 为 false 的在线节点为降级;**节点** 列表及节点页面中紧邻其状态的帮助提示显示 *沙箱提供商不可用*,目标准备原因(`rollout.diagnostic`,一个[就绪状态码](../getting-started/nodes.md#readiness-codes))显示在其目标状态中。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,**节点** 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 **地址已过期**,而不是健康状态。**添加节点** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 283f34ff0..473a093ef 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -223,6 +223,7 @@ export interface DeploymentView { core_url: string; credential_configured: boolean; generation: number; + idle_seconds: number; installation_id: string; metadata?: Record; mode: DeploymentMode; @@ -235,8 +236,8 @@ export interface DeploymentView { specification_digest?: string; suspension: Suspension | null; } -export const deploymentViewFields = ["configuration", "core_url", "credential_configured", "generation", "installation_id", "metadata", "mode", "owner_epoch", "provider", "reset", "resources", "rollout", "specification", "specification_digest", "suspension"] as const; -export const deploymentViewRequired = ["core_url", "credential_configured", "generation", "installation_id", "mode", "owner_epoch", "provider", "reset", "resources", "rollout", "suspension"] as const; +export const deploymentViewFields = ["configuration", "core_url", "credential_configured", "generation", "idle_seconds", "installation_id", "metadata", "mode", "owner_epoch", "provider", "reset", "resources", "rollout", "specification", "specification_digest", "suspension"] as const; +export const deploymentViewRequired = ["core_url", "credential_configured", "generation", "idle_seconds", "installation_id", "mode", "owner_epoch", "provider", "reset", "resources", "rollout", "suspension"] as const; export interface DiagnosticFailure { code: DiagnosticFailureCode; failed_at: string | null; @@ -920,10 +921,9 @@ export interface SessionExecutionConfiguration { } export const sessionExecutionConfigurationFields = ["harness", "harness_config", "model", "model_provider", "object", "schema_version", "session_id"] as const; export interface Suspension { - idle_seconds: number; retention_seconds: number; } -export const suspensionFields = ["idle_seconds", "retention_seconds"] as const; +export const suspensionFields = ["retention_seconds"] as const; export interface TurnDiagnostics { failure: DiagnosticFailure | null; items: ItemDiagnosticTiming[]; diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index e594288e8..47eec9290 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -44,12 +44,12 @@ const allocation = { state: "running", compute_phase: "running", compute_phase_changed_at: null, diagnostic: "", initialization: "ready", created_at: created, }; const unconfigured = { - credential_configured: false, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, installation_id: "", provider: "", core_url: "https://core.example", reset: null, owner_epoch: 0, generation: 0, mode: "", resources: { allocations: 0, running: 0, pending: 0 }, suspension: null }; + credential_configured: false, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, installation_id: "", provider: "", core_url: "https://core.example", reset: null, owner_epoch: 0, generation: 0, mode: "", resources: { allocations: 0, running: 0, pending: 0 }, idle_seconds: 300, suspension: null }; const microsandbox = { configuration: {}, metadata: {}, ...unconfigured, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "microsandbox", rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 2, running: 2, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192 }, network: { public: true, domains: [] } }, specification_digest: "0".repeat(64), - suspension: { idle_seconds: 300, retention_seconds: 86400 }, + idle_seconds: 300, suspension: { retention_seconds: 86400 }, }; const externalWorkspace = { ...deploymentContract.providers.microsandbox.workspace, capacity_quota: false }; const externalDeployment = { ...microsandbox, specification: { ...microsandbox.specification, workspace: externalWorkspace } }; @@ -177,16 +177,16 @@ describe("Core sandbox credential boundaries", () => { }); it("patches idle policy once through the existing deployment resource", async () => { - const fetch = vi.fn().mockResolvedValue(response({ ...microsandbox, suspension: { idle_seconds: 60, retention_seconds: 86400 } })); + const fetch = vi.fn().mockResolvedValue(response({ ...microsandbox, idle_seconds: 60, suspension: { retention_seconds: 86400 } })); const admin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", token: "admin-only", fetch }); - expect((await admin.updateSuspension({ idle_seconds: 60 })).suspension?.idle_seconds).toBe(60); + expect((await admin.updateIdleSeconds({ idle_seconds: 60 })).idle_seconds).toBe(60); const [url, init] = fetch.mock.calls[0]!; expect(url).toBe("/core/v1/sandbox/deployment"); expect(init?.method).toBe("PATCH"); expect(JSON.parse(String(init?.body))).toEqual({ idle_seconds: 60 }); expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer admin-only"); fetch.mockRejectedValue(new TypeError("Network failed")); - await expect(admin.updateSuspension({ idle_seconds: 60 })).rejects.toThrow("Network failed"); + await expect(admin.updateIdleSeconds({ idle_seconds: 60 })).rejects.toThrow("Network failed"); expect(fetch).toHaveBeenCalledTimes(2); }); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 65672b4d6..6e2344e3e 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -162,8 +162,8 @@ function projectDeployment(value: unknown): SandboxDeployment { const suspension = deployment.suspension === null ? null : members(deployment.suspension, suspensionFields); const configured = hasOwn(deployment, "specification"); valid(strings(deployment, ["installation_id", "core_url"]) && (deployment.provider === "" || (typeof deployment.provider === "string" && hasOwn(deploymentContract.providers, deployment.provider))) && isOneOf(deploymentModeValues, deployment.mode) && - [deployment.owner_epoch, deployment.generation, resources.allocations, resources.running, resources.pending].every(isNonnegativeInteger) && - (suspension === null || [suspension.idle_seconds, suspension.retention_seconds].every(isNonnegativeInteger)) && + [deployment.owner_epoch, deployment.generation, deployment.idle_seconds, resources.allocations, resources.running, resources.pending].every(isNonnegativeInteger) && + (suspension === null || isNonnegativeInteger(suspension.retention_seconds)) && configured === hasOwn(deployment, "specification_digest") && (!configured || (typeof deployment.specification_digest === "string" && deployment.specification_digest !== ""))); return { ...deployment, rollout: projectRollout(deployment.rollout, deployment.mode, Number(resources.allocations) + Number(resources.pending)), reset: projectReset(deployment.reset, Number(resources.allocations) + Number(resources.pending)), resources: { ...resources } as unknown as SandboxDeployment["resources"], suspension: suspension && { ...suspension } as unknown as SandboxDeployment["suspension"], @@ -247,7 +247,7 @@ export class SandboxAdminClient { updateDeployment(input: UpdateSandboxDeployment, options?: ReadOptions): Promise { return this.#writeDeployment("PUT", input, options); } - async updateSuspension(input: SandboxIdlePolicyInput, options?: ReadOptions): Promise { + async updateIdleSeconds(input: SandboxIdlePolicyInput, options?: ReadOptions): Promise { return projectDeployment(await this.#core.json("/deployment", options, "PATCH", input)); } async startReset(input: StartSandboxReset, options?: ReadOptions): Promise { diff --git a/packages/agents-client/src/session-diagnostics.test.ts b/packages/agents-client/src/session-diagnostics.test.ts index e83a5ecdd..7b3136d94 100644 --- a/packages/agents-client/src/session-diagnostics.test.ts +++ b/packages/agents-client/src/session-diagnostics.test.ts @@ -31,11 +31,6 @@ it("preserves historical nulls and finite safe provisioning parameters", () => { expect(projectTurnDiagnostics(snapshot, id.toUpperCase(), turn.toUpperCase())).toEqual(snapshot); }); -it("accepts the Harness step Core records when the selected Harness is unavailable", () => { - const value = { ...session, failure: { source: "environment", code: "environment_provisioning_failed", params: { step: "harness", index: null, exit_code: null, category: null }, failed_at: null } }; - expect(projectSessionDiagnostics(value, id)).toEqual(value); -}); - it.each([ (v: any) => { v.failure.error = "secret-canary"; }, (v: any) => { v.failure.code = "input_secret-canary"; }, diff --git a/packages/agents-client/src/session-diagnostics.ts b/packages/agents-client/src/session-diagnostics.ts index 9e7aa39a5..ad047153b 100644 --- a/packages/agents-client/src/session-diagnostics.ts +++ b/packages/agents-client/src/session-diagnostics.ts @@ -15,7 +15,7 @@ export type { DiagnosticFailureCode, InitializationStepTiming, ItemDiagnosticTim // The schema types params as a free-form object; each code that carries params has one of these shapes. export const provisioningCategoryValues = ["timeout", "runtime_disconnected", "unconfirmed", "rejected", "operation_failed", "core_error", "core_restart", "compute_released"] as const; export type ProvisioningCategory = (typeof provisioningCategoryValues)[number]; -export type ProvisioningFailureParams = { step: "setup" | "python" | "npm" | "file" | "skill" | "harness" | null; index: number | null; exit_code: number | null; category: ProvisioningCategory | null }; +export type ProvisioningFailureParams = { step: "setup" | "python" | "npm" | "file" | "skill" | null; index: number | null; exit_code: number | null; category: ProvisioningCategory | null }; export type ConnectionFailureParams = { http_status: number | null }; export type DiagnosticFailure = Omit & { params: Record | ProvisioningFailureParams | ConnectionFailureParams }; export type SessionDiagnosticFailure = DiagnosticFailure & Pick; @@ -36,7 +36,7 @@ function failure(value: unknown, codes: readonly DiagnosticFailureCode[], sessio let params: DiagnosticFailure["params"] = {}; if (value.code === "environment_provisioning_failed") { const p = value.params; - if (!exactFields(p, fields("step", "index", "exit_code", "category")) || (p.category !== null && (!isOneOf(provisioningCategoryValues, p.category) || p.step !== null)) || (p.step !== null && (typeof p.step !== "string" || !["setup", "python", "npm", "file", "skill", "harness"].includes(p.step))) || + if (!exactFields(p, fields("step", "index", "exit_code", "category")) || (p.category !== null && (!isOneOf(provisioningCategoryValues, p.category) || p.step !== null)) || (p.step !== null && (typeof p.step !== "string" || !["setup", "python", "npm", "file", "skill"].includes(p.step))) || (p.index !== null && (!Number.isSafeInteger(p.index) || (p.index as number) < 0 || p.step !== "setup")) || (p.exit_code !== null && (!Number.isSafeInteger(p.exit_code) || (p.exit_code as number) < 1 || (p.exit_code as number) > 255 || !["setup", "python", "npm"].includes(p.step ?? "")))) return invalidAdminResponse(); params = { step: p.step as ProvisioningFailureParams["step"], index: p.index as number | null, exit_code: p.exit_code as number | null, category: p.category as ProvisioningFailureParams["category"] }; diff --git a/scripts/generate-public-api.py b/scripts/generate-public-api.py index bab980913..98a0b412b 100644 --- a/scripts/generate-public-api.py +++ b/scripts/generate-public-api.py @@ -18,6 +18,7 @@ REQUEST_SHAPES = { 'CreateAgentParams': (), 'UpdateAgentParams': (), 'SessionAgentConfigParam': (), 'CreateAgentSessionParams': ('agent', 'environment'), + 'CreateAgentEnvironmentParams': ('environment',), 'UpdateAgentSessionParams': (), 'CreateVaultParams': (), 'UpdateVaultParams': (), 'CreateVaultCredentialParams': (), diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index dd2a5c989..78f61e734 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -98,7 +98,7 @@ When next-Turn input is pending on a completed managed allocation that is suspen 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 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. +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, which Core journals only while a Session is attached. 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. diff --git a/services/core/cmd/server/managed_setup_test.go b/services/core/cmd/server/managed_setup_test.go index deda0f804..3f32a83a9 100644 --- a/services/core/cmd/server/managed_setup_test.go +++ b/services/core/cmd/server/managed_setup_test.go @@ -170,7 +170,7 @@ func TestManagedSetupPreparesWithoutPublishing(t *testing.T) { s := &managedSetup{registry: providers.Builtin(), installationID: id, hub: hub, deployment: &fakeDeploymentSetups{t: t}, allocations: &fakeGenerationAllocations{t: t}, runtimeAPI: "https://core.example/api/v1"} previous := &execution.RuntimeProvider{InstallationID: id, Generation: 1, ProviderKind: "microsandbox"} s.publish(previous) - candidate, err := s.prepare(t.Context(), deployment.Setup{InstallationID: id, Provider: "microsandbox", Mode: "nodes", Operations: microsandbox.Operations(), Suspension: &deployment.Suspension{IdleSeconds: 300, RetentionSeconds: 86400}}) + candidate, err := s.prepare(t.Context(), deployment.Setup{InstallationID: id, Provider: "microsandbox", Mode: "nodes", Operations: microsandbox.Operations(), Suspension: &deployment.Suspension{RetentionSeconds: 86400}}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/api/automations.go b/services/core/internal/api/automations.go index e6aaa01b6..28619d0f3 100644 --- a/services/core/internal/api/automations.go +++ b/services/core/internal/api/automations.go @@ -404,6 +404,9 @@ func automationEnvironment(input sessionRequest) *v1.AutomationEnvironment { var supplied map[string]json.RawMessage _ = json.Unmarshal(input.originalEnvironment, &supplied) has := func(name string) bool { value, ok := supplied[name]; return ok && string(value) != "null" } + if input.environmentID != "" { + result.EnvironmentID = &input.environmentID + } if has("environment_template_id") { result.EnvironmentTemplateID = &input.templateID } diff --git a/services/core/internal/api/environment_attach.go b/services/core/internal/api/environment_attach.go new file mode 100644 index 000000000..3fd690c1b --- /dev/null +++ b/services/core/internal/api/environment_attach.go @@ -0,0 +1,63 @@ +package api + +import ( + "encoding/json" + "fmt" + "slices" + "unicode/utf8" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" +) + +// attachExclusive lists, in the pinned schema's order, the members the schema's +// `not` constraint forbids beside environment_id. +var attachExclusive = []string{"packages", "setup_commands", "network", "desktop", "env", "capability_directories", "skills", "plugins", "files", "environment_template_id", "container_size"} + +// attachedEnvironment reads the standalone Environment a Session create names +// with environment.environment_id. Only type may accompany it, and presence +// counts, so an explicit null does too. The extension carries no preparation +// either. It returns "" when the request names none. +func attachedEnvironment(fields map[string]json.RawMessage, extension *v1.SessionExecutionInput) (string, error) { + value, supplied := fields["environment_id"] + if !supplied { + return "", nil + } + // Only the openai_hosted schema has environment_id; any other type rejects it. + var kind string + _ = json.Unmarshal(fields["type"], &kind) + if kind != "openai_hosted" { + return "", &fieldError{param: "environment.environment_id", message: "Unknown parameter: 'environment.environment_id'."} + } + for _, name := range attachExclusive { + if _, present := fields[name]; present { + return "", &fieldError{param: "environment." + name, message: fmt.Sprintf("environment.%s cannot be combined with environment.environment_id.", name)} + } + } + // Any other member is unknown beside environment_id; report the first in + // name order so the answer is deterministic. + var unknown []string + for name := range fields { + if name != "type" && name != "environment_id" && !slices.Contains(attachExclusive, name) { + unknown = append(unknown, name) + } + } + if len(unknown) > 0 { + name := slices.Min(unknown) + if !echoableField(name) { + return "", errUnknownParameter + } + return "", &fieldError{param: "environment." + name, message: "Unknown parameter: 'environment." + name + "'."} + } + if extension != nil && !isNull(extension.Environment) { + return "", &fieldError{param: "x_agents_core.environment", message: "x_agents_core.environment cannot be combined with environment.environment_id."} + } + if err := checkValue("environment.environment_id", value, environmentIDShape); err != nil { + return "", err + } + var id string + _ = json.Unmarshal(value, &id) + if n := utf8.RuneCountInString(id); n < 9 || n > 256 { + return "", &fieldError{param: "environment.environment_id", message: fmt.Sprintf("Invalid 'environment.environment_id': string length must be between 9 and 256 characters, but got a string with length %d instead.", n)} + } + return id, nil +} diff --git a/services/core/internal/api/environment_create.go b/services/core/internal/api/environment_create.go new file mode 100644 index 000000000..4d63134a7 --- /dev/null +++ b/services/core/internal/api/environment_create.go @@ -0,0 +1,109 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// createEnvironment creates a standalone hosted Environment, prepared like a +// Session's and reusable by one Session at a time. +func (h *Handler) createEnvironment(w http.ResponseWriter, r *http.Request) { + raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") + if !ok { + return + } + if err := validateBody(raw, createAgentEnvironmentParams); err != nil { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } + return + } + var body struct { + Environment json.RawMessage `json:"environment"` + VaultIDs []string `json:"vault_ids"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&body) != nil { + writeFieldError(w, &fieldError{code: "invalid_request", message: "Request must be a JSON object containing supported fields."}) + return + } + if len(body.VaultIDs) > 0 { + writeFieldError(w, &fieldError{param: "vault_ids", message: "Vault credentials attach to Sessions, not to standalone Environments."}) + return + } + var members map[string]json.RawMessage + _ = json.Unmarshal(body.Environment, &members) + // The create schema has neither member; they belong to the Environment resource. + for _, name := range []string{"environment_id", "container_size"} { + if members[name] != nil { + writeFieldError(w, &fieldError{param: "environment." + name, message: "Unknown parameter: 'environment." + name + "'."}) + return + } + } + key := r.Header.Get("Idempotency-Key") + if len(r.Header.Values("Idempotency-Key")) == 0 { + key = uuid.NewString() + } else if id, found, err := h.Execution.SessionAdmission.ReplayEnvironment(r.Context(), tenantID(r), sessionCreator(r), key, raw); err != nil { + writeOperationError(w, r, err) + return + } else if found { + h.writeEnvironmentCreated(w, r, id) + return + } + var input sessionRequest + err := input.decodeEnvironment(body.Environment, false) + if err == nil && input.Environment.Type != "openai_hosted" { + err = &fieldError{param: "environment.type", message: "A standalone Environment must be openai_hosted."} + } + if err != nil { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } + return + } + if input.templateID != "" { + template, err := h.EnvironmentTemplatesReader.Resolve(r.Context(), tenantID(r), input.templateID) + if err != nil { + writeEnvironmentTemplatesError(w, r, err) + return + } + if err := applyTemplateEnvironment(&input, template); err != nil { + if !writeFieldError(w, err) { + writeSessionsError(w, r, err) + } + return + } + } + configuration, err := json.Marshal(input.Environment) + if err != nil { + writeSessionsError(w, r, err) + return + } + id, err := h.Execution.SessionAdmission.CreateEnvironment(r.Context(), tenantID(r), sessions.CreateEnvironment{ + Creator: sessionCreator(r), IdempotencyKey: key, CreationRequest: raw, + Configuration: configuration, Initialization: input.initialization, InitialFiles: input.initialFiles, + }) + if err != nil { + writeOperationError(w, r, err) + return + } + h.writeEnvironmentCreated(w, r, id) +} + +func (h *Handler) writeEnvironmentCreated(w http.ResponseWriter, r *http.Request, id string) { + environment, err := h.EnvironmentsReader.GetEnvironment(r.Context(), tenantID(r), id) + if err == nil { + var response any + if response, err = environmentResponse(environment); err == nil { + writeJSON(w, http.StatusCreated, response) + return + } + } + writeSessionsError(w, r, err) +} diff --git a/services/core/internal/api/environment_files.go b/services/core/internal/api/environment_files.go index e507f348c..31c173012 100644 --- a/services/core/internal/api/environment_files.go +++ b/services/core/internal/api/environment_files.go @@ -80,10 +80,15 @@ func (h *Handler) listEnvironmentFiles(w http.ResponseWriter, r *http.Request) { var errHostedEnvironmentProvisioning = &fieldError{message: "the hosted environment is still provisioning; wait until it is connected before accessing files"} -// environmentFilesAccessible rejects Files operations on an openai_hosted -// Environment whose first connection has not been observed (HE-18). Callers -// run it after the tenant-scoped lookup, so foreign Environments stay missing. +// environmentFilesAccessible rejects Files operations on an Environment no +// Session is attached to, and on an openai_hosted Environment whose first +// connection has not been observed (HE-18). Callers run it after the +// tenant-scoped lookup, so foreign Environments stay missing. func environmentFilesAccessible(w http.ResponseWriter, environment sessions.Environment) bool { + if environment.AttachedSessionID == "" { + writeError(w, http.StatusConflict, "conflict_error", "Attach a session to this environment before using its files.") + return false + } var configuration struct { Type string `json:"type"` } diff --git a/services/core/internal/api/environments.go b/services/core/internal/api/environments.go index 22d7e071a..ab83981b8 100644 --- a/services/core/internal/api/environments.go +++ b/services/core/internal/api/environments.go @@ -16,6 +16,7 @@ import ( // Project's self_hosted Environments. type EnvironmentsReader interface { GetEnvironment(context.Context, string, string) (sessions.Environment, error) + ListEnvironments(ctx context.Context, tenant string, creator identity.Subject, after string, limit int, ascending bool) ([]sessions.Environment, bool, error) ProjectExecutorCredentialState(context.Context, identity.Principal, string) (sessions.ExecutorCredentialState, error) } @@ -45,6 +46,33 @@ func (h *Handler) getEnvironment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, response) } +func (h *Handler) listEnvironments(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r, "type") + if !ok { + return + } + if kind, supplied := r.URL.Query()["type"]; supplied && kind[0] != "openai_hosted" { + writeFieldError(w, &fieldError{param: "type", message: "Invalid 'type': unknown value. Supported values are: 'openai_hosted'."}) + return + } + page, more, err := h.EnvironmentsReader.ListEnvironments(r.Context(), tenantID(r), sessionCreator(r), options.after, options.limit, options.ascending) + if err != nil { + writeSessionsError(w, r, err) + return + } + data := make([]v1.EnvironmentInfo, 0, len(page)) + for _, environment := range page { + item, err := environmentResponse(environment) + if err != nil { + writeSessionsError(w, r, err) + return + } + data = append(data, item) + } + first, last := listBounds(data, func(value v1.EnvironmentInfo) string { return value.ID }) + writeJSON(w, http.StatusOK, v1.EnvironmentInfoList{Object: "list", Data: data, HasMore: more, FirstID: first, LastID: last}) +} + func environmentResponse(environment sessions.Environment) (v1.EnvironmentInfo, error) { configuration, err := storedEnvironment(environment.Configuration) if err != nil || (configuration.Type != "self_hosted" && configuration.Type != "openai_hosted") || environment.ID == "" { @@ -60,6 +88,10 @@ func environmentResponse(environment sessions.Environment) (v1.EnvironmentInfo, // A self_hosted Environment that has never connected reads disconnected. status = "disconnected" } + if environment.AttachedSessionID == "" && environment.Initialization == "complete" && (status == "pending" || status == "connected" || status == "disconnected") { + // A prepared Environment that no Session is attached to waits for one. + status = "ready" + } files := configuration.Files if files == nil { files = []json.RawMessage{} diff --git a/services/core/internal/api/errors_sessions.go b/services/core/internal/api/errors_sessions.go index 5f2ee9ec3..dcdd542bd 100644 --- a/services/core/internal/api/errors_sessions.go +++ b/services/core/internal/api/errors_sessions.go @@ -43,6 +43,10 @@ func writeSessionsError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, http.StatusUnauthorized, "installation_authorization_invalid", sessions.ErrInstallationAuthorization.Error()) case errors.Is(err, sessions.ErrExecutorCredentialExists): writeError(w, http.StatusConflict, "executor_credential_exists", "This executor key ID already exists. Explicitly rotate it to replace the secret.") + case errors.Is(err, sessions.ErrEnvironmentAttached): + writeError(w, http.StatusConflict, "conflict_error", "Environment is attached to another session.", "environment.environment_id") + case errors.Is(err, sessions.ErrEnvironmentTerminal): + writeError(w, http.StatusConflict, "conflict_error", "Environment has failed or expired and cannot take a session.", "environment.environment_id") case errors.Is(err, sessions.ErrHostedEnvironmentFailed): // Observed official status, type, code, null param and message. writeError(w, http.StatusConflict, "conflict_error", "the hosted environment failed to provision") diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index 7cf37a506..a01279560 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -398,9 +398,17 @@ func (f *fakeEnvironmentWorkspaces) WriteEnvironmentFile(a0 context.Context, a1 type fakeEnvironmentsReader struct { t testing.TB getEnvironment func(context.Context, string, string) (sessions.Environment, error) + listEnvironments func(context.Context, string, identity.Subject, string, int, bool) ([]sessions.Environment, bool, error) projectExecutorCredentialState func(context.Context, identity.Principal, string) (sessions.ExecutorCredentialState, error) } +func (f *fakeEnvironmentsReader) ListEnvironments(a0 context.Context, a1 string, a2 identity.Subject, a3 string, a4 int, a5 bool) ([]sessions.Environment, bool, error) { + if f.listEnvironments == nil { + unexpectedCall(f.t, "ListEnvironments") + } + return f.listEnvironments(a0, a1, a2, a3, a4, a5) +} + func (f *fakeEnvironmentsReader) GetEnvironment(a0 context.Context, a1 string, a2 string) (sessions.Environment, error) { if f.getEnvironment == nil { unexpectedCall(f.t, "GetEnvironment") @@ -772,8 +780,20 @@ func (f *fakeSessionAdmin) GetManagedSessionArchive(a0 context.Context, a1 strin } type fakeSessionAdmission struct { - t testing.TB - createSession func(context.Context, string, sessions.CreateSession) (sessions.Creation, error) + t testing.TB + createSession func(context.Context, string, sessions.CreateSession) (sessions.Creation, error) + createEnvironment func(context.Context, string, sessions.CreateEnvironment) (string, error) +} + +func (f *fakeSessionAdmission) ReplayEnvironment(context.Context, string, identity.Subject, string, json.RawMessage) (string, bool, error) { + return "", false, nil +} + +func (f *fakeSessionAdmission) CreateEnvironment(a0 context.Context, a1 string, a2 sessions.CreateEnvironment) (string, error) { + if f.createEnvironment == nil { + unexpectedCall(f.t, "CreateEnvironment") + } + return f.createEnvironment(a0, a1, a2) } func (f *fakeSessionAdmission) CreateSession(a0 context.Context, a1 string, a2 sessions.CreateSession) (sessions.Creation, error) { diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index c2e3593a1..06dbede9c 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -25,10 +25,14 @@ type SessionCreation interface { FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (sessions.Creation, error) } -// SessionAdmission creates Sessions that admit work through the execution -// Worker, which validates execution support first. +// SessionAdmission creates Sessions that admit work, and standalone +// Environments, through the execution Worker, which validates execution +// support first. type SessionAdmission interface { CreateSession(context.Context, string, sessions.CreateSession) (sessions.Creation, error) + CreateEnvironment(context.Context, string, sessions.CreateEnvironment) (string, error) + // ReplayEnvironment returns the Environment a retry's key already created. + ReplayEnvironment(ctx context.Context, tenant string, creator identity.Subject, key string, request json.RawMessage) (string, bool, error) } // Sessions updates and deletes Sessions, and records their public write audit. @@ -95,6 +99,8 @@ func (h *Handler) routes() *chi.Mux { r.Get("/agents/environments/templates/{environment_template_id}", h.getEnvironmentTemplate) r.Post("/agents/environments/templates/{environment_template_id}", h.updateEnvironmentTemplate) r.Delete("/agents/environments/templates/{environment_template_id}", h.deleteEnvironmentTemplate) + r.Post("/agents/environments", h.createEnvironment) + r.Get("/agents/environments", h.listEnvironments) r.Get("/agents/environments/{environment_id}", h.getEnvironment) r.Get("/agents/environments/{environment_id}/files", h.listEnvironmentFiles) r.Head("/agents/environments/{environment_id}/files", methodNotAllowed) @@ -120,7 +126,6 @@ func (h *Handler) routes() *chi.Mux { h.registerAutomationRoutes(r) // Official operations Core does not run answer like an unknown route. for _, route := range []struct{ method, path string }{ - {http.MethodPost, "/agents/environments"}, {http.MethodGet, "/agents/environments"}, {http.MethodGet, "/agents/sessions/{session_id}/traces"}, } { r.MethodFunc(route.method, route.path, unsupportedOperation) @@ -210,7 +215,7 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { ModelProviderSource: plan.source, DeploymentProviderRevision: plan.revision, Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, - Engine: plan.engine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: plan.configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, + Engine: plan.engine, EnvironmentID: plan.environmentID, IdempotencyKey: key, Metadata: input.Metadata, Configuration: plan.configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, } create := h.SessionCreation.CreateSession if len(initialInputs) > 0 || input.Environment.Type == "openai_hosted" { diff --git a/services/core/internal/api/hosted_environment_test.go b/services/core/internal/api/hosted_environment_test.go index e43ffcc60..aa2561a3b 100644 --- a/services/core/internal/api/hosted_environment_test.go +++ b/services/core/internal/api/hosted_environment_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "reflect" "strings" "testing" @@ -45,20 +46,53 @@ func TestHostedEnvironmentDefaultsAndExplicitGaps(t *testing.T) { // A request naming an existing Environment is rejected by param instead of // creating a new Environment, with or without a Template. -func TestSessionCreationRejectsExistingEnvironmentReference(t *testing.T) { +// An attach names the Environment and nothing that prepares one: the first +// member of the pinned `not` constraint in schema order is the param. +func TestSessionCreationRejectsInvalidEnvironmentReference(t *testing.T) { h, s := validationHandler(t) - rejected := `{"error":{"message":"Environment environment_id is not supported.","type":"invalid_request_error","code":"invalid_request_error","param":"environment_id"}}` + "\n" - for _, environment := range []string{ - `{"type":"openai_hosted","environment_id":"` + uuid.NewString() + `"}`, - `{"type":"openai_hosted","environment_id":"` + uuid.NewString() + `","environment_template_id":"` + uuid.NewString() + `"}`, + id := uuid.NewString() + for name, test := range map[string]struct{ environment, extension, param string }{ + "template": {`{"type":"openai_hosted","environment_id":"` + id + `","environment_template_id":"` + uuid.NewString() + `"}`, "", "environment.environment_template_id"}, + "explicit null": {`{"type":"openai_hosted","environment_id":"` + id + `","network":null}`, "", "environment.network"}, + "schema order": {`{"type":"openai_hosted","environment_id":"` + id + `","files":[],"packages":{}}`, "", "environment.packages"}, + "unknown member": {`{"type":"openai_hosted","environment_id":"` + id + `","foo":1}`, "", "environment.foo"}, + "workspace dir": {`{"type":"openai_hosted","environment_id":"` + id + `","workspace_directory":"/w"}`, "", "environment.workspace_directory"}, + "short": {`{"type":"openai_hosted","environment_id":"short"}`, "", "environment.environment_id"}, + "not hosted": {`{"type":"self_hosted","environment_id":"` + id + `","workspace_directory":"/w"}`, "", "environment.environment_id"}, + "preparation": {`{"type":"openai_hosted","environment_id":"` + id + `"}`, `"x_agents_core":{"environment":{"env":{"A":"b"}}},`, "x_agents_core.environment"}, + "not a string id": {`{"type":"openai_hosted","environment_id":7}`, "", "environment.environment_id"}, } { - body := `{"agent":{"model":"m"},"environment":` + environment + `,"input":"hi"}` - if w := bodyGateRequest(h, "/v1/agents/sessions", "application/json", []byte(body)); w.Code != http.StatusBadRequest || w.Body.String() != rejected { - t.Errorf("%s: %d %s", environment, w.Code, w.Body) + body := `{` + test.extension + `"agent":{"model":"m"},"environment":` + test.environment + `,"input":"hi"}` + w := bodyGateRequest(h, "/v1/agents/sessions", "application/json", []byte(body)) + var response struct{ Error struct{ Param *string } } + if json.Unmarshal(w.Body.Bytes(), &response) != nil || w.Code != http.StatusBadRequest || (test.param != "" && (response.Error.Param == nil || *response.Error.Param != test.param)) { + t.Errorf("%s: %d %s", name, w.Code, w.Body) } } if s.writes != 0 { - t.Fatalf("an Environment reference reached storage: %d writes", s.writes) + t.Fatalf("an invalid Environment reference reached storage: %d writes", s.writes) + } +} + +// Standalone create and list reject malformed requests before any write. +func TestStandaloneEnvironmentRequestValidation(t *testing.T) { + h, s := validationHandler(t) + for name, test := range map[string]struct{ path, body, param string }{ + "no environment": {"/v1/agents/environments", `{}`, "environment"}, + "vault ids": {"/v1/agents/environments", `{"environment":{"type":"openai_hosted"},"vault_ids":["v"]}`, "vault_ids"}, + "self hosted": {"/v1/agents/environments", `{"environment":{"type":"self_hosted","workspace_directory":"/w"}}`, ""}, + "attach on create": {"/v1/agents/environments", `{"environment":{"type":"openai_hosted","environment_id":"` + uuid.NewString() + `"}}`, "environment.environment_id"}, + "container size": {"/v1/agents/environments", `{"environment":{"type":"openai_hosted","container_size":null}}`, "environment.container_size"}, + "unknown member": {"/v1/agents/environments", `{"environment":{"type":"openai_hosted"},"extra":1}`, ""}, + } { + w := bodyGateRequest(h, test.path, "application/json", []byte(test.body)) + var response struct{ Error struct{ Param *string } } + if json.Unmarshal(w.Body.Bytes(), &response) != nil || w.Code != http.StatusBadRequest || (test.param != "" && (response.Error.Param == nil || *response.Error.Param != test.param)) { + t.Errorf("%s: %d %s", name, w.Code, w.Body) + } + } + if s.writes != 0 { + t.Fatalf("an invalid Environment request reached storage: %d writes", s.writes) } } @@ -140,3 +174,31 @@ func (r *hostedCreationRecorder) CreateSession(context.Context, string, sessions r.calls++ return sessions.Creation{}, sessions.ErrInvalidInput } + +// attachExclusive is checked against the pinned schema's `not` constraint, its +// one authored definition. +func TestAttachExclusiveMatchesThePinnedSchema(t *testing.T) { + raw, err := os.ReadFile("../../../../contracts/agents-api/upstream/openapi.json") + if err != nil { + t.Fatal(err) + } + var pin struct { + Components struct { + Schemas map[string]struct { + Not struct { + AnyOf []struct{ Required []string } `json:"anyOf"` + } `json:"not"` + } `json:"schemas"` + } `json:"components"` + } + if err := json.Unmarshal(raw, &pin); err != nil { + t.Fatal(err) + } + var members []string + for _, choice := range pin.Components.Schemas["EnvironmentParamOpenaiHosted"].Not.AnyOf { + members = append(members, choice.Required...) + } + if !reflect.DeepEqual(members, attachExclusive) { + t.Fatal("the pinned not constraint", members) + } +} diff --git a/services/core/internal/api/official_shapes.gen.go b/services/core/internal/api/official_shapes.gen.go index ac84ff905..5ce65ce2f 100644 --- a/services/core/internal/api/official_shapes.gen.go +++ b/services/core/internal/api/official_shapes.gen.go @@ -259,6 +259,10 @@ var ( {"stream", shape{kind: booleanValue}}, {"x_agents_core", shape{}}, }} + createAgentEnvironmentParams = shape{kind: objectValue, members: []member{ + {"environment", shape{kind: openObject, required: true}}, + {"vault_ids", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + }} updateAgentSessionParams = shape{kind: objectValue, members: []member{ {"review", shape{kind: enumValue, values: []string{"none", "auto_review"}}}, {"agent", shape{kind: objectValue, members: []member{ diff --git a/services/core/internal/api/routing_test.go b/services/core/internal/api/routing_test.go index f3a75cc87..79f7ee38e 100644 --- a/services/core/internal/api/routing_test.go +++ b/services/core/internal/api/routing_test.go @@ -349,7 +349,7 @@ func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { t.Errorf("/v1 routes differ from the pinned set:\nserved %v\npinned %v", served, pinned.Routes) } // Official operations Core does not run answer like an unknown route. - for _, route := range []string{"POST /v1/agents/environments", "GET /v1/agents/environments", "GET /v1/agents/sessions/s/traces"} { + for _, route := range []string{"GET /v1/agents/sessions/s/traces"} { method, target, _ := strings.Cut(route, " ") if got := serve(handler, method, target, "", withHeaders(project, beta)); got.Code != http.StatusNotFound || !strings.Contains(got.Body.String(), `"code":"unsupported_operation"`) { t.Errorf("%s = %d %s", route, got.Code, got.Body) diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go index 3c2328415..6cf0d2218 100644 --- a/services/core/internal/api/sandbox_deployment_setup.go +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -237,8 +237,8 @@ type SandboxIdlePolicyInput struct { IdleSeconds *int64 `json:"idle_seconds" binding:"required" minimum:"30" maximum:"86400"` } -// @Summary Update automatic suspension policy -// @Description Changes the Core idle timeout for every running allocation without a restart or generation rollout. Requires checkpoint support and no active reset. Already-started suspensions and snapshot expiry are unchanged. Capacity pressure can still suspend quiet idle compute earlier. +// @Summary Update idle timeout +// @Description Changes the Core idle timeout for every running allocation without a restart or generation rollout. Requires a selected Provider and no active reset. With checkpoint support an idle sandbox suspends; without it an idle unattached standalone Environment expires. Already-started suspensions and snapshot expiry are unchanged. Capacity pressure can still suspend quiet idle compute earlier. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth diff --git a/services/core/internal/api/sandbox_deployment_setup_test.go b/services/core/internal/api/sandbox_deployment_setup_test.go index d7628969a..4e3c8170d 100644 --- a/services/core/internal/api/sandbox_deployment_setup_test.go +++ b/services/core/internal/api/sandbox_deployment_setup_test.go @@ -56,7 +56,7 @@ func TestSandboxIdlePolicyRequiresAdministratorAndStrictBody(t *testing.T) { if seconds < 30 || seconds > 86400 { return deployment.View{}, deployment.ErrInvalidInput } - return deployment.View{Suspension: &deployment.Suspension{IdleSeconds: seconds, RetentionSeconds: 86400}}, nil + return deployment.View{IdleSeconds: seconds, Suspension: &deployment.Suspension{RetentionSeconds: 86400}}, nil } h := newTestHandler(t, deps) for _, test := range []struct { diff --git a/services/core/internal/api/session_creation_identity.go b/services/core/internal/api/session_creation_identity.go index 6225b74e1..276b0c20c 100644 --- a/services/core/internal/api/session_creation_identity.go +++ b/services/core/internal/api/session_creation_identity.go @@ -25,7 +25,7 @@ func sessionCreationRequest(input sessionRequest, initial []sessions.Input) (jso agentID = *input.AgentID } var environment any = input.Environment - if input.templateID != "" || len(input.initialFiles) > 0 || !input.initialization.Empty() || (input.XAgentsCore != nil && len(input.XAgentsCore.Environment) > 0) { + if input.environmentID != "" || input.templateID != "" || len(input.initialFiles) > 0 || !input.initialization.Empty() || (input.XAgentsCore != nil && len(input.XAgentsCore.Environment) > 0) { environment = input.originalEnvironment if len(input.originalEnvironment) == 0 { environment = input.templateEnvironment diff --git a/services/core/internal/api/session_plan.go b/services/core/internal/api/session_plan.go index 34663f604..662ddf1ee 100644 --- a/services/core/internal/api/session_plan.go +++ b/services/core/internal/api/session_plan.go @@ -20,6 +20,7 @@ type sessionPlan struct { revision uuid.UUID engine string configuration json.RawMessage + environmentID string } // planSession resolves the environment template and the saved Agent, freezes @@ -44,6 +45,14 @@ func (h *Handler) planSession(ctx context.Context, tenant, key string, input *se return p, writeAgentsError, err } err = h.prepareSessionModelConfiguration(ctx, input, p.saved, p.inheritedProvider) + if err == nil && input.environmentID != "" { + // The Session runs in the Environment as prepared; its configuration + // replaces the request's placeholder. + if err = h.attachableEnvironment(ctx, tenant, input); err != nil { + return p, writeSessionsError, err + } + p.environmentID = input.environmentID + } if err == nil { p.configuration, err = resolve(*input, tenant, key, p.saved) } @@ -77,3 +86,16 @@ func writeSessionConfigurationError(w http.ResponseWriter, r *http.Request, err writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) } } + +// attachableEnvironment loads the standalone Environment the request names and +// takes its stored configuration as the Session's environment. It checks the +// Project and existence only. +func (h *Handler) attachableEnvironment(ctx context.Context, tenant string, input *sessionRequest) error { + environment, err := h.EnvironmentsReader.GetEnvironment(ctx, tenant, input.environmentID) + if err != nil { + return err + } + // Whether it is attached or ended is decided when the Session is created. + input.Environment, err = storedEnvironment(environment.Configuration) + return err +} diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index 6ebe519b8..84d633b9f 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -33,12 +33,49 @@ type sessionRequest struct { harnessConfigSource v1.ExecutionSource resolvedHarnessConfig json.RawMessage v1.CreateSessionRequest - Input json.RawMessage + Input json.RawMessage + // environmentID names the standalone Environment the Session attaches to. + environmentID string templateID string templateEnvironment json.RawMessage agentFields map[string]json.RawMessage } +// decodeEnvironment decodes the request's environment object and the +// preparation input it carries. The Session surface (extension) also accepts +// self_hosted Templates and x_agents_core.environment. +func (input *sessionRequest) decodeEnvironment(raw json.RawMessage, extension bool) error { + if extension { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) == nil { + id, err := attachedEnvironment(fields, input.XAgentsCore) + if err != nil || id != "" { + // The attached Environment's own configuration replaces this + // placeholder when the Session is planned. + input.environmentID, input.originalEnvironment, input.Environment = id, raw, &v1.Environment{Type: "openai_hosted"} + return err + } + } + } + canonicalEnvironment, err := preparationEnvironmentInput(raw, input.XAgentsCore) + if err != nil { + return err + } + input.originalEnvironment = raw + var environmentFields map[string]json.RawMessage + if json.Unmarshal(canonicalEnvironment, &environmentFields) != nil { + return sessions.ErrInvalidInput + } + if input.initialization, err = decodeEnvironmentSetup(environmentFields); err != nil { + return err + } + if input.initialFiles, err = decodeInitialFiles(environmentFields["files"]); err != nil { + return err + } + input.Environment, input.templateID, input.templateEnvironment, err = decodePreparationTemplate(canonicalEnvironment, extension) + return err +} + func (request decodedSessionRequest) validated() (sessionRequest, error) { input := sessionRequest{CreateSessionRequest: request.CreateSessionRequest, Input: request.Input} if request.AgentID != nil && utf8.RuneCountInString(*request.AgentID) > 64 { @@ -53,25 +90,7 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { input.modelProviderNull = bytes.Equal(bytes.TrimSpace(fields["model_provider"]), []byte("null")) } input.VaultIDs = append([]string{}, request.VaultIDs...) - canonicalEnvironment, err := preparationEnvironmentInput(request.Environment, input.XAgentsCore) - if err != nil { - return input, err - } - input.originalEnvironment = request.Environment - var environmentFields map[string]json.RawMessage - if json.Unmarshal(canonicalEnvironment, &environmentFields) != nil { - return input, sessions.ErrInvalidInput - } - input.initialization, err = decodeEnvironmentSetup(environmentFields) - if err != nil { - return input, err - } - input.initialFiles, err = decodeInitialFiles(environmentFields["files"]) - if err != nil { - return input, err - } - input.Environment, input.templateID, input.templateEnvironment, err = decodePreparationTemplate(canonicalEnvironment, true) - if err != nil { + if err := input.decodeEnvironment(request.Environment, true); err != nil { return input, err } if len(request.Agent) > 0 { diff --git a/services/core/internal/api/unsupported_fields_test.go b/services/core/internal/api/unsupported_fields_test.go index 1a12e41ae..55a986493 100644 --- a/services/core/internal/api/unsupported_fields_test.go +++ b/services/core/internal/api/unsupported_fields_test.go @@ -18,7 +18,6 @@ func TestUnsupportedOfficialFieldsAreRejectedByParam(t *testing.T) { {"/v1/agents/environments/templates", `{"desktop":{"enabled":"yes"}}`, "desktop.enabled"}, {"/v1/agents/environments/templates", `{"desktop":{}}`, "desktop.enabled"}, {"/v1/agents/environments/templates", `{"desktop":{"enabled":false,"x":1}}`, "desktop.x"}, - {"/v1/agents/sessions", `{"agent":{"model":"m"},"environment":{"type":"openai_hosted","environment_id":null},"input":"hi"}`, "environment_id"}, {"/v1/agents/sessions", `{"agent":{"model":"m"},"environment":{"type":"openai_hosted","container_size":null},"input":"hi"}`, "container_size"}, {"/v1/agents/sessions", `{"agent":{"model":"m"},"environment":{"type":"openai_hosted","desktop":{"enabled":"yes"}},"input":"hi"}`, "desktop.enabled"}, {"/v1/agents/sessions/s/events", `{"events":[{"type":"agent.session.input.computer_use_approval_request_result","request_id":"r","response":{"type":"browser_origin_access","decision":"approve"}}]}`, "events[0].type"}, diff --git a/services/core/internal/credentialcrypto/environment_file.go b/services/core/internal/credentialcrypto/environment_file.go index 779ed5e87..e85bc359a 100644 --- a/services/core/internal/credentialcrypto/environment_file.go +++ b/services/core/internal/credentialcrypto/environment_file.go @@ -30,7 +30,7 @@ func (c *Cipher) OpenEnvironmentFile(ciphertext []byte, binding EnvironmentFileB } func environmentFileData(binding EnvironmentFileBinding) ([]byte, error) { - if binding.Resource != "environment_template" && binding.Resource != "session" { + if binding.Resource != "environment_template" && binding.Resource != "environment" { return nil, errInvalidBinding } for _, value := range []string{binding.TenantID, binding.OwnerID, binding.FileID} { diff --git a/services/core/internal/credentialcrypto/environment_file_test.go b/services/core/internal/credentialcrypto/environment_file_test.go index 4a89a49d3..0a1a2b5e8 100644 --- a/services/core/internal/credentialcrypto/environment_file_test.go +++ b/services/core/internal/credentialcrypto/environment_file_test.go @@ -10,7 +10,7 @@ func TestEnvironmentFileCipherOwnershipAndPurpose(t *testing.T) { if err != nil { t.Fatal(err) } - bound := EnvironmentFileBinding{TenantID: "tenant", Resource: "session", OwnerID: "session", FileID: "file"} + bound := EnvironmentFileBinding{TenantID: "tenant", Resource: "environment", OwnerID: "environment", FileID: "file"} plain := []byte("confidential-file-canary\x00\xff") sealed, err := cipher.SealEnvironmentFile(plain, bound) if err != nil || bytes.Contains(sealed, plain) { diff --git a/services/core/internal/credentialcrypto/environment_setup.go b/services/core/internal/credentialcrypto/environment_setup.go index 7a452bc4b..bbb4a709d 100644 --- a/services/core/internal/credentialcrypto/environment_setup.go +++ b/services/core/internal/credentialcrypto/environment_setup.go @@ -30,7 +30,7 @@ func (c *Cipher) OpenEnvironmentSetup(ciphertext []byte, binding EnvironmentSetu } func environmentSetupData(binding EnvironmentSetupBinding) ([]byte, error) { - if (binding.Resource != "environment_template" && binding.Resource != "session") || (binding.Field != "env" && binding.Field != "setup_commands" && binding.Field != "initialization" && binding.Field != "skills" && binding.Field != "plugins") { + if (binding.Resource != "environment_template" && binding.Resource != "environment") || (binding.Field != "env" && binding.Field != "setup_commands" && binding.Field != "initialization" && binding.Field != "skills" && binding.Field != "plugins") { return nil, errInvalidBinding } for _, value := range []string{binding.TenantID, binding.OwnerID} { diff --git a/services/core/internal/credentialcrypto/environment_setup_test.go b/services/core/internal/credentialcrypto/environment_setup_test.go index 6d99cdcaf..dacf9e59f 100644 --- a/services/core/internal/credentialcrypto/environment_setup_test.go +++ b/services/core/internal/credentialcrypto/environment_setup_test.go @@ -17,7 +17,7 @@ func TestEnvironmentSetupResourceAndFieldBinding(t *testing.T) { if err != nil || !bytes.Equal(got, plaintext) { t.Fatal("round trip", err) } - for _, change := range []func(*EnvironmentSetupBinding){func(b *EnvironmentSetupBinding) { b.TenantID = "other" }, func(b *EnvironmentSetupBinding) { b.Resource = "session" }, func(b *EnvironmentSetupBinding) { b.OwnerID = "other" }, func(b *EnvironmentSetupBinding) { b.Field = "setup_commands" }} { + for _, change := range []func(*EnvironmentSetupBinding){func(b *EnvironmentSetupBinding) { b.TenantID = "other" }, func(b *EnvironmentSetupBinding) { b.Resource = "environment" }, func(b *EnvironmentSetupBinding) { b.OwnerID = "other" }, func(b *EnvironmentSetupBinding) { b.Field = "setup_commands" }} { other := binding change(&other) if _, err := c.OpenEnvironmentSetup(encrypted, other); err == nil { diff --git a/services/core/internal/db/queries/admin_session_archive.sql b/services/core/internal/db/queries/admin_session_archive.sql index 9cd6ba017..0ebb2f641 100644 --- a/services/core/internal/db/queries/admin_session_archive.sql +++ b/services/core/internal/db/queries/admin_session_archive.sql @@ -4,10 +4,11 @@ SELECT s.id AS session_id, e.id AS environment_id, CASE WHEN a.state = 'released' THEN 'released' WHEN a.state = 'cleanup_pending' THEN 'cleanup_pending' - WHEN e.status IN ('failed', 'expired') THEN + WHEN NOT l.active THEN 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.id = s.environment_id +JOIN environment_liveness l ON l.id = e.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/environment_connections.sql b/services/core/internal/db/queries/environment_connections.sql index 0b05037e7..3137143f4 100644 --- a/services/core/internal/db/queries/environment_connections.sql +++ b/services/core/internal/db/queries/environment_connections.sql @@ -27,7 +27,7 @@ DELETE FROM environment_connections WHERE environment_id = $1; -- name: ListEnvironmentConnections :many 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)) +WHERE e.id > $1 AND NOT (SELECT l.ended FROM environment_liveness l WHERE l.id = e.id) 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_initialization.sql b/services/core/internal/db/queries/environment_initialization.sql index 0580e3a58..62524c191 100644 --- a/services/core/internal/db/queries/environment_initialization.sql +++ b/services/core/internal/db/queries/environment_initialization.sql @@ -1,9 +1,9 @@ -- name: ListEnvironmentInitializations :many SELECT e.id, e.tenant_id, e.initialization, d.id AS device_id FROM environments e +JOIN environment_liveness l ON l.id = e.id AND l.active 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') +WHERE e.id > $1 AND e.initialization IN ('pending', 'running') ORDER BY e.id LIMIT 32; diff --git a/services/core/internal/db/queries/environment_setup.sql b/services/core/internal/db/queries/environment_setup.sql index 555925470..870d18d70 100644 --- a/services/core/internal/db/queries/environment_setup.sql +++ b/services/core/internal/db/queries/environment_setup.sql @@ -2,8 +2,8 @@ INSERT INTO environment_setups (environment_id, contents) VALUES ($1, $2); -- name: GetEnvironmentSetup :one -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 +SELECT f.contents, l.ended::boolean AS ended +FROM environments e JOIN environment_liveness l ON l.id = e.id LEFT JOIN environment_setups f ON f.environment_id = e.id WHERE e.tenant_id = $1 AND e.id = $2; -- name: SetEnvironmentSetupMetadata :exec diff --git a/services/core/internal/db/queries/environments.sql b/services/core/internal/db/queries/environments.sql index 312ad9264..085fd2a02 100644 --- a/services/core/internal/db/queries/environments.sql +++ b/services/core/internal/db/queries/environments.sql @@ -1,6 +1,16 @@ -- name: CreateEnvironment :exec -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); +-- A creation identity makes the Environment standalone; without one a Session owns it. +INSERT INTO environments (id, tenant_id, configuration, session_owned, initialization, idempotency_key, request_hash, creator_kind, creator_id) +VALUES (sqlc.arg(id), sqlc.arg(tenant_id), sqlc.arg(configuration), sqlc.narg(idempotency_key)::text IS NULL, + CASE WHEN sqlc.arg(prepared)::boolean THEN 'pending' ELSE 'complete' END, + sqlc.narg(idempotency_key), sqlc.narg(request_hash), sqlc.narg(creator_kind), sqlc.narg(creator_id)); + +-- name: LockEnvironmentCreation :exec +SELECT pg_advisory_xact_lock(hashtextextended(sqlc.arg(tenant_id)::text || ':' || sqlc.arg(creator_kind)::text || ':' || sqlc.arg(creator_id)::text || ':' || sqlc.arg(idempotency_key)::text, 0)); + +-- name: FindEnvironmentCreation :one +SELECT id, idempotency_key, request_hash, creator_kind, creator_id +FROM environments WHERE tenant_id = $1 AND creator_kind = $2 AND creator_id = $3 AND idempotency_key = $4; -- name: AttachSessionEnvironment :exec UPDATE sessions SET environment_id = $2 WHERE id = $1; @@ -23,5 +33,24 @@ FROM environments e LEFT JOIN sessions s ON s.environment_id = e.id AND s.delete 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; +SELECT e.id FROM environments e WHERE e.tenant_id = $1 AND e.id = $2 FOR UPDATE; + +-- name: GetStandaloneEnvironmentCursor :one +SELECT created_at, id FROM environments +WHERE tenant_id = $1 AND id = $2 AND NOT session_owned AND creator_kind = $3 AND creator_id = $4; + +-- name: ListStandaloneEnvironments :many +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 LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.tenant_id = sqlc.arg(tenant_id) AND NOT e.session_owned + AND e.creator_kind = sqlc.arg(creator_kind) AND e.creator_id = sqlc.arg(creator_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (e.created_at, e.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (e.created_at, e.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN e.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN e.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN e.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN e.id END DESC +LIMIT sqlc.arg(page_limit); diff --git a/services/core/internal/db/queries/initial_environment_files.sql b/services/core/internal/db/queries/initial_environment_files.sql index fe78a21ed..b9ef68e3a 100644 --- a/services/core/internal/db/queries/initial_environment_files.sql +++ b/services/core/internal/db/queries/initial_environment_files.sql @@ -7,8 +7,9 @@ UPDATE environments SET configuration = jsonb_set(configuration, '{files}', $2:: WHERE id = $1; -- name: GetInitialEnvironmentFile :one -SELECT f.*, (SELECT s.id FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) AS owner_session_id +SELECT f.*, l.ended::boolean AS ended FROM initial_environment_files f JOIN environments e ON e.id = f.environment_id +JOIN environment_liveness l ON l.id = e.id WHERE e.tenant_id = $1 AND f.environment_id = $2 AND f.position = $3; -- name: GetSessionInitializationReady :one diff --git a/services/core/internal/db/queries/local_environment_devices.sql b/services/core/internal/db/queries/local_environment_devices.sql index 59819d64a..c2d90670e 100644 --- a/services/core/internal/db/queries/local_environment_devices.sql +++ b/services/core/internal/db/queries/local_environment_devices.sql @@ -4,7 +4,7 @@ SELECT sqlc.arg(id), e.tenant_id, sqlc.arg(name), sqlc.arg(credential_hash), e.i 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 EXISTS (SELECT 1 FROM environment_liveness l WHERE l.id = e.id AND l.active) 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 diff --git a/services/core/internal/db/queries/runtime_allocations.sql b/services/core/internal/db/queries/runtime_allocations.sql index 6bc4d7956..c7f425eaa 100644 --- a/services/core/internal/db/queries/runtime_allocations.sql +++ b/services/core/internal/db/queries/runtime_allocations.sql @@ -3,17 +3,19 @@ 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), 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 +SELECT sqlc.embed(a), s.id AS attached_session_id, e.tenant_id, l.ended::boolean AS ended, (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 environment_liveness l ON l.id = e.id 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), 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 +SELECT sqlc.embed(a), s.id AS attached_session_id, e.tenant_id, l.ended::boolean AS ended, (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 environment_liveness l ON l.id = e.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; @@ -61,9 +63,10 @@ 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 environment_liveness l ON l.id = e.id AND l.active 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 e.status NOT IN ('failed','expired') + WHERE a.id = $1 AND e.initialization = 'complete' AND e.configuration->>'type' = 'openai_hosted' AND d.supported_agent_kinds @> jsonb_build_array(jsonb_build_object( diff --git a/services/core/internal/db/queries/runtime_deployment.sql b/services/core/internal/db/queries/runtime_deployment.sql index cc9f46221..522962921 100644 --- a/services/core/internal/db/queries/runtime_deployment.sql +++ b/services/core/internal/db/queries/runtime_deployment.sql @@ -4,9 +4,8 @@ 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 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)) +((SELECT count(*) FROM environments e JOIN environment_liveness l ON l.id = e.id AND l.active + WHERE ((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 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 diff --git a/services/core/internal/db/queries/runtime_enrollment.sql b/services/core/internal/db/queries/runtime_enrollment.sql index 9f912ea37..8d256e904 100644 --- a/services/core/internal/db/queries/runtime_enrollment.sql +++ b/services/core/internal/db/queries/runtime_enrollment.sql @@ -2,13 +2,14 @@ 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 environment_liveness l ON l.id = e.id AND l.active 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.deleted_at IS NULL AND e.configuration->>'type' = 'self_hosted' FOR SHARE OF c; @@ -30,7 +31,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 environment_liveness l ON l.id = e.id AND l.active 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_lifecycle_nodes.sql b/services/core/internal/db/queries/runtime_lifecycle_nodes.sql index 3640eb0be..e91cf6147 100644 --- a/services/core/internal/db/queries/runtime_lifecycle_nodes.sql +++ b/services/core/internal/db/queries/runtime_lifecycle_nodes.sql @@ -6,9 +6,10 @@ SELECT NULL::uuid AS id FROM runtime_deployment WHERE mode = 'direct' ORDER BY id; -- name: ListRuntimeAllocationsForNode :many -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 +SELECT sqlc.embed(a), s.id AS attached_session_id, e.tenant_id, l.ended::boolean AS ended, (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 environment_liveness l ON l.id=e.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' @@ -16,13 +17,13 @@ ORDER BY a.id LIMIT 32; -- name: ListUnallocatedHostedEnvironmentsForNode :many 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 +FROM environments e JOIN environment_liveness l ON l.id=e.id AND l.active 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.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') + AND e.id > sqlc.arg(after_id)::uuid 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; @@ -44,11 +45,11 @@ WITH demand AS ( 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 LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +FROM environments e JOIN environment_liveness l ON l.id = e.id AND l.active +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.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') - AND e.configuration->>'type' = 'openai_hosted' +WHERE 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 909ad727e..7f445d07a 100644 --- a/services/core/internal/db/queries/runtime_nodes.sql +++ b/services/core/internal/db/queries/runtime_nodes.sql @@ -65,10 +65,12 @@ LEFT JOIN runtime_allocations a ON a.environment_id=p.environment_id AND a.state -- name: ReleaseRuntimePlacement :exec UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp()) WHERE environment_id=$1; --- name: ReleaseUnallocatedRuntimePlacement :exec -UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp()) -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: ReleaseInactiveRuntimePlacements :exec +-- Environments that can no longer run work give up the placements they hold without compute. +UPDATE runtime_placements p SET released_at=clock_timestamp() +FROM environment_liveness l +WHERE p.released_at IS NULL AND l.id=p.environment_id AND NOT l.active +AND NOT EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.environment_id=p.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,s.id AS attached_session_id,e.tenant_id diff --git a/services/core/internal/db/queries/runtime_suspension.sql b/services/core/internal/db/queries/runtime_suspension.sql index 8655d235e..0ae139f5b 100644 --- a/services/core/internal/db/queries/runtime_suspension.sql +++ b/services/core/internal/db/queries/runtime_suspension.sql @@ -38,6 +38,21 @@ 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: RecordStandaloneActivity :exec +UPDATE runtime_allocations a SET compute_activity_at = clock_timestamp() +FROM environments e +WHERE e.id = $1 AND NOT e.session_owned AND a.environment_id = e.id AND a.state <> 'released'; + +-- name: EndUnattachedStandaloneEnvironment :exec +-- An unattached standalone Environment whose compute is gone or being removed has nothing to resume, so it ends. +-- One that never had compute is waiting for its first placement and stays. +UPDATE environments e SET status = 'expired' +FROM environment_liveness l +WHERE e.id = $1 AND l.id = e.id AND l.active AND NOT e.session_owned + AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) + AND EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id) + AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state IN ('creating', 'running')); + -- name: RuntimeComputeBlocksAdmission :one SELECT EXISTS ( SELECT 1 FROM runtime_allocations a JOIN sessions s ON s.environment_id = a.environment_id @@ -76,3 +91,6 @@ SET node_id=sqlc.arg(destination)::uuid, compute_phase=sqlc.arg(phase), compute_state=sqlc.arg(state)::jsonb, compute_revision=a.compute_revision+1, compute_phase_changed_at=CASE WHEN a.compute_phase=sqlc.arg(phase)::text THEN a.compute_phase_changed_at ELSE clock_timestamp() END FROM moved WHERE a.id=moved.id RETURNING a.*; + +-- name: GetSessionEnvironmentID :one +SELECT environment_id FROM sessions WHERE id = $1; diff --git a/services/core/internal/db/queries/runtime_suspension_pressure.sql b/services/core/internal/db/queries/runtime_suspension_pressure.sql index fe4d0e768..efcd39147 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.environment_id=e.id AND s.deleted_at IS NULL +FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN environment_liveness l ON l.id=e.id AND l.active 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 e.status NOT IN ('failed','expired') + AND a.compute_retained_until>clock_timestamp() 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/queries/sandbox_reset.sql b/services/core/internal/db/queries/sandbox_reset.sql index efba74d27..ec15d3d89 100644 --- a/services/core/internal/db/queries/sandbox_reset.sql +++ b/services/core/internal/db/queries/sandbox_reset.sql @@ -36,12 +36,15 @@ SELECT ( 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.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 +-- name: ListSandboxResetCandidates :many +-- Each hosted Environment that holds reset resources, with its live Session: +-- the reset archives the Session, or expires an unattached standalone one. +SELECT e.id, e.tenant_id, s.id AS session_id +FROM environments e +JOIN environment_liveness l ON l.id = e.id AND l.active +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.configuration->>'type' = 'openai_hosted' + AND e.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')) OR (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) @@ -51,7 +54,7 @@ WHERE s.deleted_at IS NULL AND e.configuration->>'type' = 'openai_hosted' OR EXISTS (SELECT 1 FROM subagent_turns t WHERE t.session_id = s.id AND t.status IN ('in_progress', 'waiting')) OR EXISTS (SELECT 1 FROM environment_file_writes f WHERE f.environment_id = e.id AND f.state = 'pending') )) -ORDER BY s.id LIMIT 32; +ORDER BY e.id LIMIT 32; -- name: GetSandboxResetProject :one SELECT id FROM projects WHERE tenant_id = $1; @@ -61,18 +64,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 (e.session_owned AND s.id IS NULL) OR e.status IN ('failed', 'expired') + (a.state = 'cleanup_pending' OR NOT l.active 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 environment_liveness l ON l.id = e.id 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 LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL + FROM environments e JOIN environment_liveness l ON l.id = e.id AND l.active + 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.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) + WHERE ((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 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 diff --git a/services/core/internal/db/queries/workspaces.sql b/services/core/internal/db/queries/workspaces.sql index fcf6556ed..c1b010bdf 100644 --- a/services/core/internal/db/queries/workspaces.sql +++ b/services/core/internal/db/queries/workspaces.sql @@ -61,6 +61,9 @@ SELECT NOT EXISTS ( SELECT 1 FROM runtime_allocations WHERE environment_id = $1 AND state <> 'released' ) AS released; +-- name: EnvironmentWorkspaceDeletable :one +SELECT EXISTS (SELECT 1 FROM environment_liveness WHERE id = $1 AND ended)::boolean; + -- name: BeginWorkspaceDeletion :execrows UPDATE environment_workspaces SET state = 'deleting' WHERE object_id = $1 AND state IN ('creating', 'ready'); @@ -75,7 +78,7 @@ FROM environment_workspaces w JOIN workspace_fs_configurations c ON c.id = w.configuration_id JOIN environments e ON e.id = w.environment_id WHERE w.object_id > $1 AND w.state <> 'deleted' - 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 (e.id IN (SELECT id FROM environment_liveness WHERE ended) 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 1a32e9d0e..4384d0467 100644 --- a/services/core/internal/db/sqlc/admin_session_archive.sql.go +++ b/services/core/internal/db/sqlc/admin_session_archive.sql.go @@ -17,11 +17,12 @@ SELECT s.id AS session_id, e.id AS environment_id, CASE WHEN a.state = 'released' THEN 'released' WHEN a.state = 'cleanup_pending' THEN 'cleanup_pending' - WHEN e.status IN ('failed', 'expired') THEN + WHEN NOT l.active THEN 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.id = s.environment_id +JOIN environment_liveness l ON l.id = e.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/environment_connections.sql.go b/services/core/internal/db/sqlc/environment_connections.sql.go index 37bb17080..1d7afc81c 100644 --- a/services/core/internal/db/sqlc/environment_connections.sql.go +++ b/services/core/internal/db/sqlc/environment_connections.sql.go @@ -66,7 +66,7 @@ func (q *Queries) GetEnvironmentConnection(ctx context.Context, environmentID pg const listEnvironmentConnections = `-- name: ListEnvironmentConnections :many 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)) +WHERE e.id > $1 AND NOT (SELECT l.ended FROM environment_liveness l WHERE l.id = e.id) 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/sqlc/environment_initialization.sql.go b/services/core/internal/db/sqlc/environment_initialization.sql.go index 3bf079bfc..41312b019 100644 --- a/services/core/internal/db/sqlc/environment_initialization.sql.go +++ b/services/core/internal/db/sqlc/environment_initialization.sql.go @@ -49,9 +49,9 @@ func (q *Queries) FailEnvironmentInitialization(ctx context.Context, id pgtype.U const listEnvironmentInitializations = `-- name: ListEnvironmentInitializations :many SELECT e.id, e.tenant_id, e.initialization, d.id AS device_id FROM environments e +JOIN environment_liveness l ON l.id = e.id AND l.active 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') +WHERE e.id > $1 AND e.initialization IN ('pending', 'running') ORDER BY e.id LIMIT 32 ` diff --git a/services/core/internal/db/sqlc/environment_setup.sql.go b/services/core/internal/db/sqlc/environment_setup.sql.go index 1444af2ec..6c14ea5e5 100644 --- a/services/core/internal/db/sqlc/environment_setup.sql.go +++ b/services/core/internal/db/sqlc/environment_setup.sql.go @@ -26,8 +26,8 @@ func (q *Queries) CreateEnvironmentSetup(ctx context.Context, arg CreateEnvironm } const getEnvironmentSetup = `-- name: GetEnvironmentSetup :one -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 +SELECT f.contents, l.ended::boolean AS ended +FROM environments e JOIN environment_liveness l ON l.id = e.id LEFT JOIN environment_setups f ON f.environment_id = e.id WHERE e.tenant_id = $1 AND e.id = $2 ` @@ -37,14 +37,14 @@ type GetEnvironmentSetupParams struct { } type GetEnvironmentSetupRow struct { - Contents []byte `json:"contents"` - OwnerSessionID pgtype.UUID `json:"owner_session_id"` + Contents []byte `json:"contents"` + Ended bool `json:"ended"` } func (q *Queries) GetEnvironmentSetup(ctx context.Context, arg GetEnvironmentSetupParams) (GetEnvironmentSetupRow, error) { row := q.db.QueryRow(ctx, getEnvironmentSetup, arg.TenantID, arg.ID) var i GetEnvironmentSetupRow - err := row.Scan(&i.Contents, &i.OwnerSessionID) + err := row.Scan(&i.Contents, &i.Ended) return i, err } diff --git a/services/core/internal/db/sqlc/environments.sql.go b/services/core/internal/db/sqlc/environments.sql.go index 9fc72e77a..6b8c36e9e 100644 --- a/services/core/internal/db/sqlc/environments.sql.go +++ b/services/core/internal/db/sqlc/environments.sql.go @@ -26,27 +26,76 @@ func (q *Queries) AttachSessionEnvironment(ctx context.Context, arg AttachSessio } const createEnvironment = `-- name: CreateEnvironment :exec -INSERT INTO environments (id, tenant_id, configuration, session_owned, initialization) -VALUES ($1, $2, $3, true, CASE WHEN $4::boolean THEN 'pending' ELSE 'complete' END) +INSERT INTO environments (id, tenant_id, configuration, session_owned, initialization, idempotency_key, request_hash, creator_kind, creator_id) +VALUES ($1, $2, $3, $4::text IS NULL, + CASE WHEN $5::boolean THEN 'pending' ELSE 'complete' END, + $4, $6, $7, $8) ` type CreateEnvironmentParams struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Configuration []byte `json:"configuration"` - Prepared bool `json:"prepared"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Configuration []byte `json:"configuration"` + IdempotencyKey pgtype.Text `json:"idempotency_key"` + Prepared bool `json:"prepared"` + RequestHash pgtype.Text `json:"request_hash"` + CreatorKind pgtype.Text `json:"creator_kind"` + CreatorID pgtype.Text `json:"creator_id"` } +// A creation identity makes the Environment standalone; without one a Session owns it. func (q *Queries) CreateEnvironment(ctx context.Context, arg CreateEnvironmentParams) error { _, err := q.db.Exec(ctx, createEnvironment, arg.ID, arg.TenantID, arg.Configuration, + arg.IdempotencyKey, arg.Prepared, + arg.RequestHash, + arg.CreatorKind, + arg.CreatorID, ) return err } +const findEnvironmentCreation = `-- name: FindEnvironmentCreation :one +SELECT id, idempotency_key, request_hash, creator_kind, creator_id +FROM environments WHERE tenant_id = $1 AND creator_kind = $2 AND creator_id = $3 AND idempotency_key = $4 +` + +type FindEnvironmentCreationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + CreatorKind pgtype.Text `json:"creator_kind"` + CreatorID pgtype.Text `json:"creator_id"` + IdempotencyKey pgtype.Text `json:"idempotency_key"` +} + +type FindEnvironmentCreationRow struct { + ID pgtype.UUID `json:"id"` + IdempotencyKey pgtype.Text `json:"idempotency_key"` + RequestHash pgtype.Text `json:"request_hash"` + CreatorKind pgtype.Text `json:"creator_kind"` + CreatorID pgtype.Text `json:"creator_id"` +} + +func (q *Queries) FindEnvironmentCreation(ctx context.Context, arg FindEnvironmentCreationParams) (FindEnvironmentCreationRow, error) { + row := q.db.QueryRow(ctx, findEnvironmentCreation, + arg.TenantID, + arg.CreatorKind, + arg.CreatorID, + arg.IdempotencyKey, + ) + var i FindEnvironmentCreationRow + err := row.Scan( + &i.ID, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} + const getEnvironment = `-- name: GetEnvironment :one 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 @@ -156,9 +205,116 @@ func (q *Queries) GetSessionEnvironment(ctx context.Context, arg GetSessionEnvir return i, err } +const getStandaloneEnvironmentCursor = `-- name: GetStandaloneEnvironmentCursor :one +SELECT created_at, id FROM environments +WHERE tenant_id = $1 AND id = $2 AND NOT session_owned AND creator_kind = $3 AND creator_id = $4 +` + +type GetStandaloneEnvironmentCursorParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + CreatorKind pgtype.Text `json:"creator_kind"` + CreatorID pgtype.Text `json:"creator_id"` +} + +type GetStandaloneEnvironmentCursorRow struct { + CreatedAt pgtype.Timestamptz `json:"created_at"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetStandaloneEnvironmentCursor(ctx context.Context, arg GetStandaloneEnvironmentCursorParams) (GetStandaloneEnvironmentCursorRow, error) { + row := q.db.QueryRow(ctx, getStandaloneEnvironmentCursor, + arg.TenantID, + arg.ID, + arg.CreatorKind, + arg.CreatorID, + ) + var i GetStandaloneEnvironmentCursorRow + err := row.Scan(&i.CreatedAt, &i.ID) + return i, err +} + +const listStandaloneEnvironments = `-- name: ListStandaloneEnvironments :many +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 LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.tenant_id = $1 AND NOT e.session_owned + AND e.creator_kind = $2 AND e.creator_id = $3 + AND ($4::timestamptz IS NULL + OR (NOT $5::boolean AND (e.created_at, e.id) < ($4::timestamptz, $6::uuid)) + OR ($5::boolean AND (e.created_at, e.id) > ($4::timestamptz, $6::uuid))) +ORDER BY + CASE WHEN $5::boolean THEN e.created_at END ASC, + CASE WHEN $5::boolean THEN e.id END ASC, + CASE WHEN NOT $5::boolean THEN e.created_at END DESC, + CASE WHEN NOT $5::boolean THEN e.id END DESC +LIMIT $7 +` + +type ListStandaloneEnvironmentsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + CreatorKind pgtype.Text `json:"creator_kind"` + CreatorID pgtype.Text `json:"creator_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListStandaloneEnvironmentsRow struct { + Environment Environment `json:"environment"` + AttachedSessionID pgtype.UUID `json:"attached_session_id"` + ExternalWorkspace bool `json:"external_workspace"` +} + +func (q *Queries) ListStandaloneEnvironments(ctx context.Context, arg ListStandaloneEnvironmentsParams) ([]ListStandaloneEnvironmentsRow, error) { + rows, err := q.db.Query(ctx, listStandaloneEnvironments, + arg.TenantID, + arg.CreatorKind, + arg.CreatorID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListStandaloneEnvironmentsRow{} + for rows.Next() { + var i ListStandaloneEnvironmentsRow + if err := rows.Scan( + &i.Environment.ID, + &i.Environment.Status, + &i.Environment.CreatedAt, + &i.Environment.FailureReason, + &i.Environment.FailedAt, + &i.Environment.FailureDetail, + &i.Environment.Initialization, + &i.Environment.InitializationSteps, + &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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + 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 +SELECT e.id FROM environments e WHERE e.tenant_id = $1 AND e.id = $2 FOR UPDATE ` type LockEnvironmentParams struct { @@ -166,14 +322,30 @@ type LockEnvironmentParams struct { 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) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, lockEnvironment, arg.TenantID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err } -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 +const lockEnvironmentCreation = `-- name: LockEnvironmentCreation :exec +SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':' || $2::text || ':' || $3::text || ':' || $4::text, 0)) +` + +type LockEnvironmentCreationParams struct { + TenantID string `json:"tenant_id"` + CreatorKind string `json:"creator_kind"` + CreatorID string `json:"creator_id"` + IdempotencyKey string `json:"idempotency_key"` +} + +func (q *Queries) LockEnvironmentCreation(ctx context.Context, arg LockEnvironmentCreationParams) error { + _, err := q.db.Exec(ctx, lockEnvironmentCreation, + arg.TenantID, + arg.CreatorKind, + arg.CreatorID, + arg.IdempotencyKey, + ) + return 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 84498fab0..771e6423a 100644 --- a/services/core/internal/db/sqlc/initial_environment_files.sql.go +++ b/services/core/internal/db/sqlc/initial_environment_files.sql.go @@ -38,8 +38,9 @@ func (q *Queries) CreateInitialEnvironmentFile(ctx context.Context, arg CreateIn } const getInitialEnvironmentFile = `-- name: GetInitialEnvironmentFile :one -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 +SELECT f.id, f.environment_id, f.position, f.path, f.size_bytes, f.contents, l.ended::boolean AS ended FROM initial_environment_files f JOIN environments e ON e.id = f.environment_id +JOIN environment_liveness l ON l.id = e.id WHERE e.tenant_id = $1 AND f.environment_id = $2 AND f.position = $3 ` @@ -50,13 +51,13 @@ type GetInitialEnvironmentFileParams struct { } 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"` + 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"` + Ended bool `json:"ended"` } func (q *Queries) GetInitialEnvironmentFile(ctx context.Context, arg GetInitialEnvironmentFileParams) (GetInitialEnvironmentFileRow, error) { @@ -69,7 +70,7 @@ func (q *Queries) GetInitialEnvironmentFile(ctx context.Context, arg GetInitialE &i.Path, &i.SizeBytes, &i.Contents, - &i.OwnerSessionID, + &i.Ended, ) return i, 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 b8062357d..b78f5a48a 100644 --- a/services/core/internal/db/sqlc/local_environment_devices.sql.go +++ b/services/core/internal/db/sqlc/local_environment_devices.sql.go @@ -51,7 +51,7 @@ 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 EXISTS (SELECT 1 FROM environment_liveness l WHERE l.id = e.id AND l.active) 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 diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index 0e318904c..e0f29b68f 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -159,6 +159,12 @@ type EnvironmentInputReservation struct { Settings []byte `json:"settings"` } +type EnvironmentLiveness struct { + ID pgtype.UUID `json:"id"` + Ended pgtype.Bool `json:"ended"` + Active pgtype.Bool `json:"active"` +} + type EnvironmentSetup struct { EnvironmentID pgtype.UUID `json:"environment_id"` Contents []byte `json:"contents"` diff --git a/services/core/internal/db/sqlc/runtime_allocations.sql.go b/services/core/internal/db/sqlc/runtime_allocations.sql.go index 9d66e996a..29593c071 100644 --- a/services/core/internal/db/sqlc/runtime_allocations.sql.go +++ b/services/core/internal/db/sqlc/runtime_allocations.sql.go @@ -33,9 +33,10 @@ 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 environment_liveness l ON l.id = e.id AND l.active 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 e.status NOT IN ('failed','expired') + WHERE a.id = $1 AND e.initialization = 'complete' AND e.configuration->>'type' = 'openai_hosted' AND d.supported_agent_kinds @> jsonb_build_array(jsonb_build_object( @@ -98,9 +99,10 @@ 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, 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 +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, l.ended::boolean AS ended, (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 environment_liveness l ON l.id = e.id 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 @@ -115,7 +117,7 @@ type GetLatestRuntimeAllocationRow struct { RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` AttachedSessionID pgtype.UUID `json:"attached_session_id"` TenantID pgtype.UUID `json:"tenant_id"` - Reclaimed bool `json:"reclaimed"` + Ended bool `json:"ended"` Expired bool `json:"expired"` } @@ -143,16 +145,17 @@ func (q *Queries) GetLatestRuntimeAllocation(ctx context.Context, arg GetLatestR &i.RuntimeAllocation.DeploymentGeneration, &i.AttachedSessionID, &i.TenantID, - &i.Reclaimed, + &i.Ended, &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, 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 +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, l.ended::boolean AS ended, (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 environment_liveness l ON l.id = e.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 @@ -162,7 +165,7 @@ type ListRuntimeAllocationsRow struct { RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` AttachedSessionID pgtype.UUID `json:"attached_session_id"` TenantID pgtype.UUID `json:"tenant_id"` - Reclaimed bool `json:"reclaimed"` + Ended bool `json:"ended"` Expired bool `json:"expired"` } @@ -196,7 +199,7 @@ func (q *Queries) ListRuntimeAllocations(ctx context.Context, id pgtype.UUID) ([ &i.RuntimeAllocation.DeploymentGeneration, &i.AttachedSessionID, &i.TenantID, - &i.Reclaimed, + &i.Ended, &i.Expired, ); err != nil { return nil, err diff --git a/services/core/internal/db/sqlc/runtime_deployment.sql.go b/services/core/internal/db/sqlc/runtime_deployment.sql.go index cc26a23a8..7795de689 100644 --- a/services/core/internal/db/sqlc/runtime_deployment.sql.go +++ b/services/core/internal/db/sqlc/runtime_deployment.sql.go @@ -34,9 +34,8 @@ 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 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)) +((SELECT count(*) FROM environments e JOIN environment_liveness l ON l.id = e.id AND l.active + WHERE ((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 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 diff --git a/services/core/internal/db/sqlc/runtime_enrollment.sql.go b/services/core/internal/db/sqlc/runtime_enrollment.sql.go index 328fa6095..21de26120 100644 --- a/services/core/internal/db/sqlc/runtime_enrollment.sql.go +++ b/services/core/internal/db/sqlc/runtime_enrollment.sql.go @@ -15,13 +15,14 @@ const authorizeRuntimeEnrollment = `-- name: AuthorizeRuntimeEnrollment :one 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 environment_liveness l ON l.id = e.id AND l.active 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.deleted_at IS NULL AND e.configuration->>'type' = 'self_hosted' FOR SHARE OF c ` @@ -82,9 +83,9 @@ 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 environment_liveness l ON l.id = e.id AND l.active 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_lifecycle_nodes.sql.go b/services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go index 22224e793..ee5255673 100644 --- a/services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go +++ b/services/core/internal/db/sqlc/runtime_lifecycle_nodes.sql.go @@ -61,11 +61,11 @@ WITH demand AS ( 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 LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +FROM environments e JOIN environment_liveness l ON l.id = e.id AND l.active +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.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') - AND e.configuration->>'type' = 'openai_hosted' +WHERE 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,9 +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, 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 +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, l.ended::boolean AS ended, (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 environment_liveness l ON l.id=e.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' @@ -144,7 +145,7 @@ type ListRuntimeAllocationsForNodeRow struct { RuntimeAllocation RuntimeAllocation `json:"runtime_allocation"` AttachedSessionID pgtype.UUID `json:"attached_session_id"` TenantID pgtype.UUID `json:"tenant_id"` - Reclaimed bool `json:"reclaimed"` + Ended bool `json:"ended"` Expired bool `json:"expired"` } @@ -178,7 +179,7 @@ func (q *Queries) ListRuntimeAllocationsForNode(ctx context.Context, arg ListRun &i.RuntimeAllocation.DeploymentGeneration, &i.AttachedSessionID, &i.TenantID, - &i.Reclaimed, + &i.Ended, &i.Expired, ); err != nil { return nil, err @@ -221,13 +222,13 @@ func (q *Queries) ListRuntimeLifecycleNodes(ctx context.Context) ([]pgtype.UUID, const listUnallocatedHostedEnvironmentsForNode = `-- name: ListUnallocatedHostedEnvironmentsForNode :many 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 +FROM environments e JOIN environment_liveness l ON l.id=e.id AND l.active 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.id IS NOT NULL OR NOT e.session_owned) AND e.status NOT IN ('failed','expired') + AND e.id > $2::uuid 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 c4e582d12..5fbbc4c94 100644 --- a/services/core/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/core/internal/db/sqlc/runtime_nodes.sql.go @@ -484,23 +484,25 @@ func (q *Queries) ReleaseFailedRuntimePlacement(ctx context.Context, arg Release return err } -const releaseRuntimePlacement = `-- name: ReleaseRuntimePlacement :exec -UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp()) WHERE environment_id=$1 +const releaseInactiveRuntimePlacements = `-- name: ReleaseInactiveRuntimePlacements :exec +UPDATE runtime_placements p SET released_at=clock_timestamp() +FROM environment_liveness l +WHERE p.released_at IS NULL AND l.id=p.environment_id AND NOT l.active +AND NOT EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.environment_id=p.environment_id AND a.state<>'released') ` -func (q *Queries) ReleaseRuntimePlacement(ctx context.Context, environmentID pgtype.UUID) error { - _, err := q.db.Exec(ctx, releaseRuntimePlacement, environmentID) +// Environments that can no longer run work give up the placements they hold without compute. +func (q *Queries) ReleaseInactiveRuntimePlacements(ctx context.Context) error { + _, err := q.db.Exec(ctx, releaseInactiveRuntimePlacements) return err } -const releaseUnallocatedRuntimePlacement = `-- name: ReleaseUnallocatedRuntimePlacement :exec -UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp()) -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') +const releaseRuntimePlacement = `-- name: ReleaseRuntimePlacement :exec +UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp()) WHERE environment_id=$1 ` -func (q *Queries) ReleaseUnallocatedRuntimePlacement(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, releaseUnallocatedRuntimePlacement, id) +func (q *Queries) ReleaseRuntimePlacement(ctx context.Context, environmentID pgtype.UUID) error { + _, err := q.db.Exec(ctx, releaseRuntimePlacement, environmentID) 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 c638b83e2..2eccec994 100644 --- a/services/core/internal/db/sqlc/runtime_suspension.sql.go +++ b/services/core/internal/db/sqlc/runtime_suspension.sql.go @@ -27,6 +27,22 @@ func (q *Queries) ClearRuntimeWake(ctx context.Context, arg ClearRuntimeWakePara return err } +const endUnattachedStandaloneEnvironment = `-- name: EndUnattachedStandaloneEnvironment :exec +UPDATE environments e SET status = 'expired' +FROM environment_liveness l +WHERE e.id = $1 AND l.id = e.id AND l.active AND NOT e.session_owned + AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) + AND EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id) + AND NOT EXISTS (SELECT 1 FROM runtime_allocations a WHERE a.environment_id = e.id AND a.state IN ('creating', 'running')) +` + +// An unattached standalone Environment whose compute is gone or being removed has nothing to resume, so it ends. +// One that never had compute is waiting for its first placement and stays. +func (q *Queries) EndUnattachedStandaloneEnvironment(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, endUnattachedStandaloneEnvironment, id) + return err +} + const getRuntimeActivity = `-- name: GetRuntimeActivity :one SELECT clock_timestamp()::timestamptz AS observed_at, GREATEST(a.compute_activity_at, a.compute_phase_changed_at, @@ -62,6 +78,17 @@ func (q *Queries) GetRuntimeActivity(ctx context.Context, id pgtype.UUID) (GetRu return i, err } +const getSessionEnvironmentID = `-- name: GetSessionEnvironmentID :one +SELECT environment_id FROM sessions WHERE id = $1 +` + +func (q *Queries) GetSessionEnvironmentID(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, getSessionEnvironmentID, id) + var environment_id pgtype.UUID + err := row.Scan(&environment_id) + return environment_id, err +} + const moveSuspendedRuntimeCompute = `-- name: MoveSuspendedRuntimeCompute :one WITH moved AS ( UPDATE runtime_placements p SET node_id=$1::uuid @@ -138,6 +165,17 @@ func (q *Queries) RecordRuntimeTerminalActivity(ctx context.Context, id pgtype.U return err } +const recordStandaloneActivity = `-- name: RecordStandaloneActivity :exec +UPDATE runtime_allocations a SET compute_activity_at = clock_timestamp() +FROM environments e +WHERE e.id = $1 AND NOT e.session_owned AND a.environment_id = e.id AND a.state <> 'released' +` + +func (q *Queries) RecordStandaloneActivity(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, recordStandaloneActivity, id) + return err +} + const runtimeComputeBlocksAdmission = `-- name: RuntimeComputeBlocksAdmission :one SELECT EXISTS ( SELECT 1 FROM runtime_allocations a JOIN sessions s ON s.environment_id = a.environment_id 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 c907ea605..b4b7b6453 100644 --- a/services/core/internal/db/sqlc/runtime_suspension_pressure.sql.go +++ b/services/core/internal/db/sqlc/runtime_suspension_pressure.sql.go @@ -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.environment_id=e.id AND s.deleted_at IS NULL +FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN environment_liveness l ON l.id=e.id AND l.active 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 e.status NOT IN ('failed','expired') + AND a.compute_retained_until>clock_timestamp() 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 4d2d805ce..3add782f1 100644 --- a/services/core/internal/db/sqlc/sandbox_reset.sql.go +++ b/services/core/internal/db/sqlc/sandbox_reset.sql.go @@ -54,18 +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 (e.session_owned AND s.id IS NULL) OR e.status IN ('failed', 'expired') + (a.state = 'cleanup_pending' OR NOT l.active 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 environment_liveness l ON l.id = e.id 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 LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL + FROM environments e JOIN environment_liveness l ON l.id = e.id AND l.active + 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.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) + WHERE ((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 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 @@ -165,12 +166,13 @@ func (q *Queries) GetSandboxResetProject(ctx context.Context, tenantID pgtype.UU return id, err } -const listSandboxResetSessions = `-- name: ListSandboxResetSessions :many -SELECT s.id, s.tenant_id -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 +const listSandboxResetCandidates = `-- name: ListSandboxResetCandidates :many +SELECT e.id, e.tenant_id, s.id AS session_id +FROM environments e +JOIN environment_liveness l ON l.id = e.id AND l.active +LEFT JOIN sessions s ON s.environment_id = e.id AND s.deleted_at IS NULL +WHERE e.configuration->>'type' = 'openai_hosted' + AND e.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')) OR (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) @@ -180,29 +182,32 @@ WHERE s.deleted_at IS NULL AND e.configuration->>'type' = 'openai_hosted' OR EXISTS (SELECT 1 FROM subagent_turns t WHERE t.session_id = s.id AND t.status IN ('in_progress', 'waiting')) OR EXISTS (SELECT 1 FROM environment_file_writes f WHERE f.environment_id = e.id AND f.state = 'pending') )) -ORDER BY s.id LIMIT 32 +ORDER BY e.id LIMIT 32 ` -type ListSandboxResetSessionsParams struct { +type ListSandboxResetCandidatesParams struct { AfterID pgtype.UUID `json:"after_id"` Force bool `json:"force"` } -type ListSandboxResetSessionsRow struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` +type ListSandboxResetCandidatesRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` } -func (q *Queries) ListSandboxResetSessions(ctx context.Context, arg ListSandboxResetSessionsParams) ([]ListSandboxResetSessionsRow, error) { - rows, err := q.db.Query(ctx, listSandboxResetSessions, arg.AfterID, arg.Force) +// Each hosted Environment that holds reset resources, with its live Session: +// the reset archives the Session, or expires an unattached standalone one. +func (q *Queries) ListSandboxResetCandidates(ctx context.Context, arg ListSandboxResetCandidatesParams) ([]ListSandboxResetCandidatesRow, error) { + rows, err := q.db.Query(ctx, listSandboxResetCandidates, arg.AfterID, arg.Force) if err != nil { return nil, err } defer rows.Close() - items := []ListSandboxResetSessionsRow{} + items := []ListSandboxResetCandidatesRow{} for rows.Next() { - var i ListSandboxResetSessionsRow - if err := rows.Scan(&i.ID, &i.TenantID); err != nil { + var i ListSandboxResetCandidatesRow + if err := rows.Scan(&i.ID, &i.TenantID, &i.SessionID); err != nil { return nil, err } items = append(items, i) diff --git a/services/core/internal/db/sqlc/workspaces.sql.go b/services/core/internal/db/sqlc/workspaces.sql.go index f134d632b..de9ac614a 100644 --- a/services/core/internal/db/sqlc/workspaces.sql.go +++ b/services/core/internal/db/sqlc/workspaces.sql.go @@ -42,6 +42,17 @@ func (q *Queries) ClearActiveWorkspaceConfiguration(ctx context.Context) error { return err } +const environmentWorkspaceDeletable = `-- name: EnvironmentWorkspaceDeletable :one +SELECT EXISTS (SELECT 1 FROM environment_liveness WHERE id = $1 AND ended)::boolean +` + +func (q *Queries) EnvironmentWorkspaceDeletable(ctx context.Context, id pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, environmentWorkspaceDeletable, id) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + const getActiveWorkspaceConfiguration = `-- name: GetActiveWorkspaceConfiguration :one SELECT id, adapter, parameters, active, credential FROM workspace_fs_configurations WHERE active ` @@ -189,7 +200,7 @@ FROM environment_workspaces w JOIN workspace_fs_configurations c ON c.id = w.configuration_id JOIN environments e ON e.id = w.environment_id WHERE w.object_id > $1 AND w.state <> 'deleted' - 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 (e.id IN (SELECT id FROM environment_liveness WHERE ended) 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/deployment/allocation.go b/services/core/internal/deployment/allocation.go index 48456e4b3..ba27ccad2 100644 --- a/services/core/internal/deployment/allocation.go +++ b/services/core/internal/deployment/allocation.go @@ -29,10 +29,11 @@ type Allocation struct { // CreateSettled records evidence that the original Create can no longer // change resources. CreateSettled 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 + // Ended reports that nothing can run on the Environment again: no live + // Session holds it and it is Session-owned or failed or expired. + // AttachedSessionID is the live Session using the Environment, empty when + // none is attached. + Ended bool // Replayed reports that ReserveAllocation returned an existing allocation, // which never authorizes another Create. Replayed bool diff --git a/services/core/internal/deployment/allocations.go b/services/core/internal/deployment/allocations.go index a7e6b3a76..56e5c7569 100644 --- a/services/core/internal/deployment/allocations.go +++ b/services/core/internal/deployment/allocations.go @@ -169,6 +169,15 @@ func (e *ExecutionOperations) ReleaseLostCompute(ctx context.Context, owner Allo return e.cleanup(ctx, owner, computeLost) } +// ReleaseIdleUnattached requests the cleanup of a standalone Environment's +// compute at the deployment idle timeout while no Session is attached, once +// nothing is pending and its initialization is complete. The caller is a +// Provider that cannot checkpoint; with checkpoints an idle Environment +// suspends instead. Any other state is ErrNotIdle. +func (e *ExecutionOperations) ReleaseIdleUnattached(ctx context.Context, owner Allocation) (Allocation, error) { + return e.cleanup(ctx, owner, idleRelease) +} + // cleanupEvidence is the provider proof a cleanup commit consumes. type cleanupEvidence int @@ -176,6 +185,7 @@ const ( cleanupRequested cleanupEvidence = iota creationAbsent computeLost + idleRelease ) func (e *ExecutionOperations) cleanup(ctx context.Context, owner Allocation, evidence cleanupEvidence) (Allocation, error) { @@ -190,6 +200,11 @@ func (e *ExecutionOperations) cleanup(ctx context.Context, owner Allocation, evi if err != nil { return err } + if evidence == idleRelease { + if err := checkIdleUnattached(ctx, tx, current); err != nil { + return err + } + } if lost && (current.State != "running" || current.ComputePhase != "running" || current.ComputeRevision != owner.ComputeRevision || !current.CreateSettled) { return ErrAllocationConflict } @@ -206,7 +221,7 @@ func (e *ExecutionOperations) cleanup(ctx context.Context, owner Allocation, evi if err != nil { return err } - if revoked.Reclaimed { + if revoked.Ended { err = sessions.CancelWork(ctx, tx) } else { retain := false @@ -219,10 +234,12 @@ func (e *ExecutionOperations) cleanup(ctx context.Context, owner Allocation, evi // Compute retention ends independently of the Session only when its // initialized filesystem and native history can continue on new compute. // Explicit archive/reset makes this locked eligibility check false. - if !retain && lost { + // Without a Session there is nothing to settle: the cleanup request + // itself ends a standalone Environment. + if !retain && lost && revoked.AttachedSessionID != "" { return ErrAllocationConflict } - if !retain { + if !retain && revoked.AttachedSessionID != "" { err = sessions.TerminateEnvironment(ctx, tx, revoked.Expired, sessions.ProvisioningFailureReason, sessions.ProvisioningFailure{Category: sessions.ProvisioningComputeReleased}.Detail()) } } @@ -230,7 +247,7 @@ func (e *ExecutionOperations) cleanup(ctx context.Context, owner Allocation, evi return err } pending, err := tx.RequestCleanup(current) - if err != nil || evidence == cleanupRequested { + if err != nil || evidence == cleanupRequested || evidence == idleRelease { result = pending return err } @@ -247,6 +264,32 @@ func (e *ExecutionOperations) cleanup(ctx context.Context, owner Allocation, evi return result, nil } +func checkIdleUnattached(ctx context.Context, tx AllocationCleanupTx, current Allocation) error { + if current.State != "running" || current.ComputePhase != "disabled" || current.AttachedSessionID != "" { + return ErrNotIdle + } + environment, err := tx.LoadEnvironment(ctx) + if err != nil { + return err + } + if environment.Initialization != "complete" { + // The initialization budget fails an unfinished one, which ends it. + return ErrNotIdle + } + activity, err := tx.LoadActivity(current) + if err != nil { + return err + } + seconds, err := tx.LoadIdleSeconds() + if err != nil { + return err + } + if !activity.ReadyToSuspend(time.Duration(seconds) * time.Second) { + return ErrNotIdle + } + return nil +} + // SetCompute commits a compute phase before its external effects. The owner's // revision and the Session lock fence a stale lifecycle observation. Moving // running compute to quiescing requires idle compute and either the idle @@ -406,6 +449,13 @@ func (e *ExecutionOperations) ClearWake(ctx context.Context, owner Allocation, o return e.storage.ClearWake(ctx, id, observed) } +// ReleaseInactivePlacements is the one release of node placements: every +// Environment that can no longer run work gives up the placement it holds +// without compute, whatever ended it. +func (e *ExecutionOperations) ReleaseInactivePlacements(ctx context.Context) error { + return e.storage.ReleaseInactivePlacements(ctx) +} + // change runs apply on the owner's allocation in its Session-locked // transaction, after checkAllocation. func (e *ExecutionOperations) change(ctx context.Context, owner Allocation, live bool, apply func(AllocationTx, Allocation) (Allocation, error)) (Allocation, error) { @@ -442,7 +492,7 @@ func checkAllocation(tx AllocationTx, owner Allocation, live bool) (Allocation, if !live { return current, nil } - if current.Reclaimed { + if current.Ended { return Allocation{}, sessions.ErrNotFound } device, bound, err := tx.LoadEnvironmentDevice() diff --git a/services/core/internal/deployment/allocations_test.go b/services/core/internal/deployment/allocations_test.go index aaf856143..331b5db15 100644 --- a/services/core/internal/deployment/allocations_test.go +++ b/services/core/internal/deployment/allocations_test.go @@ -437,7 +437,7 @@ func TestAllocationChangesCheckTheOwner(t *testing.T) { t.Fatal("a replaced allocation settled", err) } deleted := owner - deleted.Reclaimed = true + deleted.Ended = 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) } @@ -517,7 +517,8 @@ func TestCleanupRevokesSettlesTheSessionThenReleases(t *testing.T) { t.Run(test.name, func(t *testing.T) { var calls []string stored := owner - stored.Reclaimed, stored.Expired = test.deleted, !test.deleted + stored.AttachedSessionID = "session" + stored.Ended, 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) @@ -542,7 +543,7 @@ func TestCleanupRevokesSettlesTheSessionThenReleases(t *testing.T) { if !test.deleted { tx.loadEnvironment = func() (sessions.Environment, error) { calls = append(calls, "LoadEnvironment") - return sessions.Environment{ID: owner.EnvironmentID, Status: "ready"}, nil + return sessions.Environment{ID: owner.EnvironmentID, Status: "ready", AttachedSessionID: "session"}, nil } tx.loadEnvironmentInput = func() (*sessions.EnvironmentInputState, error) { calls = append(calls, "LoadEnvironmentInput") @@ -575,7 +576,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.Reclaimed = deleted + current.Ended = 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) { @@ -623,6 +624,7 @@ func TestReleaseLostComputeRetainsOrChangesNothing(t *testing.T) { t.Run(test.name, func(t *testing.T) { var calls []string stored := owner + stored.AttachedSessionID = "session" test.stored(&stored) change := func(name string, apply func(*Allocation)) func(Allocation) (Allocation, error) { return func(current Allocation) (Allocation, error) { diff --git a/services/core/internal/deployment/fakes_test.go b/services/core/internal/deployment/fakes_test.go index f264a6158..514e3ac7e 100644 --- a/services/core/internal/deployment/fakes_test.go +++ b/services/core/internal/deployment/fakes_test.go @@ -490,6 +490,11 @@ func (f *fakeExecutionStorage) WithAllocationCleanup(ctx context.Context, key Al return f.withAllocationCleanup(ctx, key, apply) } +func (f *fakeExecutionStorage) WithResetEnvironment(context.Context, AllocationKey, func(ResetEnvironmentTx) error) error { + unexpected(f.t, "WithResetEnvironment") + return nil +} + func (f *fakeExecutionStorage) WithSessionArchive(ctx context.Context, tenantID, sessionID string, apply func(context.Context, sessions.LockedSession, SessionArchiveTx) error) error { if f.withSessionArchive == nil { unexpected(f.t, "WithSessionArchive") @@ -497,6 +502,8 @@ func (f *fakeExecutionStorage) WithSessionArchive(ctx context.Context, tenantID, return f.withSessionArchive(ctx, tenantID, sessionID, apply) } +func (f *fakeExecutionStorage) ReleaseInactivePlacements(context.Context) error { return nil } + func (f *fakeExecutionStorage) ClearWake(ctx context.Context, allocationID string, observed time.Time) error { if f.clearWake == nil { unexpected(f.t, "ClearWake") diff --git a/services/core/internal/deployment/reset.go b/services/core/internal/deployment/reset.go index 90da87734..f09bdd71d 100644 --- a/services/core/internal/deployment/reset.go +++ b/services/core/internal/deployment/reset.go @@ -17,8 +17,10 @@ type ResetRequest struct { DeadlineSeconds *int32 `json:"deadline_seconds,omitempty" minimum:"300" maximum:"86400"` } -// ResetSession is a hosted Session a reset still has to archive. -type ResetSession struct{ SessionID, TenantID string } +// ResetSession is a hosted Environment a reset still has to clear: its live +// Session to archive, or, with no SessionID, an unattached standalone +// Environment to expire. +type ResetSession struct{ SessionID, EnvironmentID, TenantID string } const ( // ResetAuto archives idle Sessions and escalates to ResetForce at the diff --git a/services/core/internal/deployment/reset_environment.go b/services/core/internal/deployment/reset_environment.go new file mode 100644 index 000000000..380c993ec --- /dev/null +++ b/services/core/internal/deployment/reset_environment.go @@ -0,0 +1,65 @@ +package deployment + +import ( + "context" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// ResetEnvironmentTx is one reset expiry of a standalone Environment. +type ResetEnvironmentTx interface { + // LoadDeployment locks the deployment and returns it. + LoadDeployment() (Record, error) + LoadEnvironment(ctx context.Context) (sessions.Environment, error) + // FindAllocation returns the Environment's latest allocation. + FindAllocation() (Allocation, bool, error) + ExpireEnvironment(ctx context.Context, environment string) error + // RequestCleanup revokes the allocation's device and requests its cleanup. + RequestCleanup(current Allocation) error +} + +// ExpireResetEnvironment ends an unattached standalone Environment for the +// running reset, which its generation and request time identify. Its +// allocation is revoked and left to the lifecycle's ordinary cleanup; an +// Environment that a Session attached or that already ended is left alone. +func (e *ExecutionOperations) ExpireResetEnvironment(ctx context.Context, key AllocationKey, generation uint64, requestedAt time.Time) error { + key, err := key.parse() + if err != nil { + return err + } + return e.storage.WithResetEnvironment(ctx, key, func(tx ResetEnvironmentTx) error { + d, err := tx.LoadDeployment() + if err != nil { + return err + } + if err := checkArchiveDeployment(d, generation); err != nil { + return err + } + if err := checkArchiveReset(d, requestedAt); err != nil { + return err + } + environment, err := tx.LoadEnvironment(ctx) + if err != nil { + return err + } + if environment.SessionOwned || environment.AttachedSessionID != "" || environment.Status == "failed" || environment.Status == "expired" { + return nil + } + current, allocated, err := tx.FindAllocation() + if err != nil { + return err + } + live := allocated && current.State != "released" + if live && current.ProviderKey != d.InstallationID { + return ErrConflict + } + if err := tx.ExpireEnvironment(ctx, environment.ID); err != nil { + return err + } + if live { + return tx.RequestCleanup(current) + } + return nil + }) +} diff --git a/services/core/internal/deployment/service.go b/services/core/internal/deployment/service.go index 2d4a2648b..0ad1a9040 100644 --- a/services/core/internal/deployment/service.go +++ b/services/core/internal/deployment/service.go @@ -45,7 +45,7 @@ func (s *Service) View(ctx context.Context) (View, error) { // view reports the public URL as the deployment's read-only core_url. func (s *Service) view(snapshot Snapshot) (View, error) { d := snapshot.Record - result := View{InstallationID: d.InstallationID, Provider: d.Provider, CoreURL: s.rules.PublicURL(), OwnerEpoch: d.OwnerEpoch, Generation: d.Generation, Mode: sandbox.DeploymentMode(d.Mode), Rollout: snapshot.Rollout, Resources: snapshot.Resources} + result := View{InstallationID: d.InstallationID, Provider: d.Provider, CoreURL: s.rules.PublicURL(), IdleSeconds: d.IdleSeconds, OwnerEpoch: d.OwnerEpoch, Generation: d.Generation, Mode: sandbox.DeploymentMode(d.Mode), Rollout: snapshot.Rollout, Resources: snapshot.Resources} if len(d.Specification) > 0 && string(d.Specification) != "{}" { var spec sandbox.DeploymentSpec if json.Unmarshal(d.Specification, &spec) == nil { @@ -65,7 +65,7 @@ func (s *Service) view(snapshot Snapshot) (View, error) { result.Configuration = configurationJSON(record.Public) result.Metadata = configurationJSON(record.Metadata) result.CredentialConfigured = d.CredentialStored - if result.Suspension, err = s.suspension(d.Provider, d.IdleSeconds); err != nil { + if result.Suspension, err = s.suspension(d.Provider); err != nil { return View{}, err } } @@ -108,23 +108,18 @@ func (s *Service) setup(d Record) (Setup, error) { if err != nil { return Setup{}, ErrConflict } - result, err = s.describe(result) - if result.Suspension != nil { - result.Suspension.IdleSeconds = d.IdleSeconds - } - return result, err + return s.describe(result) } // describe adds the provider's declared operations, suspension policy and -// credential use. The idle policy lives in the stored deployment row, so a -// setup that is not read from it leaves IdleSeconds zero. +// credential use. func (s *Service) describe(setup Setup) (Setup, error) { adapter, err := s.registry.Lookup(setup.Provider) if err != nil { return Setup{}, err } setup.Operations = adapter.Operations() - if setup.Suspension, err = s.suspension(setup.Provider, 0); err != nil { + if setup.Suspension, err = s.suspension(setup.Provider); err != nil { return Setup{}, err } setup.UsesCredential, err = s.registry.UsesCredential(setup.Provider) @@ -255,14 +250,14 @@ func (s *Service) SetupForSelection(installationID string, input sandbox.Selecti return s.describe(result) } -// suspension returns Core's idle suspension policy for a provider that -// declares checkpoint support, and nil for any other provider. -func (s *Service) suspension(provider string, idleSeconds int64) (*Suspension, error) { +// suspension returns Core's snapshot retention for a provider that declares +// checkpoint support, and nil for any other provider. +func (s *Service) suspension(provider string) (*Suspension, error) { checkpoint, err := s.registry.SupportsCheckpoint(provider) if err != nil || !checkpoint { return nil, err } - return &Suspension{IdleSeconds: idleSeconds, RetentionSeconds: 24 * 60 * 60}, nil + return &Suspension{RetentionSeconds: 24 * 60 * 60}, nil } // validateSelection rejects a selection its provider cannot normalize. @@ -335,13 +330,6 @@ func (s *Service) UpdateIdleSeconds(ctx context.Context, seconds int64) (View, e if d.Reset != nil { return ErrResetInProgress } - supported, err := s.registry.SupportsCheckpoint(d.Provider) - if err != nil { - return err - } - if !supported { - return ErrInvalidInput - } return tx.SaveIdleSeconds(seconds) }) if err != nil { diff --git a/services/core/internal/deployment/session_archive.go b/services/core/internal/deployment/session_archive.go index 22f5d41d6..025fa98f0 100644 --- a/services/core/internal/deployment/session_archive.go +++ b/services/core/internal/deployment/session_archive.go @@ -101,10 +101,6 @@ func (e *ExecutionOperations) archiveSession(ctx context.Context, tenantID, sess if err := tx.RequestArchiveCleanup(current); err != nil { return err } - } else { - if err := tx.ReleasePlacement(); err != nil { - return err - } } if err := tx.RecordArchiveAudit(ctx); err != nil { return err diff --git a/services/core/internal/deployment/session_archive_test.go b/services/core/internal/deployment/session_archive_test.go index 9583fe32c..1731fa128 100644 --- a/services/core/internal/deployment/session_archive_test.go +++ b/services/core/internal/deployment/session_archive_test.go @@ -136,11 +136,6 @@ func (f *fakeArchiveTx) RequestArchiveCleanup(current Allocation) error { return nil } -func (f *fakeArchiveTx) ReleasePlacement() error { - f.record("ReleasePlacement") - return nil -} - func (f *fakeArchiveTx) RecordArchiveAudit(ctx context.Context) error { f.record("RecordArchiveAudit") if source, ok := adminaudit.FromContext(ctx); ok { @@ -250,9 +245,9 @@ func TestArchiveSession(t *testing.T) { }{ {"live allocation", sessions.LockedSession{}, hosted, live, nil, join(head, expire, []string{"RequestArchiveCleanup device allocation", "RecordArchiveAudit", "LoadArchive"})}, - {"no allocation", sessions.LockedSession{}, hosted, nil, nil, join(head, expire, []string{"ReleasePlacement", "RecordArchiveAudit", "LoadArchive"})}, + {"no allocation", sessions.LockedSession{}, hosted, nil, nil, join(head, expire, []string{"RecordArchiveAudit", "LoadArchive"})}, {"released allocation", sessions.LockedSession{}, hosted, &Allocation{ID: "allocation", ProviderKey: "previous", State: "released"}, nil, - join(head, expire, []string{"ReleasePlacement", "RecordArchiveAudit", "LoadArchive"})}, + join(head, expire, []string{"RecordArchiveAudit", "LoadArchive"})}, {"ended Environment", sessions.LockedSession{}, expired, live, nil, join(head, settle, []string{"RequestArchiveCleanup device allocation", "RecordArchiveAudit", "LoadArchive"})}, {"allocation of another installation", sessions.LockedSession{}, hosted, &Allocation{ID: "allocation", ProviderKey: "previous", State: "running"}, ErrConflict, head}, @@ -296,10 +291,10 @@ func TestArchiveResetSession(t *testing.T) { calls []string }{ {"idle Session", resetting(ResetAuto), hosted, false, requested, nil, - []string{"LoadDeployment", "LoadEnvironment", "LoadResetBusy", "LoadResetSource", "LoadProject", "FindAllocation environment", "LoadEnvironmentInput", "ExpireEnvironment environment", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput", "ReleasePlacement", "RecordArchiveAudit", "LoadArchive"}}, + []string{"LoadDeployment", "LoadEnvironment", "LoadResetBusy", "LoadResetSource", "LoadProject", "FindAllocation environment", "LoadEnvironmentInput", "ExpireEnvironment environment", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput", "RecordArchiveAudit", "LoadArchive"}}, {"busy Session", resetting(ResetAuto), hosted, true, requested, ErrSandboxResetSessionBusy, []string{"LoadDeployment", "LoadEnvironment", "LoadResetBusy"}}, {"forced busy Session", resetting(ResetForce), hosted, true, requested, nil, - []string{"LoadDeployment", "LoadEnvironment", "LoadResetSource", "LoadProject", "FindAllocation environment", "LoadEnvironmentInput", "ExpireEnvironment environment", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput", "ReleasePlacement", "RecordArchiveAudit", "LoadArchive"}}, + []string{"LoadDeployment", "LoadEnvironment", "LoadResetSource", "LoadProject", "FindAllocation environment", "LoadEnvironmentInput", "ExpireEnvironment environment", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput", "RecordArchiveAudit", "LoadArchive"}}, {"ended Environment", resetting(ResetAuto), failed, false, requested, nil, []string{"LoadDeployment", "LoadEnvironment", "LoadResetBusy", "LoadArchive"}}, {"another reset", resetting(ResetAuto), hosted, false, requested.Add(time.Second), ErrConflict, []string{"LoadDeployment", "LoadEnvironment"}}, } { diff --git a/services/core/internal/deployment/setup.go b/services/core/internal/deployment/setup.go index dae24b6c7..257d32159 100644 --- a/services/core/internal/deployment/setup.go +++ b/services/core/internal/deployment/setup.go @@ -21,7 +21,7 @@ type Setup struct { // transport proxies. Operations providercontract.Operations Configuration sandbox.Configuration `json:"-"` - // Suspension is the idle suspension policy of a provider that declares + // Suspension is the snapshot retention of a provider that declares // checkpoint support, and nil otherwise. Suspension *Suspension // UsesCredential reports whether the provider's configuration carries a diff --git a/services/core/internal/deployment/storage.go b/services/core/internal/deployment/storage.go index fd939a803..d9633453d 100644 --- a/services/core/internal/deployment/storage.go +++ b/services/core/internal/deployment/storage.go @@ -62,9 +62,15 @@ type ExecutionStorage interface { // It commits only when both succeed. A malformed tenant is // ErrInvalidInput; a malformed or missing Session is sessions.ErrNotFound. WithSessionArchive(ctx context.Context, tenantID, sessionID string, apply func(context.Context, sessions.LockedSession, SessionArchiveTx) error) error + // WithResetEnvironment runs apply in one leased transaction that locks + // the Environment and its attached Session, if any. + WithResetEnvironment(ctx context.Context, key AllocationKey, apply func(ResetEnvironmentTx) error) error // ClearWake clears the allocation's wake request unless activity newer // than observed arrived. It runs on the lease. ClearWake(ctx context.Context, allocationID string, observed time.Time) error + // ReleaseInactivePlacements releases the placements of Environments that + // can no longer run work and hold no compute. It runs on the lease. + ReleaseInactivePlacements(ctx context.Context) error } // AllocationCleanupStorage runs allocation cleanup, which settles the @@ -193,9 +199,6 @@ type SessionArchiveTx interface { // Session's active Turn whose cancellation was requested, which the // archived cancellation receipt reports. RequestArchiveCleanup(current Allocation) error - // ReleasePlacement releases the node placement of the Session's - // Environments that have no allocation. - ReleasePlacement() error // RecordArchiveAudit records the administrator audit of the archive with // the source on ctx. RecordArchiveAudit(ctx context.Context) error @@ -229,10 +232,12 @@ type Reader interface { NodeHistory(ctx context.Context, nodeID string, window coremetrics.Range) (NodeRecord, []HostHistoryPoint, error) // ReadNodes runs apply in one read-only snapshot. ReadNodes(ctx context.Context, apply func(NodeReads) error) error - // ResetSessions returns up to 32 hosted Sessions after the given Session - // ID, in ID order, that still hold or await deployment resources. Without - // force it skips Sessions with a running Turn or a pending file write. An - // empty after starts at the beginning; a malformed one is ErrInvalidInput. + // ResetSessions returns up to 32 hosted Environments after the given + // Environment ID, in ID order, that still hold or await deployment + // resources: each with its live Session, or unattached and standalone. + // Without force it skips those with a running Turn or a pending file + // write. An empty after starts at the beginning; a malformed one is + // ErrInvalidInput. ResetSessions(ctx context.Context, after string, force bool) ([]ResetSession, error) // AddressBindings counts what is bound to an installation address, read // in one snapshot. publicURL is the address nodes are compared against. diff --git a/services/core/internal/deployment/view.go b/services/core/internal/deployment/view.go index 253fa0da0..4fc54a157 100644 --- a/services/core/internal/deployment/view.go +++ b/services/core/internal/deployment/view.go @@ -34,7 +34,9 @@ type View struct { Configuration json.RawMessage `json:"configuration,omitempty" swaggertype:"object"` Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` CredentialConfigured bool `json:"credential_configured" binding:"required"` - // Idle suspension policy; null unless the selected Provider declares checkpoint support. + // Idle timeout for every Provider. With checkpoint support an idle sandbox suspends; without it an idle unattached standalone Environment expires. + IdleSeconds int64 `json:"idle_seconds" binding:"required"` + // Snapshot retention; null unless the selected Provider declares checkpoint support. Suspension *Suspension `json:"suspension" extensions:"x-nullable" binding:"required"` InstallationID string `json:"installation_id" binding:"required"` Provider string `json:"provider" binding:"required"` @@ -52,10 +54,9 @@ type Resources struct { Pending int64 `json:"pending" binding:"required"` } -// Suspension is Core's idle suspension policy, which applies to every Provider -// that declares checkpoint support. +// Suspension is Core's snapshot retention, which applies to every Provider that +// declares checkpoint support. type Suspension struct { - IdleSeconds int64 `json:"idle_seconds" binding:"required"` RetentionSeconds int64 `json:"retention_seconds" binding:"required"` } diff --git a/services/core/internal/execution/deployment_fixture_test.go b/services/core/internal/execution/deployment_fixture_test.go index 2f4c2b0f1..e1afdb0dd 100644 --- a/services/core/internal/execution/deployment_fixture_test.go +++ b/services/core/internal/execution/deployment_fixture_test.go @@ -130,6 +130,7 @@ type strictExecutionStorage struct { withAllocation func(context.Context, deployment.AllocationKey, func(deployment.AllocationTx) error) error withAllocationCleanup func(context.Context, deployment.AllocationKey, func(deployment.AllocationCleanupTx) error) error clearWake func(context.Context, string, time.Time) error + releasedInactive int } func (s *strictExecutionStorage) WithDeployment(ctx context.Context, apply func(deployment.DeploymentTx) error) error { @@ -264,10 +265,19 @@ func (s *strictExecutionStorage) WithAllocationCleanup(ctx context.Context, key return s.withAllocationCleanup(ctx, key, apply) } +func (s *strictExecutionStorage) WithResetEnvironment(context.Context, deployment.AllocationKey, func(deployment.ResetEnvironmentTx) error) error { + return unexpectedDeploymentCall(s.t, "WithResetEnvironment") +} + func (s *strictExecutionStorage) WithSessionArchive(context.Context, string, string, func(context.Context, sessions.LockedSession, deployment.SessionArchiveTx) error) error { return unexpectedDeploymentCall(s.t, "WithSessionArchive") } +func (s *strictExecutionStorage) ReleaseInactivePlacements(context.Context) error { + s.releasedInactive++ + return nil +} + func (s *strictExecutionStorage) ClearWake(ctx context.Context, allocationID string, observed time.Time) error { if s.clearWake == nil { return unexpectedDeploymentCall(s.t, "ClearWake") diff --git a/services/core/internal/execution/environment_admission.go b/services/core/internal/execution/environment_admission.go index 266591c6a..89c0ec099 100644 --- a/services/core/internal/execution/environment_admission.go +++ b/services/core/internal/execution/environment_admission.go @@ -60,7 +60,8 @@ func (w *Worker) validateCreation(ctx context.Context, input sessions.CreateSess if err := json.Unmarshal(input.Configuration, &snapshot); err != nil { return sessions.ErrInvalidInput } - if snapshot.Environment.Type == "openai_hosted" { + // An attached Environment passed workspace and network admission when it was created. + if snapshot.Environment.Type == "openai_hosted" && input.EnvironmentID == "" { if err := w.runtimes.validateWorkspaceAdmission(ctx); err != nil { return err } diff --git a/services/core/internal/execution/network_egress.go b/services/core/internal/execution/network_egress.go index 6e6dc8b47..29f7462c1 100644 --- a/services/core/internal/execution/network_egress.go +++ b/services/core/internal/execution/network_egress.go @@ -101,6 +101,23 @@ func egressFor(network sandbox.Network, policy *v1.EnvironmentNetworkInput, mode return egress, nil } +// egressCovers reports whether an allocation created with the Egress, which +// names no model endpoint, reaches the model provider at modelBaseURL: a +// loopback host is the sandbox itself, and any other host needs the Egress to +// allow it by name or, for a public address, as a public destination. +func egressCovers(created sandbox.Egress, modelBaseURL string) bool { + u, err := url.Parse(modelBaseURL) + if err != nil || u.Hostname() == "" { + return true + } + host := strings.ToLower(u.Hostname()) + if loopbackHost(host) || inCap(created.PrivateDomains, host) || inCap(created.Domains, host) { + return true + } + ip, err := netip.ParseAddr(host) + return created.Public && (err != nil || sandbox.PublicAddress(ip.Unmap())) +} + // inCap reports whether the cap lists name exactly or as a subdomain through a // "*.suffix" entry; the suffix itself is not covered. func inCap(domains []string, name string) bool { diff --git a/services/core/internal/execution/network_egress_test.go b/services/core/internal/execution/network_egress_test.go index 8a0c8cd7f..8babd8761 100644 --- a/services/core/internal/execution/network_egress_test.go +++ b/services/core/internal/execution/network_egress_test.go @@ -106,3 +106,39 @@ func TestAdmissionNeedsProviderSupport(t *testing.T) { t.Fatal(err) } } + +func TestEgressCoversTheModelProviderOfAnAttachingSession(t *testing.T) { + cap := sandbox.Network{Public: true, Domains: []string{"model.internal", "*.corp.example"}} + enabled, err := egressFor(cap, nil, "") + if err != nil { + t.Fatal(err) + } + disabled, err := egressFor(cap, &v1.EnvironmentNetworkInput{Access: "disabled"}, "") + if err != nil { + t.Fatal(err) + } + restricted, err := egressFor(cap, &v1.EnvironmentNetworkInput{Access: "restricted", AllowedDomains: []string{"api.example.com"}}, "") + if err != nil { + t.Fatal(err) + } + for name, test := range map[string]struct { + created sandbox.Egress + url string + want bool + }{ + "public host, public network": {enabled, "https://api.example.com/v1", true}, + "private IP, public network": {enabled, "http://10.0.0.8:8080", false}, + "cap-listed host, public network": {enabled, "http://model.internal/v1", true}, + "cap wildcard, public network": {enabled, "http://llm.corp.example/v1", true}, + "cap-listed host under disabled": {disabled, "http://model.internal/v1", false}, + "public host under disabled": {disabled, "https://api.example.com/v1", false}, + "loopback under disabled": {disabled, "http://127.0.0.1:9000", true}, + "allowed domain under restricted": {restricted, "https://api.example.com/v1", true}, + "other host under restricted": {restricted, "https://other.example.com/v1", false}, + "no model provider": {disabled, "", true}, + } { + if got := egressCovers(test.created, test.url); got != test.want { + t.Errorf("%s: covers = %v, want %v", name, got, test.want) + } + } +} diff --git a/services/core/internal/execution/runtime_compute.go b/services/core/internal/execution/runtime_compute.go index 2ce05c5f1..18b7f3ef9 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.Reclaimed || owner.Expired || owner.State == "cleanup_pending" { + if owner.Ended || owner.Expired || owner.State == "cleanup_pending" { return r.cleanupCompute(ctx, p, owner, state) } if err := r.lease.CheckOwnership(ctx); err != nil { @@ -227,12 +227,15 @@ func (r *runtimeLifecycle) releaseLostCompute(ctx context.Context, p sandbox.San if current.ID != owner.ID || current.State != owner.State || current.ComputePhase != owner.ComputePhase || current.ComputeRevision != owner.ComputeRevision { return deployment.ErrAllocationConflict } - retained, err := r.reader.RetainedNativeHistory(ctx, owner.Key()) - if err != nil { - return err - } - if !retained { - return observed + // An unattached standalone Environment is never replaced: its confirmed loss expires it. + if owner.AttachedSessionID != "" { + retained, err := r.reader.RetainedNativeHistory(ctx, owner.Key()) + if err != nil { + return err + } + if !retained { + return observed + } } if err := r.lease.CheckOwnership(ctx); err != nil { return err diff --git a/services/core/internal/execution/runtime_compute_wake.go b/services/core/internal/execution/runtime_compute_wake.go index 7d9087d14..8c3d89b1d 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.Reclaimed { + if owner.Ended { return ErrExecutionUnavailable } if owner.State == "released" || owner.State == "cleanup_pending" || owner.Expired { diff --git a/services/core/internal/execution/runtime_connections.go b/services/core/internal/execution/runtime_connections.go index efd72394a..2971c4e03 100644 --- a/services/core/internal/execution/runtime_connections.go +++ b/services/core/internal/execution/runtime_connections.go @@ -24,16 +24,18 @@ type runtimeConnection struct { } func (r *runtimeLifecycle) observeConnection(ctx context.Context, owner deployment.Allocation) error { - bound, err := r.sessions.GetSessionRuntimeDevice(ctx, owner.TenantID, owner.AttachedSessionID) - if errors.Is(err, sessions.ErrNotFound) { - r.forgetAwaitingConnection(owner.DeviceID) - return nil - } - if err != nil { - return err - } - if bound.ID != owner.DeviceID || bound.EnvironmentID != owner.EnvironmentID { - return sessions.ErrDeviceBindingConflict + if owner.AttachedSessionID != "" { + bound, err := r.sessions.GetSessionRuntimeDevice(ctx, owner.TenantID, owner.AttachedSessionID) + if errors.Is(err, sessions.ErrNotFound) { + r.forgetAwaitingConnection(owner.DeviceID) + return nil + } + if err != nil { + return err + } + if bound.ID != owner.DeviceID || bound.EnvironmentID != owner.EnvironmentID { + return sessions.ErrDeviceBindingConflict + } } if !owner.CreateSettled || owner.State != "running" { r.forgetAwaitingConnection(owner.DeviceID) @@ -138,7 +140,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.Reclaimed && !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.Ended && !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_lifecycle.go b/services/core/internal/execution/runtime_lifecycle.go index 965d38e7f..2bc9fd470 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.Reclaimed || owner.Expired || owner.State == "cleanup_pending" { + if owner.Ended || owner.Expired || owner.State == "cleanup_pending" { var err error owner, err = r.deployment.RequestCleanup(ctx, owner) if err != nil { @@ -410,6 +410,17 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner deployment.Allocat if owner.ComputePhase != "disabled" { return r.observeCompute(ctx, owner) } + if owner.AttachedSessionID == "" && owner.State == "running" && owner.CreateSettled && r.config.Suspension == nil { + // Without checkpoints, an idle unattached Environment requests its + // compute release at the idle timeout. + released, err := r.deployment.ReleaseIdleUnattached(ctx, owner) + if err == nil { + return r.observe(ctx, released) + } + if !errors.Is(err, deployment.ErrNotIdle) && !errors.Is(err, deployment.ErrAllocationConflict) { + return err + } + } provider := r.config.Provider if owner.ProviderKey != r.config.InstallationID { return sandbox.ErrInvalid @@ -418,7 +429,7 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner deployment.Allocat return err } info, err := provider.GetInfo(ctx, runtimeReference(owner)) - cleanup := owner.Reclaimed || owner.Expired || owner.State == "cleanup_pending" + cleanup := owner.Ended || 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 +449,7 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner deployment.Allocat return err } } - if owner.Reclaimed || owner.Expired || owner.State == "cleanup_pending" { + if owner.Ended || 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_manager_test.go b/services/core/internal/execution/runtime_manager_test.go index 1e991a193..5d7b1e098 100644 --- a/services/core/internal/execution/runtime_manager_test.go +++ b/services/core/internal/execution/runtime_manager_test.go @@ -23,7 +23,8 @@ func (heldLease) Close(context.Context) error { return nil } func testRuntimeManager(t *testing.T) *runtimeManager { t.Helper() ctx, cancel := context.WithCancel(t.Context()) - m := &runtimeManager{lease: heldLease{}, config: RuntimeProvider{ProviderKind: "microsandbox", Mode: "nodes", Provider: &drainFixtureProvider{}}, loadDeployment: func(context.Context) (*RuntimeProvider, error) { return nil, nil }, ctx: ctx, cancel: cancel, nodes: make(map[string]*runtimeNode), failed: make(chan error, 1), inventory: make(chan struct{}, 1)} + _, operations := deploymentOperations(t, &strictDeploymentStorage{t: t}, &strictDeploymentReader{t: t}, &strictExecutionStorage{t: t}) + m := &runtimeManager{deployment: operations, lease: heldLease{}, config: RuntimeProvider{ProviderKind: "microsandbox", Mode: "nodes", Provider: &drainFixtureProvider{}}, loadDeployment: func(context.Context) (*RuntimeProvider, error) { return nil, nil }, ctx: ctx, cancel: cancel, nodes: make(map[string]*runtimeNode), failed: make(chan error, 1), inventory: make(chan struct{}, 1)} t.Cleanup(func() { m.stop(); m.drain() }) return m } diff --git a/services/core/internal/execution/runtime_pending.go b/services/core/internal/execution/runtime_pending.go index fc2f3f6a7..0f219d39d 100644 --- a/services/core/internal/execution/runtime_pending.go +++ b/services/core/internal/execution/runtime_pending.go @@ -66,6 +66,14 @@ func (m *runtimeManager) reservePlacements(ctx context.Context) error { } operation, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() + // The one release of placements: an Environment that can no longer run work + // gives up the placement it holds without compute, freeing the slot for demand. + if err := m.deployment.ReleaseInactivePlacements(operation); err != nil { + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { + return m.lease.CheckOwnership(ctx) + } + return err + } rows, next, err := m.deploymentReader.PlacementDemand(operation, m.placementCursor) if err != nil { // A bounded inventory read may time out while the execution owner is diff --git a/services/core/internal/execution/runtime_pending_test.go b/services/core/internal/execution/runtime_pending_test.go index 6d4438245..826d7dd7b 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.Reclaimed = true + owner.Ended = true case "expired": owner.Expired = true case "unsettled": diff --git a/services/core/internal/execution/runtime_placement_demand_test.go b/services/core/internal/execution/runtime_placement_demand_test.go index 40255bb9c..86cd1b194 100644 --- a/services/core/internal/execution/runtime_placement_demand_test.go +++ b/services/core/internal/execution/runtime_placement_demand_test.go @@ -183,3 +183,20 @@ func TestFreedSlotWakesPlacement(t *testing.T) { t.Fatal("freed slot did not wake placement") } } + +// Placement reservation first releases the placements of inactive +// Environments, so their slots serve the demand it reads next. +func TestPlacementReservationReleasesInactivePlacementsFirst(t *testing.T) { + m := testRuntimeManager(t) + storage := &strictExecutionStorage{t: t} + _, m.deployment = deploymentOperations(t, &strictDeploymentStorage{t: t}, &strictDeploymentReader{t: t}, storage) + m.deploymentReader = &strictDeploymentReader{t: t, placementDemand: func(context.Context, deployment.PlacementDemandCursor) ([]deployment.PlacementDemand, deployment.PlacementDemandCursor, error) { + if storage.releasedInactive != 1 { + t.Fatal("demand read before the release", storage.releasedInactive) + } + return nil, deployment.PlacementDemandCursor{}, nil + }} + if err := m.reservePlacements(t.Context()); err != nil || storage.releasedInactive != 1 { + t.Fatal("inactive placements not released", storage.releasedInactive, err) + } +} diff --git a/services/core/internal/execution/runtime_wake_hint.go b/services/core/internal/execution/runtime_wake_hint.go index ce5f14384..677811d55 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.Reclaimed || owner.Expired { + !owner.CreateSettled || owner.Ended || owner.Expired { return } switch owner.ComputePhase { diff --git a/services/core/internal/execution/sandbox_reset.go b/services/core/internal/execution/sandbox_reset.go index 241d452b0..63110f0de 100644 --- a/services/core/internal/execution/sandbox_reset.go +++ b/services/core/internal/execution/sandbox_reset.go @@ -102,11 +102,16 @@ func (m *runtimeManager) resetPage(parent, ctx context.Context) error { if ctx.Err() != nil { return nil } - _, err := m.deployment.ArchiveResetSession(ctx, candidate.TenantID, candidate.SessionID, current.Generation, current.Reset.RequestedAt) - m.resetCursor = candidate.SessionID + var err error + if candidate.SessionID == "" { + err = m.deployment.ExpireResetEnvironment(ctx, deployment.AllocationKey{TenantID: candidate.TenantID, EnvironmentID: candidate.EnvironmentID}, current.Generation, current.Reset.RequestedAt) + } else { + _, err = m.deployment.ArchiveResetSession(ctx, candidate.TenantID, candidate.SessionID, current.Generation, current.Reset.RequestedAt) + } + m.resetCursor = candidate.EnvironmentID if err != nil && !errors.Is(err, deployment.ErrSandboxResetSessionBusy) && !errors.Is(err, sessions.ErrNotFound) { // Do not log a provider body, request, credential or stored provenance. - log.Warn(ctx, "Sandbox reset archive remains pending", "session_id", candidate.SessionID) + log.Warn(ctx, "Sandbox reset archive remains pending", "session_id", candidate.SessionID, "environment_id", candidate.EnvironmentID) } } if len(candidates) < 32 { diff --git a/services/core/internal/execution/worker.go b/services/core/internal/execution/worker.go index 8764f4222..b77c5cacd 100644 --- a/services/core/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -8,8 +8,12 @@ import ( "sync" "time" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) @@ -169,13 +173,89 @@ func (w *Worker) CreateSession(ctx context.Context, tenant string, input session return sessions.Creation{}, sessions.ErrInvalidInput } input.SupportsRetainedNativeHistory = declaration.Capabilities.RetainedNativeHistory.IsSupported() + if input.EnvironmentID != "" { + if err := w.validateAttach(ctx, tenant, input); err != nil { + return sessions.Creation{}, err + } + } creation, err := w.dispatcher.Sessions.CreateSession(ctx, tenant, input) if err == nil { w.wakeScheduler() + if input.EnvironmentID != "" && creation.Created { + // The Environment's compute may be suspended; the Session's first input needs it. + w.hintRuntimeWake(ctx, creation.Session) + } } return creation, err } +// validateAttach checks the Session against what its standalone Environment +// was created with: the generation it runs on must keep the Harness's native +// history, and its Egress, which knows no model provider, must reach the +// Session's model provider. +func (w *Worker) validateAttach(ctx context.Context, tenant string, input sessions.CreateSession) error { + target, err := w.runtimes.deploymentReader.LifecyclePlacement(ctx, deployment.AllocationKey{TenantID: tenant, EnvironmentID: input.EnvironmentID}) + if err != nil { + return err + } + var spec sandbox.DeploymentSpec + var snapshot Snapshot + if json.Unmarshal(target.Specification, &spec) != nil || json.Unmarshal(input.Configuration, &snapshot) != nil { + return sessions.ErrInvalidInput + } + if err := sessions.ValidateRetainedHistory(spec.Workspace != nil, input.SupportsRetainedNativeHistory); err != nil { + return err + } + if input.ModelProvider == nil { + return nil + } + created, err := egressFor(spec.Network, snapshot.Environment.Network, "") + if err != nil { + return err + } + if !egressCovers(created, input.ModelProvider.BaseURL) { + return &sandbox.ConfigurationError{Class: sandbox.ConfigurationInvalid, Code: "model_provider_unreachable", Param: "environment.environment_id", + Message: "The environment's network policy does not allow this session's model provider."} + } + return nil +} + +// CreateEnvironment validates that the deployment can host a standalone +// Environment before the pooled creation, and wakes the scheduler once it +// commits. +func (w *Worker) CreateEnvironment(ctx context.Context, tenant string, input sessions.CreateEnvironment) (string, error) { + var environment v1.Environment + if err := json.Unmarshal(input.Configuration, &environment); err != nil { + return "", sessions.ErrInvalidInput + } + ready, err := w.runtimes.ensureDeployment(ctx) + if err != nil { + return "", err + } + if !ready { + return "", ErrExecutionUnavailable + } + if err := w.runtimes.validateWorkspaceAdmission(ctx); err != nil { + return "", err + } + if err := w.runtimes.validateNetworkAdmission(environment.Network); err != nil { + return "", err + } + if err := w.checkAdmissionOwnership(ctx); err != nil { + return "", err + } + id, err := w.dispatcher.Sessions.CreateEnvironment(ctx, tenant, input) + if err == nil { + w.wakeScheduler() + } + return id, err +} + +// ReplayEnvironment returns the Environment a retry's key already created. +func (w *Worker) ReplayEnvironment(ctx context.Context, tenant string, creator identity.Subject, key string, request json.RawMessage) (string, bool, error) { + return w.dispatcher.Sessions.ReplayEnvironment(ctx, tenant, creator, key, request) +} + // Run retains queued work across restarts, but never replays an uncertain claim. func (w *Worker) Run(ctx context.Context) (runErr error) { defer w.stopOnce.Do(func() { close(w.stopped) }) diff --git a/services/core/internal/execution/worker_device.go b/services/core/internal/execution/worker_device.go index de616de19..5ec7ff235 100644 --- a/services/core/internal/execution/worker_device.go +++ b/services/core/internal/execution/worker_device.go @@ -122,7 +122,8 @@ func (w *Worker) bindSessionDevice(ctx context.Context, session sessions.Session } // harnessUnavailable reports whether the Runtime bound to the Session is -// connected and reports the Session's Harness missing or unavailable. +// connected and reports the Session's Harness missing or unavailable, or lacks +// the retained native history the Environment requires. func (w *Worker) harnessUnavailable(ctx context.Context, tenantID, sessionID string) (bool, error) { session, err := w.dispatcher.SessionsReader.GetSession(ctx, tenantID, sessionID) if err != nil { @@ -137,7 +138,16 @@ func (w *Worker) harnessUnavailable(ctx context.Context, tenantID, sessionID str return false, nil } info, found, known := peer.AgentKindStatus(session.Engine) - return known && (!found || !info.Available), nil + if !known || !found || !info.Available { + return known, nil + } + environment, err := w.dispatcher.SessionsReader.GetSessionEnvironment(ctx, tenantID, sessionID) + if err != nil { + return false, nilIfNotFound(err) + } + // A Runtime without retained native history cannot serve an Environment + // that keeps its history outside compute. + return w.dispatcher.validateEnvironmentHistory(session.Engine, environment, info.Capabilities.RetainedNativeHistory.IsSupported()) != nil, nil } func nilIfNotFound(err error) error { diff --git a/services/core/internal/oauthrefresh/network.go b/services/core/internal/oauthrefresh/network.go index 27fd6c59c..17706ee16 100644 --- a/services/core/internal/oauthrefresh/network.go +++ b/services/core/internal/oauthrefresh/network.go @@ -8,6 +8,8 @@ import ( "net/url" "strings" "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) func parseEndpoint(raw string) (*url.URL, error) { @@ -60,7 +62,7 @@ func (p *networkPolicy) dialContext(ctx context.Context, network, address string // service after validation. HTTPS still verifies the original hostname. for _, item := range addresses { ip, ok := netip.AddrFromSlice(item.IP) - if !ok || item.Zone != "" || (!trusted && !publicAddress(ip.Unmap())) { + if !ok || item.Zone != "" || (!trusted && !sandbox.PublicAddress(ip.Unmap())) { return nil, ErrRefresh } } @@ -75,10 +77,3 @@ func (p *networkPolicy) dialContext(ctx context.Context, network, address string } return nil, ErrRefresh } - -var sharedAddressSpace = netip.MustParsePrefix("100.64.0.0/10") - -func publicAddress(ip netip.Addr) bool { - return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && - !ip.IsLinkLocalUnicast() && !sharedAddressSpace.Contains(ip) -} diff --git a/services/core/internal/persistence/postgres/deploymentpg/allocations.go b/services/core/internal/persistence/postgres/deploymentpg/allocations.go index 12ff5dac3..903739afa 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/allocations.go +++ b/services/core/internal/persistence/postgres/deploymentpg/allocations.go @@ -18,15 +18,15 @@ import ( ) // 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 { +// its tenant, attached Session, if any, and whether it has ended. +func allocation(row sqlc.RuntimeAllocation, session, tenant pgtype.UUID, ended, 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), AttachedSessionID: uuidString(session), TenantID: uuidString(tenant), DeviceID: uuidString(row.DeviceID), ProviderKey: uuidString(row.ProviderKey), State: row.State, CreateSettled: row.CreateSettled, - Reclaimed: reclaimed, Expired: expired, CreatedAt: row.CreatedAt.Time, + Ended: ended, Expired: expired, CreatedAt: row.CreatedAt.Time, } } @@ -41,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.AttachedSessionID, row.TenantID, row.Reclaimed, row.Expired), true, nil + return allocation(row.RuntimeAllocation, row.AttachedSessionID, row.TenantID, row.Ended, row.Expired), true, nil } func allocationKey(key deployment.AllocationKey) (pgtype.UUID, pgtype.UUID, error) { @@ -93,7 +93,7 @@ func (e *Execution) WithAllocationCleanup(ctx context.Context, key deployment.Al } // withAllocation locks the Environment of the allocation and its attached -// Session, reclaimed or not, runs apply and prunes the Session's journal. +// Session, ended 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 { @@ -109,6 +109,12 @@ func (e *Execution) withAllocation(ctx context.Context, key deployment.Allocatio })) } +func (e *Execution) ReleaseInactivePlacements(ctx context.Context) error { + return e.lease.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + return sqlc.New(tx).ReleaseInactiveRuntimePlacements(ctx) + }) +} + func (e *Execution) ClearWake(ctx context.Context, allocationID string, observed time.Time) error { id, err := parseID(allocationID) if err != nil { @@ -178,6 +184,20 @@ type reservationTx struct { tenant, environment, session pgtype.UUID } +// LoadEnvironment and LoadBoundDevice read by Environment: a standalone +// Environment reserves compute with no Session. +func (t *reservationTx) LoadEnvironment(ctx context.Context) (sessions.Environment, error) { + return sessionpg.LoadEnvironment(ctx, t.q, uuidString(t.tenant), uuidString(t.environment)) +} + +func (t *reservationTx) LoadBoundDevice(ctx context.Context) (bool, error) { + _, err := t.q.GetEnvironmentDevice(ctx, sqlc.GetEnvironmentDeviceParams{TenantID: t.tenant, EnvironmentID: t.environment}) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return err == nil, err +} + func (t *reservationTx) FindAllocation() (deployment.Allocation, bool, error) { return findAllocation(t.ctx, t.q, t.tenant, t.environment) } @@ -376,7 +396,7 @@ func (t *allocationTx) change(current deployment.Allocation, write func(context. if err != nil { return deployment.Allocation{}, err } - return allocation(row, t.session, t.tenant, current.Reclaimed, current.Expired), nil + return allocation(row, t.session, t.tenant, current.Ended, current.Expired), nil } // cleanupTx is one allocation cleanup, with the owning Session's cancellation @@ -394,6 +414,12 @@ func (t *allocationTx) CanRetainEnvironment(current deployment.Allocation) (bool return t.q.CanRetainRuntimeEnvironment(t.ctx, id) } +// LoadEnvironment reads the allocation's Environment, which has no attached +// Session while it is standalone and idle. +func (t *cleanupTx) LoadEnvironment(ctx context.Context) (sessions.Environment, error) { + return sessionpg.LoadEnvironment(ctx, t.q, uuidString(t.tenant), uuidString(t.environment)) +} + func (t *cleanupTx) RevokeDevice(current deployment.Allocation) error { device, err := parseID(current.DeviceID) if err != nil { @@ -403,8 +429,14 @@ func (t *cleanupTx) RevokeDevice(current deployment.Allocation) error { return err } +// RequestCleanup is the one commit that removes an allocation's compute, so it +// is also where an unattached standalone Environment ends. func (t *cleanupTx) RequestCleanup(current deployment.Allocation) (deployment.Allocation, error) { - return t.change(current, t.q.RequestRuntimeCleanup) + requested, err := t.change(current, t.q.RequestRuntimeCleanup) + if err != nil { + return deployment.Allocation{}, err + } + return requested, t.q.EndUnattachedStandaloneEnvironment(t.ctx, t.environment) } func loadActivity(ctx context.Context, q *sqlc.Queries, id pgtype.UUID) (deployment.Activity, error) { @@ -441,7 +473,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.AttachedSessionID, row.TenantID, row.Reclaimed, row.Expired)) + result = append(result, allocation(row.RuntimeAllocation, row.AttachedSessionID, row.TenantID, row.Ended, row.Expired)) } return result, nil } @@ -551,7 +583,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.AttachedSessionID, row.TenantID, row.Reclaimed, row.Expired)) + result = append(result, allocation(row.RuntimeAllocation, row.AttachedSessionID, row.TenantID, row.Ended, row.Expired)) } return result, nil } diff --git a/services/core/internal/persistence/postgres/deploymentpg/idle_policy_test.go b/services/core/internal/persistence/postgres/deploymentpg/idle_policy_test.go index 52d700efa..ad9998601 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/idle_policy_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/idle_policy_test.go @@ -17,8 +17,8 @@ func TestIdlePolicyChangesCurrentAllocationsWithoutRollout(t *testing.T) { node := f.enroll(t, before, deployment.Capacity{MaxActive: 1, MaxRetained: 4}) f.connect(t, node.NodeID) owner := runningPressureOwner(t, f, changes, installation, node.NodeID, before.Generation) - if before.Suspension.IdleSeconds != 300 { - t.Fatal(before.Suspension) + if before.IdleSeconds != 300 { + t.Fatal(before.IdleSeconds) } until := time.Now().Add(time.Hour) if _, err := changes.SetCompute(t.Context(), owner, "quiescing", json.RawMessage(`{}`), &until); !errors.Is(err, deployment.ErrNotIdle) { @@ -32,7 +32,7 @@ func TestIdlePolicyChangesCurrentAllocationsWithoutRollout(t *testing.T) { // A longer saved value is also applied to the existing allocation. for _, seconds := range []int64{86400, 30} { after, err := f.service.UpdateIdleSeconds(admin(t), seconds) - if err != nil || after.Suspension.IdleSeconds != seconds || after.Generation != before.Generation || after.SpecificationDigest != before.SpecificationDigest || after.OwnerEpoch != before.OwnerEpoch || after.Suspension.RetentionSeconds != before.Suspension.RetentionSeconds { + if err != nil || after.IdleSeconds != seconds || after.Generation != before.Generation || after.SpecificationDigest != before.SpecificationDigest || after.OwnerEpoch != before.OwnerEpoch || after.Suspension.RetentionSeconds != before.Suspension.RetentionSeconds { t.Fatal(after, err) } _, err = changes.SetCompute(t.Context(), owner, "quiescing", json.RawMessage(`{}`), &until) diff --git a/services/core/internal/persistence/postgres/deploymentpg/reset_environment.go b/services/core/internal/persistence/postgres/deploymentpg/reset_environment.go new file mode 100644 index 000000000..3965ecda6 --- /dev/null +++ b/services/core/internal/persistence/postgres/deploymentpg/reset_environment.go @@ -0,0 +1,41 @@ +package deploymentpg + +import ( + "context" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/jackc/pgx/v5/pgtype" +) + +func (e *Execution) WithResetEnvironment(ctx context.Context, key deployment.AllocationKey, apply func(deployment.ResetEnvironmentTx) error) error { + tenant, environment, err := allocationKey(key) + if err != nil { + return err + } + return translate(sessionpg.WithEnvironment(ctx, e.lease, tenant, environment, func(ctx context.Context, q *sqlc.Queries, _ sessions.LockedSession, session pgtype.UUID) error { + return apply(&resetEnvironmentTx{ + unit: unit{ctx: ctx, q: q, locked: true}, + cleanupTx: &cleanupTx{allocationTx: &allocationTx{ctx: ctx, q: q, tenant: tenant, environment: environment, session: session}, SessionTx: sessionpg.BindSession(q, tenant, session)}, + }) + })) +} + +type resetEnvironmentTx struct { + unit + *cleanupTx +} + +func (t *resetEnvironmentTx) FindAllocation() (deployment.Allocation, bool, error) { + return findAllocation(t.cleanupTx.ctx, t.cleanupTx.q, t.cleanupTx.tenant, t.cleanupTx.environment) +} + +func (t *resetEnvironmentTx) RequestCleanup(current deployment.Allocation) error { + if err := t.cleanupTx.RevokeDevice(current); err != nil { + return err + } + _, err := t.cleanupTx.RequestCleanup(current) + return err +} diff --git a/services/core/internal/persistence/postgres/deploymentpg/reset_test.go b/services/core/internal/persistence/postgres/deploymentpg/reset_test.go index 58f40defa..4d651a2d7 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/reset_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/reset_test.go @@ -170,3 +170,33 @@ func TestResetSessionsAndAddressBindings(t *testing.T) { t.Fatalf("AddressBindings for another address = %+v, %v", bindings, err) } } + +// A reset expires an unattached standalone Environment and requests its +// cleanup, so the reset is not held open by it. +func TestResetExpiresUnattachedStandaloneEnvironment(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, view := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "disabled") + if err := changes.StartReset(admin(t), installation, deployment.ResetRequest{ExpectedGeneration: view.Generation, Clear: deployment.ResetAuto}); err != nil { + t.Fatal(err) + } + started, err := f.service.View(t.Context()) + if err != nil || started.Reset == nil { + t.Fatal(started, err) + } + candidates, err := f.adapter.ResetSessions(t.Context(), "", false) + if err != nil || len(candidates) != 1 || candidates[0].EnvironmentID != owner.EnvironmentID || candidates[0].SessionID != "" { + t.Fatalf("reset candidates = %+v, %v", candidates, err) + } + if err := changes.ExpireResetEnvironment(t.Context(), owner.Key(), started.Generation, started.Reset.RequestedAt); err != nil { + t.Fatal(err) + } + after, err := f.adapter.EnvironmentAllocation(t.Context(), owner.Key()) + if err != nil || after.State != "cleanup_pending" || environmentStatus(t, f, owner.Key()) != "expired" { + t.Fatal("reset did not expire the Environment", after, err) + } + if candidates, err := f.adapter.ResetSessions(t.Context(), "", false); err != nil || len(candidates) != 0 { + t.Fatalf("reset candidates after expiry = %+v, %v", candidates, err) + } +} diff --git a/services/core/internal/persistence/postgres/deploymentpg/session_archive.go b/services/core/internal/persistence/postgres/deploymentpg/session_archive.go index a361597ee..e5f23e49e 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/session_archive.go +++ b/services/core/internal/persistence/postgres/deploymentpg/session_archive.go @@ -76,10 +76,6 @@ func (t *archiveTx) RequestArchiveCleanup(current deployment.Allocation) error { return err } -func (t *archiveTx) ReleasePlacement() error { - return t.q.ReleaseUnallocatedRuntimePlacement(t.ctx, t.session) -} - func (t *archiveTx) RecordArchiveAudit(ctx context.Context) error { return auditpg.RecordAdminMutation(ctx, t.q, t.tenantID, "archive", "session", uuidString(t.session)) } diff --git a/services/core/internal/persistence/postgres/deploymentpg/standalone_environment_test.go b/services/core/internal/persistence/postgres/deploymentpg/standalone_environment_test.go new file mode 100644 index 000000000..4ad657b87 --- /dev/null +++ b/services/core/internal/persistence/postgres/deploymentpg/standalone_environment_test.go @@ -0,0 +1,320 @@ +package deploymentpg_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "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/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// standaloneEnvironment stores an initialized standalone Environment of a +// fresh tenant, with no Session, and returns its key. +func standaloneEnvironment(t *testing.T, f fixture) deployment.AllocationKey { + t.Helper() + tenant, environment := uuid.New(), uuid.New() + if _, err := f.pool.Exec(t.Context(), `INSERT INTO environments(id, tenant_id, configuration, session_owned, initialization, idempotency_key, request_hash, creator_kind, creator_id) + VALUES ($1, $2, '{"type":"openai_hosted"}', false, 'complete', 'key', repeat('a', 64), 'user', 'creator')`, environment, tenant); err != nil { + t.Fatal(err) + } + return deployment.AllocationKey{TenantID: tenant.String(), EnvironmentID: environment.String()} +} + +// reserveStandalone reserves compute for a standalone Environment and +// settles it as running idle compute in phase. +func reserveStandalone(t *testing.T, f fixture, changes *deployment.ExecutionOperations, installation, phase string) deployment.Allocation { + t.Helper() + key := standaloneEnvironment(t, f) + owner, err := changes.ReserveAllocation(t.Context(), key, installation, credentialHash()) + if err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_allocations SET state='running', create_settled=true, compute_phase=$2, + compute_retained_until = CASE WHEN $2 = 'running' OR $2 = 'disabled' THEN NULL ELSE clock_timestamp() - interval '1 second' END, + compute_activity_at=clock_timestamp() - interval '1 day', compute_phase_changed_at=clock_timestamp() - interval '1 day' WHERE id=$1`, owner.ID, phase); err != nil { + t.Fatal(err) + } + owner, err = f.adapter.EnvironmentAllocation(t.Context(), key) + if err != nil { + t.Fatal(err) + } + return owner +} + +func environmentStatus(t *testing.T, f fixture, key deployment.AllocationKey) string { + t.Helper() + var status string + if err := f.pool.QueryRow(t.Context(), "SELECT status FROM environments WHERE id=$1", key.EnvironmentID).Scan(&status); err != nil { + t.Fatal(err) + } + return status +} + +// An unattached Environment provisions without a Session, and without +// checkpoints expires once it stays idle for the idle timeout. +func TestUnattachedEnvironmentExpiresAtTheIdleTimeout(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, _ := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "disabled") + if owner.AttachedSessionID != "" || owner.Ended { + t.Fatal("standalone allocation reads attached or ended", owner) + } + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_allocations SET compute_activity_at=clock_timestamp(), compute_phase_changed_at=clock_timestamp(), created_at=clock_timestamp() WHERE id=$1`, owner.ID); err != nil { + t.Fatal(err) + } + if _, err := changes.ReleaseIdleUnattached(t.Context(), owner); !errors.Is(err, deployment.ErrNotIdle) { + t.Fatal("expired before the idle timeout", err) + } + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_allocations SET compute_activity_at=clock_timestamp() - interval '1 day', compute_phase_changed_at=clock_timestamp() - interval '1 day', created_at=clock_timestamp() - interval '1 day' WHERE id=$1`, owner.ID); err != nil { + t.Fatal(err) + } + expired, err := changes.ReleaseIdleUnattached(t.Context(), owner) + if err != nil || expired.State != "cleanup_pending" || environmentStatus(t, f, owner.Key()) != "expired" { + t.Fatal("idle Environment did not expire", expired, err) + } +} + +// Confirmed compute loss expires an unattached Environment; it is never +// replaced on new compute. +func TestUnattachedEnvironmentExpiresOnConfirmedComputeLoss(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, _ := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "running") + released, err := changes.ReleaseLostCompute(t.Context(), owner) + if err != nil || released.State != "released" || environmentStatus(t, f, owner.Key()) != "expired" { + t.Fatal("lost compute did not expire the Environment", released, err) + } +} + +// With checkpoints, the end of retention expires an unattached Environment. +func TestUnattachedEnvironmentExpiresWhenRetentionEnds(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, _ := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "suspended") + if !owner.Expired { + t.Fatal("retention did not end", owner) + } + cleaned, err := changes.RequestCleanup(t.Context(), owner) + if err != nil || cleaned.State != "cleanup_pending" || environmentStatus(t, f, owner.Key()) != "expired" { + t.Fatal("retention end did not expire the Environment", cleaned, err) + } +} + +// attachSession stores a live Session attached to the Environment. +func attachSession(t *testing.T, f fixture, key deployment.AllocationKey) uuid.UUID { + t.Helper() + session := uuid.New() + if _, err := f.pool.Exec(t.Context(), `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash, configuration, environment_id) + VALUES ($1, $2, 'codex', 'key', 'hash', '{}', $3)`, session, key.TenantID, key.EnvironmentID); err != nil { + t.Fatal(err) + } + return session +} + +// deleteSession deletes the Session through the storage path of the public delete. +func deleteSession(t *testing.T, f fixture, key deployment.AllocationKey, session uuid.UUID) { + t.Helper() + store := sessionpg.New(pgunit.NewPool(f.pool), f.cipher) + if err := store.WithSessionDeletion(t.Context(), key.TenantID, session.String(), func(ctx context.Context, _ sessions.LockedSession, tx sessions.SessionDeletionTx) error { + return tx.ApplyDeletion(ctx) + }); err != nil { + t.Fatal(err) + } +} + +// Deleting the attached Session starts the idle clock of an Environment that +// outlives it, so it does not expire the moment it detaches. +func TestDetachStartsTheIdleClock(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, _ := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "disabled") + // The compute has been idle for a day, so only the detach can make it busy. + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_allocations SET created_at=clock_timestamp() - interval '1 day' WHERE id=$1`, owner.ID); err != nil { + t.Fatal(err) + } + deleteSession(t, f, owner.Key(), attachSession(t, f, owner.Key())) + if _, err := changes.ReleaseIdleUnattached(t.Context(), owner); !errors.Is(err, deployment.ErrNotIdle) { + t.Fatal("expired right after the detach", err) + } +} + +// An Environment whose compute was released while a Session held it expires +// when the Session detaches, so its workspace is deleted and a reset does not +// wait for it. +func TestDetachAfterReleasedComputeExpiresTheEnvironment(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, view := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "suspended") + config := uuid.New() + for _, statement := range []struct { + sql string + args []any + }{ + {`INSERT INTO workspace_fs_configurations(id,adapter,parameters) VALUES($1,'fixture','{}')`, []any{config}}, + {`INSERT INTO environment_workspaces(object_id,environment_id,configuration_id,state,attachment) VALUES($1,$2,$3,'ready','{}')`, []any{uuid.New(), owner.EnvironmentID, config}}, + {`UPDATE runtime_allocations SET state='released', released_at=clock_timestamp() WHERE id=$1`, []any{owner.ID}}, + } { + if _, err := f.pool.Exec(t.Context(), statement.sql, statement.args...); err != nil { + t.Fatal(err) + } + } + session := attachSession(t, f, owner.Key()) + if status := environmentStatus(t, f, owner.Key()); status != "pending" { + t.Fatal("the held Environment ended", status) + } + deleteSession(t, f, owner.Key(), session) + if status := environmentStatus(t, f, owner.Key()); status != "expired" { + t.Fatal("the detached Environment stayed", status) + } + candidates, err := sqlc.New(f.pool).ListWorkspaceDeletionCandidates(t.Context(), pgtype.UUID{Valid: true}) + if err != nil || len(candidates) != 1 { + t.Fatal("workspace is not a deletion candidate", candidates, err) + } + if err := changes.StartReset(admin(t), installation, deployment.ResetRequest{ExpectedGeneration: view.Generation, Clear: deployment.ResetForce}); err != nil { + t.Fatal(err) + } + reset, err := f.service.View(t.Context()) + if err != nil || reset.Reset == nil || reset.Reset.Remaining.Idle+reset.Reset.Remaining.Busy+reset.Reset.Remaining.Cleanup != 0 { + t.Fatal("the reset still waits", reset.Reset, err) + } + if _, err := changes.CompleteReset(t.Context(), installation, view.Generation, reset.Reset.RequestedAt); err != nil { + t.Fatal(err) + } +} + +// A failed unattached Environment releases its compute without waiting for the +// idle timeout, also where the Provider suspends idle compute. +func TestFailedUnattachedEnvironmentReleasesItsCompute(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, _ := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "disabled") + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_allocations SET compute_activity_at=clock_timestamp(), compute_phase_changed_at=clock_timestamp(), created_at=clock_timestamp() WHERE id=$1`, owner.ID); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), `UPDATE environments SET status='failed' WHERE id=$1`, owner.Key().EnvironmentID); err != nil { + t.Fatal(err) + } + failed, err := f.adapter.EnvironmentAllocation(t.Context(), owner.Key()) + if err != nil || !failed.Ended { + t.Fatal("a failed unattached Environment has not ended", failed, err) + } + released, err := changes.RequestCleanup(t.Context(), failed) + if err != nil || released.State != "cleanup_pending" || environmentStatus(t, f, owner.Key()) != "failed" { + t.Fatal("failed Environment kept its compute", released, err) + } +} + +// Without checkpoints, the idle clock of an unattached Environment starts when +// its initialization completes, not when its compute was created. +func TestInitializationStartsTheIdleClock(t *testing.T) { + f := newFixture(t) + changes, lease := f.execution(t) + installation, _ := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "disabled") + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_allocations SET created_at=clock_timestamp() - interval '1 day' WHERE id=$1`, owner.ID); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), `UPDATE environments SET initialization='running' WHERE id=$1`, owner.Key().EnvironmentID); err != nil { + t.Fatal(err) + } + key := owner.Key() + if err := sessionpg.NewExecution(lease).WithInitialization(t.Context(), key.TenantID, key.EnvironmentID, func(ctx context.Context, tx sessions.InitializationTx, _ sessions.LockedSession) error { + _, err := tx.CompleteInitialization(ctx, key.EnvironmentID) + return err + }); err != nil { + t.Fatal(err) + } + if _, err := changes.ReleaseIdleUnattached(t.Context(), owner); !errors.Is(err, deployment.ErrNotIdle) { + t.Fatal("expired as soon as it was ready", err) + } +} + +// Detaching an Environment that ends for lack of compute also releases the +// placement it held for new compute. +func TestDetachReleasesTheHeldPlacementOfAnEndedEnvironment(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")}) + node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + f.connect(t, node.NodeID) + key := standaloneEnvironment(t, f) + if _, err := f.pool.Exec(t.Context(), `INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) VALUES($1,$2,$3)`, key.EnvironmentID, node.NodeID, view.Generation); err != nil { + t.Fatal(err) + } + owner, err := changes.ReserveAllocation(t.Context(), key, installation, credentialHash()) + if err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_allocations SET state='released', create_settled=true, released_at=clock_timestamp() WHERE id=$1`, owner.ID); err != nil { + t.Fatal(err) + } + deleteSession(t, f, key, attachSession(t, f, key)) + if err := changes.ReleaseInactivePlacements(t.Context()); err != nil { + t.Fatal(err) + } + if held := heldPlacements(t, f, key); held != 0 || environmentStatus(t, f, key) != "expired" { + t.Fatal("the ended Environment kept its placement", held) + } +} + +func heldPlacements(t *testing.T, f fixture, key deployment.AllocationKey) (held int) { + t.Helper() + if err := f.pool.QueryRow(t.Context(), `SELECT count(*) FROM runtime_placements WHERE environment_id=$1 AND released_at IS NULL`, key.EnvironmentID).Scan(&held); err != nil { + t.Fatal(err) + } + return held +} + +// An Environment that fails while holding a placement and no compute gives the +// placement up, and an active one keeps it. +func TestFailedEnvironmentWithoutComputeReleasesItsPlacement(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + _, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")}) + node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + f.connect(t, node.NodeID) + key := standaloneEnvironment(t, f) + if _, err := f.pool.Exec(t.Context(), `INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) VALUES($1,$2,$3)`, key.EnvironmentID, node.NodeID, view.Generation); err != nil { + t.Fatal(err) + } + if err := changes.ReleaseInactivePlacements(t.Context()); err != nil || heldPlacements(t, f, key) != 1 { + t.Fatal("an active Environment lost its placement", err) + } + if _, err := f.pool.Exec(t.Context(), `UPDATE environments SET status='failed' WHERE id=$1`, key.EnvironmentID); err != nil { + t.Fatal(err) + } + if err := changes.ReleaseInactivePlacements(t.Context()); err != nil || heldPlacements(t, f, key) != 0 { + t.Fatal("the failed Environment kept its placement", err) + } +} + +// Compute that is being removed when the Session detaches counts as gone: the +// Environment ends and is never replaced. +func TestDetachWhileComputeIsBeingRemovedExpiresTheEnvironment(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, _ := f.initialize(t, changes, setupE2BSelection()) + owner := reserveStandalone(t, f, changes, installation, "disabled") + session := attachSession(t, f, owner.Key()) + if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_allocations SET state='cleanup_pending' WHERE id=$1`, owner.ID); err != nil { + t.Fatal(err) + } + deleteSession(t, f, owner.Key(), session) + if status := environmentStatus(t, f, owner.Key()); status != "expired" { + t.Fatal("the Environment kept its removed compute", status) + } +} diff --git a/services/core/internal/persistence/postgres/deploymentpg/store.go b/services/core/internal/persistence/postgres/deploymentpg/store.go index e4aee9e74..deddf0c08 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/store.go +++ b/services/core/internal/persistence/postgres/deploymentpg/store.go @@ -282,13 +282,17 @@ func (s *Store) ResetSessions(ctx context.Context, after string, force bool) ([] return nil, err } } - rows, err := s.pool.Queries().ListSandboxResetSessions(ctx, sqlc.ListSandboxResetSessionsParams{AfterID: cursor, Force: force}) + rows, err := s.pool.Queries().ListSandboxResetCandidates(ctx, sqlc.ListSandboxResetCandidatesParams{AfterID: cursor, Force: force}) if err != nil { return nil, err } result := make([]deployment.ResetSession, 0, len(rows)) for _, row := range rows { - result = append(result, deployment.ResetSession{SessionID: uuidString(row.ID), TenantID: uuidString(row.TenantID)}) + session := "" + if row.SessionID.Valid { + session = uuidString(row.SessionID) + } + result = append(result, deployment.ResetSession{SessionID: session, EnvironmentID: uuidString(row.ID), TenantID: uuidString(row.TenantID)}) } return result, nil } diff --git a/services/core/internal/persistence/postgres/sessionpg/binding.go b/services/core/internal/persistence/postgres/sessionpg/binding.go index 145e20f43..3443a8b23 100644 --- a/services/core/internal/persistence/postgres/sessionpg/binding.go +++ b/services/core/internal/persistence/postgres/sessionpg/binding.go @@ -162,8 +162,8 @@ func (t *SessionTx) LoadBoundDevice(ctx context.Context) (bool, error) { return err == nil, err } -// InsertEnvironmentDevice inserts the device only for the Session's own hosted -// Environment. The insert takes no row when the Environment already has a +// InsertEnvironmentDevice inserts the device only for a hosted Environment, +// bound to the attached Session if there is one. The insert takes no row when the Environment already has a // device, including one a concurrent transaction inserted first, or cannot // take one: that is sessions.ErrDeviceBindingConflict. func (t *SessionTx) InsertEnvironmentDevice(ctx context.Context, device sessions.ExecutionDevice, credentialHash string) error { @@ -185,6 +185,10 @@ func (t *SessionTx) InsertEnvironmentDevice(ctx context.Context, device sessions if err != nil { return err } + if !t.session.Valid { + // A standalone Environment has no Session to bind yet; attaching one binds it. + return nil + } _, err = t.q.BindHostedSessionDevice(ctx, sqlc.BindHostedSessionDeviceParams{TenantID: t.tenant, ID: t.session, ID_2: inserted}) if errors.Is(err, pgx.ErrNoRows) { return sessions.ErrDeviceBindingConflict diff --git a/services/core/internal/persistence/postgres/sessionpg/creation.go b/services/core/internal/persistence/postgres/sessionpg/creation.go index 9bf107ec0..ffc22710d 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/placementpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/skillpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -170,7 +171,7 @@ func (t *creationTx) SaveInitialFiles(ctx context.Context, initial []environment size := int64(len(body)) metadata[i].ID = id.String() metadata[i].SizeBytes = &size - encrypted, err := t.cipher.SealEnvironmentFile(body, fileBinding(t.tenant, t.session, id.String())) + encrypted, err := t.cipher.SealEnvironmentFile(body, fileBinding(t.tenant, t.environment, id.String())) if err != nil { return fmt.Errorf("seal initial file: %w", err) } @@ -192,7 +193,7 @@ func (t *creationTx) SaveSetup(ctx context.Context, setup environmentconfig.Setu if err != nil { return err } - encrypted, err := t.cipher.SealEnvironmentSetup(plaintext, setupBinding(t.tenant, t.session)) + encrypted, err := t.cipher.SealEnvironmentSetup(plaintext, setupBinding(t.tenant, t.environment)) if err != nil { return fmt.Errorf("seal environment setup: %w", err) } @@ -217,8 +218,7 @@ func (t *creationTx) SaveSetup(ctx context.Context, setup environmentconfig.Setu } 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 { + if err := t.insertEnvironment(ctx, sqlc.CreateEnvironmentParams{Configuration: configuration, Prepared: prepared}); err != nil { return "", err } if err := t.q.AttachSessionEnvironment(ctx, sqlc.AttachSessionEnvironmentParams{ID: t.session, EnvironmentID: t.environment}); err != nil { @@ -227,6 +227,54 @@ func (t *creationTx) CreateEnvironment(ctx context.Context, configuration json.R return optionalID(t.environment), nil } +func (t *creationTx) AttachEnvironment(ctx context.Context, environment string) error { + id := pgunit.PathID(environment) + if _, err := t.q.LockEnvironment(ctx, sqlc.LockEnvironmentParams{TenantID: t.tenant, ID: id}); err != nil { + return notFound(err) + } + // A statement after the lock sees any attachment committed while waiting. + row, err := t.q.GetEnvironment(ctx, sqlc.GetEnvironmentParams{TenantID: t.tenant, ID: id}) + if err != nil { + return notFound(err) + } + switch { + case row.AttachedSessionID.Valid: + return sessions.ErrEnvironmentAttached + case row.Environment.Status == "failed" || row.Environment.Status == "expired": + return sessions.ErrEnvironmentTerminal + } + t.environment = id + if err := t.q.AttachSessionEnvironment(ctx, sqlc.AttachSessionEnvironmentParams{ID: t.session, EnvironmentID: id}); err != nil { + return err + } + // A Runtime that already connected to the Environment serves this Session. + device, err := t.q.GetEnvironmentDevice(ctx, sqlc.GetEnvironmentDeviceParams{TenantID: t.tenant, EnvironmentID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + _, err = t.q.BindHostedSessionDevice(ctx, sqlc.BindHostedSessionDeviceParams{TenantID: t.tenant, ID: t.session, ID_2: device.ID}) + return err +} + +// notFound maps a missing row to sessions.ErrNotFound. +func notFound(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return sessions.ErrNotFound + } + return err +} + +// insertEnvironment inserts the Environment with a new ID, which the later +// preparation steps act on. +func (t *creationTx) insertEnvironment(ctx context.Context, params sqlc.CreateEnvironmentParams) error { + t.environment = pgtype.UUID{Bytes: uuid.New(), Valid: true} + params.ID, params.TenantID = t.environment, t.tenant + return t.q.CreateEnvironment(ctx, params) +} + func (t *creationTx) PruneChanges(ctx context.Context) error { return PruneChanges(ctx, t.q, t.session) } diff --git a/services/core/internal/persistence/postgres/sessionpg/environment.go b/services/core/internal/persistence/postgres/sessionpg/environment.go index 7e7e6beeb..86596d74e 100644 --- a/services/core/internal/persistence/postgres/sessionpg/environment.go +++ b/services/core/internal/persistence/postgres/sessionpg/environment.go @@ -9,6 +9,7 @@ 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/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -106,3 +107,45 @@ func LoadEnvironmentInput(ctx context.Context, q *sqlc.Queries, session pgtype.U } return state, nil } + +// ListEnvironments reads a page of the standalone Environments the creator +// made in the tenant, whatever their status, after the cursor Environment in (created, ID) order. +// It reports whether more follow. A missing cursor is sessions.ErrNotFound. +func (s *Store) ListEnvironments(ctx context.Context, tenantID string, creator identity.Subject, cursor string, limit int, ascending bool) ([]sessions.Environment, bool, error) { + tenant, err := parseID(tenantID) + if err != nil { + return nil, false, err + } + params := sqlc.ListStandaloneEnvironmentsParams{TenantID: tenant, CreatorKind: pgtype.Text{String: creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: creator.ID, Valid: true}, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + var page []sessions.Environment + more := false + err = s.units.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if cursor != "" { + after, err := q.GetStandaloneEnvironmentCursor(ctx, sqlc.GetStandaloneEnvironmentCursorParams{TenantID: tenant, ID: pgunit.PathID(cursor), CreatorKind: params.CreatorKind, CreatorID: params.CreatorID}) + if err != nil { + return err + } + params.AfterCreated, params.AfterID = after.CreatedAt, after.ID + } + rows, err := q.ListStandaloneEnvironments(ctx, params) + if err != nil { + return err + } + if more = len(rows) > limit; more { + rows = rows[:limit] + } + for _, row := range rows { + environment, err := environmentFromRow(row.Environment, row.AttachedSessionID, row.ExternalWorkspace, nil) + if err != nil { + return err + } + page = append(page, environment) + } + return nil + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, sessions.ErrNotFound + } + return page, more, err +} diff --git a/services/core/internal/persistence/postgres/sessionpg/environment_creation.go b/services/core/internal/persistence/postgres/sessionpg/environment_creation.go new file mode 100644 index 000000000..27735ae98 --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/environment_creation.go @@ -0,0 +1,69 @@ +package sessionpg + +import ( + "context" + "encoding/json" + "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/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" +) + +var _ sessions.StandaloneCreationTx = (*standaloneCreationTx)(nil) + +func (s *Store) WithEnvironmentCreation(ctx context.Context, tenantID string, ident sessions.EnvironmentIdentity, apply func(context.Context, sessions.StandaloneCreationTx) error) error { + tenant, err := parseID(tenantID) + if err != nil { + return err + } + return s.units.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + base := &creationTx{SessionTx: SessionTx{q: sqlc.New(tx), tenant: tenant}, tenantID: tenantID, tx: tx, cipher: s.cipher} + return apply(ctx, &standaloneCreationTx{creationTx: base, identity: ident}) + }) +} + +func findEnvironmentCreation(ctx context.Context, q *sqlc.Queries, tenant pgtype.UUID, creator identity.Subject, key string) (sessions.EnvironmentCreationRecord, error) { + row, err := q.FindEnvironmentCreation(ctx, sqlc.FindEnvironmentCreationParams{TenantID: tenant, CreatorKind: pgtype.Text{String: creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: creator.ID, Valid: true}, IdempotencyKey: pgtype.Text{String: key, Valid: true}}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.EnvironmentCreationRecord{}, sessions.ErrNotFound + } + if err != nil { + return sessions.EnvironmentCreationRecord{}, err + } + return sessions.EnvironmentCreationRecord{EnvironmentID: optionalID(row.ID), EnvironmentIdentity: sessions.EnvironmentIdentity{ + Key: row.IdempotencyKey.String, Hash: row.RequestHash.String, Creator: identity.Subject{Kind: row.CreatorKind.String, ID: row.CreatorID.String}, + }}, nil +} + +// standaloneCreationTx creates an Environment no Session owns, under its +// creation identity. +type standaloneCreationTx struct { + *creationTx + identity sessions.EnvironmentIdentity +} + +func (t *standaloneCreationTx) FindCreation(ctx context.Context) (sessions.EnvironmentCreationRecord, error) { + if err := t.q.LockEnvironmentCreation(ctx, sqlc.LockEnvironmentCreationParams{TenantID: t.tenantID, CreatorKind: t.identity.Creator.Kind, CreatorID: t.identity.Creator.ID, IdempotencyKey: t.identity.Key}); err != nil { + return sessions.EnvironmentCreationRecord{}, err + } + return findEnvironmentCreation(ctx, t.q, t.tenant, t.identity.Creator, t.identity.Key) +} + +func (t *standaloneCreationTx) CreateEnvironment(ctx context.Context, configuration json.RawMessage, prepared bool) (string, error) { + err := t.insertEnvironment(ctx, sqlc.CreateEnvironmentParams{ + Configuration: configuration, Prepared: prepared, + IdempotencyKey: pgtype.Text{String: t.identity.Key, Valid: true}, RequestHash: pgtype.Text{String: t.identity.Hash, Valid: true}, + CreatorKind: pgtype.Text{String: t.identity.Creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: t.identity.Creator.ID, Valid: true}, + }) + return optionalID(t.environment), storable(err) +} + +func (t *standaloneCreationTx) AuditCreation(ctx context.Context, environment string, created ...writeaudit.Resource) error { + return auditpg.RecordWriteAudit(ctx, t.q, t.tenantID, writeaudit.ActionCreate, writeaudit.ResourceEnvironment, environment, "", created...) +} diff --git a/services/core/internal/persistence/postgres/sessionpg/environment_lock.go b/services/core/internal/persistence/postgres/sessionpg/environment_lock.go index ad60da600..a05ec9d14 100644 --- a/services/core/internal/persistence/postgres/sessionpg/environment_lock.go +++ b/services/core/internal/persistence/postgres/sessionpg/environment_lock.go @@ -49,7 +49,13 @@ func lockEnvironment(ctx context.Context, q *sqlc.Queries, tenant, environment p return err } } - row, err := q.LockEnvironment(ctx, sqlc.LockEnvironmentParams{TenantID: tenant, ID: environment}) + if _, err := q.LockEnvironment(ctx, sqlc.LockEnvironmentParams{TenantID: tenant, ID: environment}); err != nil { + return err + } + // Read the attachment in a new statement: the lock statement's snapshot + // predates the wait for the lock, so it would miss an attach that committed + // meanwhile. + row, err := q.GetEnvironmentAttachment(ctx, sqlc.GetEnvironmentAttachmentParams{TenantID: tenant, ID: environment}) if err != nil { return err } diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_environment.go b/services/core/internal/persistence/postgres/sessionpg/execution_environment.go index c4f353af7..fa20ea1e3 100644 --- a/services/core/internal/persistence/postgres/sessionpg/execution_environment.go +++ b/services/core/internal/persistence/postgres/sessionpg/execution_environment.go @@ -117,7 +117,11 @@ func (t *environmentTx) CompleteInitialization(ctx context.Context, environment return false, err } count, err := t.q.CompleteEnvironmentInitialization(ctx, id) - return count == 1, err + if err != nil || count != 1 { + return false, err + } + // A standalone Environment's idle clock starts once it is ready. + return true, t.q.RecordStandaloneActivity(ctx, id) } func (t *environmentTx) RecordInitializationSteps(ctx context.Context, environment string, steps []sessions.InitializationStep) error { diff --git a/services/core/internal/persistence/postgres/sessionpg/frozen.go b/services/core/internal/persistence/postgres/sessionpg/frozen.go index 7c4c1c81c..2a0046d82 100644 --- a/services/core/internal/persistence/postgres/sessionpg/frozen.go +++ b/services/core/internal/persistence/postgres/sessionpg/frozen.go @@ -15,18 +15,17 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -// 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. +// ReadEnvironmentSetup opens the setup frozen for the Environment. An ended +// Environment 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, environment string) (environmentconfig.Setup, error) { lookup, err := ResourceLookup(tenant, environment) if err != nil { return environmentconfig.Setup{}, sessions.ErrNotFound } 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) { + if errors.Is(err, pgx.ErrNoRows) || (err == nil && row.Ended) { return environmentconfig.Setup{}, sessions.ErrNotFound } if err != nil { @@ -36,7 +35,7 @@ func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, environment st if len(row.Contents) == 0 { return setup, nil } - plaintext, err := s.cipher.OpenEnvironmentSetup(row.Contents, setupBinding(lookup.TenantID, row.OwnerSessionID)) + plaintext, err := s.cipher.OpenEnvironmentSetup(row.Contents, setupBinding(lookup.TenantID, lookup.ID)) if err != nil { return environmentconfig.Setup{}, fmt.Errorf("open frozen environment setup: %w", err) } @@ -55,14 +54,14 @@ func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, environm return environmentconfig.InitialFileMetadata{}, nil, err } 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) { + if errors.Is(err, pgx.ErrNoRows) || (err == nil && row.Ended) { 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, row.OwnerSessionID, id)) + body, err := s.cipher.OpenEnvironmentFile(row.Contents, fileBinding(lookup.TenantID, lookup.ID, id)) if err != nil { return environmentconfig.InitialFileMetadata{}, nil, fmt.Errorf("open frozen initial file: %w", err) } @@ -72,13 +71,14 @@ func (s *Store) ReadInitialEnvironmentFile(ctx context.Context, tenant, environm return environmentconfig.InitialFileMetadata{ID: id, Path: row.Path, SizeBytes: &row.SizeBytes}, body, nil } -// setupBinding binds the setup a Session froze to the tenant and Session. -func setupBinding(tenant, session pgtype.UUID) credentialcrypto.EnvironmentSetupBinding { - return credentialcrypto.EnvironmentSetupBinding{TenantID: optionalID(tenant), Resource: "session", OwnerID: optionalID(session), Field: "initialization"} +// setupBinding binds the setup frozen for an Environment to the tenant and +// the Environment. +func setupBinding(tenant, environment pgtype.UUID) credentialcrypto.EnvironmentSetupBinding { + return credentialcrypto.EnvironmentSetupBinding{TenantID: optionalID(tenant), Resource: "environment", OwnerID: optionalID(environment), Field: "initialization"} } -// fileBinding binds an initial file a Session froze to the tenant, Session -// and file. -func fileBinding(tenant, session pgtype.UUID, file string) credentialcrypto.EnvironmentFileBinding { - return credentialcrypto.EnvironmentFileBinding{TenantID: optionalID(tenant), Resource: "session", OwnerID: optionalID(session), FileID: file} +// fileBinding binds an initial file frozen for an Environment to the tenant, +// the Environment and the file. +func fileBinding(tenant, environment pgtype.UUID, file string) credentialcrypto.EnvironmentFileBinding { + return credentialcrypto.EnvironmentFileBinding{TenantID: optionalID(tenant), Resource: "environment", OwnerID: optionalID(environment), FileID: file} } diff --git a/services/core/internal/persistence/postgres/sessionpg/frozen_test.go b/services/core/internal/persistence/postgres/sessionpg/frozen_test.go index eaf0abe6b..820db33d4 100644 --- a/services/core/internal/persistence/postgres/sessionpg/frozen_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/frozen_test.go @@ -26,8 +26,8 @@ func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { cipher := pgtest.CredentialKey(t) adapter := New(pgunit.NewPool(pool), cipher) 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"} + tenant, environment := uuidText(tenantID), uuidText(environmentID) + binding := credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: "environment", OwnerID: environment, Field: "initialization"} freeze := func(plaintext string, binding credentialcrypto.EnvironmentSetupBinding) []byte { t.Helper() sealed, err := cipher.SealEnvironmentSetup([]byte(plaintext), binding) @@ -40,7 +40,7 @@ func TestReadEnvironmentSetupClassifiesFrozenData(t *testing.T) { 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"}} { + for name, ids := range map[string][2]string{"unknown Session": {tenant, uuid.NewString()}, "other tenant": {uuid.NewString(), environment}, "malformed Session": {tenant, "session"}} { if _, err := adapter.ReadEnvironmentSetup(t.Context(), ids[0], ids[1]); !errors.Is(err, sessions.ErrNotFound) { t.Fatalf("%s: %v", name, err) } @@ -81,9 +81,9 @@ func TestReadInitialEnvironmentFileClassifiesFrozenData(t *testing.T) { cipher := pgtest.CredentialKey(t) adapter := New(pgunit.NewPool(pool), cipher) tenantID, sessionID, environmentID := newEnvironment(t, pool, "self_hosted", "pending") - tenant, session, environment := uuidText(tenantID), uuidText(sessionID), uuidText(environmentID) + tenant, environment := uuidText(tenantID), uuidText(environmentID) file := uuid.NewString() - binding := credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "session", OwnerID: session, FileID: file} + binding := credentialcrypto.EnvironmentFileBinding{TenantID: tenant, Resource: "environment", OwnerID: environment, FileID: file} freeze := func(body string, binding credentialcrypto.EnvironmentFileBinding) []byte { t.Helper() sealed, err := cipher.SealEnvironmentFile([]byte(body), binding) diff --git a/services/core/internal/persistence/postgres/sessionpg/session_writes.go b/services/core/internal/persistence/postgres/sessionpg/session_writes.go index 9810d078b..484415569 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_writes.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_writes.go @@ -39,10 +39,20 @@ func (t *deletionTx) ApplyDeletion(ctx context.Context) error { if err := t.q.DeleteSessionArtifacts(ctx, t.session); err != nil { return err } - if err := t.q.ReleaseUnallocatedRuntimePlacement(ctx, t.session); err != nil { + // Detaching starts the idle clock of a standalone Environment's compute, and + // ends an Environment whose compute was released or is being removed while the + // Session held it. + environment, err := t.q.GetSessionEnvironmentID(ctx, t.session) + if err != nil { + return err + } + if err := t.q.RecordStandaloneActivity(ctx, environment); err != nil { + return err + } + if err := t.q.MarkSessionDeleted(ctx, t.session); err != nil { return err } - return t.q.MarkSessionDeleted(ctx, t.session) + return t.q.EndUnattachedStandaloneEnvironment(ctx, environment) } func (t *deletionTx) RecordDeletionAudit(ctx context.Context) error { diff --git a/services/core/internal/persistence/postgres/sessionpg/standalone_environment_test.go b/services/core/internal/persistence/postgres/sessionpg/standalone_environment_test.go new file mode 100644 index 000000000..fd83bc22b --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/standalone_environment_test.go @@ -0,0 +1,108 @@ +package sessionpg + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// An attach that commits while an Environment transaction waits for the lock +// is seen after the wait: the transaction retries with the attached Session +// locked instead of running without it. +func TestEnvironmentLockSeesAttachCommittedWhileWaiting(t *testing.T) { + pool := pgtest.OpenIsolated(t, nil) + store, service := creationService(t, pool) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + tenant := uuid.NewString() + session, err := service.CreateSession(ctx, tenant, sessions.CreateSession{Creator: creator, Engine: "codex", IdempotencyKey: "key", Configuration: environmentConfiguration}) + if err != nil { + t.Fatal(err) + } + environment := uuid.New() + exec(t, pool, `INSERT INTO environments(id, tenant_id, configuration, session_owned, initialization, idempotency_key, request_hash, creator_kind, creator_id) + VALUES ($1, $2, '{"type":"openai_hosted"}', false, 'complete', 'key', repeat('a', 64), 'user', 'creator')`, environment, tenant) + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + var holder int32 + if err := tx.QueryRow(ctx, "SELECT pg_backend_pid() FROM environments WHERE id=$1 FOR UPDATE", environment).Scan(&holder); err != nil { + t.Fatal(err) + } + attached := make(chan pgtype.UUID, 1) + go func() { + var last pgtype.UUID + err := WithEnvironment(ctx, store.units, pgtype.UUID{Bytes: uuid.MustParse(tenant), Valid: true}, pgtype.UUID{Bytes: environment, Valid: true}, + func(_ context.Context, _ *sqlc.Queries, _ sessions.LockedSession, session pgtype.UUID) error { + last = session + return nil + }) + if err != nil { + t.Error(err) + } + attached <- last + }() + awaitBlocked(ctx, t, pool, holder) + if _, err := tx.Exec(ctx, "UPDATE sessions SET environment_id=$1 WHERE id=$2", environment, session.Session.ID); err != nil { + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + if got := <-attached; !got.Valid || uuid.UUID(got.Bytes).String() != session.Session.ID { + t.Fatal("the waiting transaction missed the attached Session", got) + } +} + +// A creation key deduplicates within the creator: another creator reusing it +// creates its own Environment. +func TestEnvironmentCreationKeyIsScopedToTheCreator(t *testing.T) { + pool := pgtest.OpenIsolated(t, nil) + _, service := creationService(t, pool) + tenant := uuid.NewString() + exec(t, pool, `UPDATE runtime_deployment SET installation_id=$1, backend_fingerprint=$2, provider_kind='e2b', mode='direct', generation=1, + specification='{"resources":{"cpus":2,"memory_mib":2048}}'`, uuid.New(), strings.Repeat("a", 64)) + create := func(who identity.Subject) string { + id, err := service.CreateEnvironment(t.Context(), tenant, sessions.CreateEnvironment{ + Creator: who, IdempotencyKey: "shared", CreationRequest: json.RawMessage(`{}`), Configuration: json.RawMessage(`{"type":"openai_hosted"}`)}) + if err != nil { + t.Fatal(err) + } + return id + } + first, other := create(creator), create(identity.Subject{Kind: "service_account", ID: "other"}) + if first == other || create(creator) != first { + t.Fatal("the key crossed creators or did not replay", first, other) + } +} + +// Attaching a Session meets the deployment state a new Environment meets: a +// pending reset refuses it. +func TestAttachIsRefusedWhileAResetIsPending(t *testing.T) { + pool := pgtest.OpenIsolated(t, nil) + _, service := creationService(t, pool) + tenant, environment := uuid.NewString(), uuid.New() + exec(t, pool, `UPDATE runtime_deployment SET installation_id=$1, backend_fingerprint=$2, provider_kind='e2b', mode='direct', generation=1, + specification='{"resources":{"cpus":2,"memory_mib":2048}}', reset_clear='force', reset_requested_at=now(), reset_forced_at=now(), reset_audit='{}'`, uuid.New(), strings.Repeat("a", 64)) + exec(t, pool, `INSERT INTO environments(id, tenant_id, configuration, session_owned, initialization, idempotency_key, request_hash, creator_kind, creator_id) + VALUES ($1, $2, '{"type":"openai_hosted"}', false, 'complete', 'key', repeat('a', 64), 'user', 'creator')`, environment, tenant) + _, err := service.CreateSession(t.Context(), tenant, sessions.CreateSession{Creator: creator, Engine: "codex", IdempotencyKey: "attach", EnvironmentID: environment.String(), + Configuration: json.RawMessage(`{"agent":{"model":"m"},"environment":{"type":"openai_hosted"}}`)}) + if !errors.Is(err, placement.ErrResetAdmission) { + t.Fatal("attach bypassed the pending reset", err) + } +} diff --git a/services/core/internal/persistence/postgres/workspacepg/store.go b/services/core/internal/persistence/postgres/workspacepg/store.go index 8d30e339e..160d081ef 100644 --- a/services/core/internal/persistence/postgres/workspacepg/store.go +++ b/services/core/internal/persistence/postgres/workspacepg/store.go @@ -331,10 +331,14 @@ func (e *Execution) change(ctx context.Context, ref workspacefs.Reference, apply } // withEnvironment runs apply under the Environment lock, after the attached -// Session's, and reports whether a Session-owned Environment lost its Session. +// Session's, and reports whether the Environment's workspace may be deleted. 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) + err := sessionpg.WithEnvironment(ctx, e.lease, pgunit.PathID(tenant), pgunit.PathID(environment), func(ctx context.Context, q *sqlc.Queries, _ sessions.LockedSession, _ pgtype.UUID) error { + deletable, err := q.EnvironmentWorkspaceDeletable(ctx, pgunit.PathID(environment)) + if err != nil { + return err + } + return apply(ctx, q, deletable) }) if errors.Is(err, sessions.ErrNotFound) { return workspaces.ErrNotFound diff --git a/services/core/internal/persistence/postgres/workspacepg/store_test.go b/services/core/internal/persistence/postgres/workspacepg/store_test.go index 507565fde..082780bc6 100644 --- a/services/core/internal/persistence/postgres/workspacepg/store_test.go +++ b/services/core/internal/persistence/postgres/workspacepg/store_test.go @@ -429,3 +429,49 @@ func TestWorkspaceWritesRequireExecutionLease(t *testing.T) { } } } + +// An unattached standalone Environment's workspace is deleted once the +// Environment ended; a live Session or a live Environment keeps it. +func TestEndedStandaloneEnvironmentWorkspaceIsDeleted(t *testing.T) { + f := newFixture(t) + tenant, environment := uuid.NewString(), uuid.NewString() + if _, err := f.pool.Exec(t.Context(), `INSERT INTO environments(id, tenant_id, configuration, session_owned, idempotency_key, request_hash, creator_kind, creator_id) + VALUES ($1, $2, '{"type":"openai_hosted"}', false, 'key', repeat('a', 64), 'user', 'creator')`, environment, tenant); err != nil { + t.Fatal(err) + } + record, err := f.execution.Bind(t.Context(), tenant, environment, *f.active, 0) + if err != nil { + t.Fatal(err) + } + if err := f.execution.MarkReady(t.Context(), record.Reference, attachment(record)); err != nil { + t.Fatal(err) + } + deletable := func() bool { + candidates, err := f.store.DeletionCandidates(t.Context(), "") + if err != nil { + t.Fatal(err) + } + return len(candidates) == 1 + } + if deletable() || !errors.Is(f.execution.BeginDelete(t.Context(), record.Reference), workspaces.ErrConflict) { + t.Fatal("a live standalone Environment lost its workspace") + } + session := uuid.NewString() + if _, err := f.pool.Exec(t.Context(), `UPDATE environments SET status='expired' WHERE id=$1`, environment); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash, configuration, environment_id) + VALUES ($1, $2, 'codex', 'key', 'hash', '{}', $3)`, session, tenant, environment); err != nil { + t.Fatal(err) + } + if deletable() || !errors.Is(f.execution.BeginDelete(t.Context(), record.Reference), workspaces.ErrConflict) { + t.Fatal("an attached Session lost its workspace") + } + f.deleteSession(t, session) + if !deletable() { + t.Fatal("an ended, unattached Environment kept its workspace") + } + if err := f.execution.BeginDelete(t.Context(), record.Reference); err != nil { + t.Fatal(err) + } +} diff --git a/services/core/internal/sandbox/network.go b/services/core/internal/sandbox/network.go index f12e11d5d..a4871eefc 100644 --- a/services/core/internal/sandbox/network.go +++ b/services/core/internal/sandbox/network.go @@ -3,6 +3,7 @@ package sandbox import ( "fmt" "net" + "net/netip" "regexp" "slices" "strconv" @@ -47,6 +48,19 @@ type Network struct { Domains []string `json:"domains" binding:"required"` } +// PrivatePrefixes are the addresses no sandbox may reach. Loopback is not among +// them: inside the sandbox's network namespace it is the sandbox itself. +var PrivatePrefixes = []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), +} + +// PublicAddress reports whether addr is a global unicast address outside every +// private prefix. +func PublicAddress(addr netip.Addr) bool { + return addr.IsGlobalUnicast() && !slices.ContainsFunc(PrivatePrefixes, func(p netip.Prefix) bool { return p.Contains(addr) }) +} + // DefaultNetwork is the public internet only. func DefaultNetwork() Network { return Network{Public: true, Domains: []string{}} } diff --git a/services/core/internal/sandbox/opensandbox/network.go b/services/core/internal/sandbox/opensandbox/network.go index e8c8def66..76a187c59 100644 --- a/services/core/internal/sandbox/opensandbox/network.go +++ b/services/core/internal/sandbox/opensandbox/network.go @@ -14,14 +14,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -// privatePrefixes are the addresses no sandbox may reach. -// Loopback is not among them: inside the sandbox's network namespace it is the -// sandbox itself (execd and the sidecar's DNS proxy), never the host. -var privatePrefixes = []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"), - netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), -} - type resolver func(ctx context.Context, host string) ([]netip.Addr, error) // networkPolicy maps Egress to the OpenSandbox egress policy. The sidecar lets a @@ -48,11 +40,11 @@ func networkPolicy(ctx context.Context, b sandbox.Bootstrap, resolve resolver) ( if err != nil { return nil, err } - if slices.ContainsFunc(addrs, func(a netip.Addr) bool { return !isGlobal(a) }) { + if slices.ContainsFunc(addrs, func(a netip.Addr) bool { return !sandbox.PublicAddress(a) }) { return nil, fmt.Errorf("%w: OpenSandbox sandboxes reach Core only at global addresses", sandbox.ErrInvalid) } var rules []osdk.NetworkRule - for _, prefix := range privatePrefixes { + for _, prefix := range sandbox.PrivatePrefixes { rules = append(rules, osdk.NetworkRule{Action: "deny", Target: prefix.String()}) } if b.Egress.Public { @@ -62,7 +54,7 @@ func networkPolicy(ctx context.Context, b sandbox.Bootstrap, resolve resolver) ( allow := func(host string, port int) { rule := osdk.NetworkRule{Action: "allow", Target: host} if addr, err := netip.ParseAddr(host); err == nil { - if !isGlobal(addr) { + if !sandbox.PublicAddress(addr) { return } rule.Ports = []int{port} @@ -98,7 +90,3 @@ func hostAddrs(ctx context.Context, host string, resolve resolver) ([]netip.Addr } return addrs, nil } - -func isGlobal(addr netip.Addr) bool { - return addr.IsGlobalUnicast() && !slices.ContainsFunc(privatePrefixes, func(p netip.Prefix) bool { return p.Contains(addr) }) -} diff --git a/services/core/internal/sessions/artifacts_test.go b/services/core/internal/sessions/artifacts_test.go index 9b1a3b7be..89f0e5226 100644 --- a/services/core/internal/sessions/artifacts_test.go +++ b/services/core/internal/sessions/artifacts_test.go @@ -293,6 +293,11 @@ func (s *fakeArtifactStorage) FindCreation(context.Context, string, string) (Cre return CreationRecord{}, nil } +func (s *fakeArtifactStorage) WithEnvironmentCreation(context.Context, string, EnvironmentIdentity, func(context.Context, StandaloneCreationTx) error) error { + s.t.Fatal("unexpected call to WithEnvironmentCreation") + return nil +} + func discard(content io.Reader, size int64) error { _, err := io.CopyN(io.Discard, content, size) return err diff --git a/services/core/internal/sessions/creation.go b/services/core/internal/sessions/creation.go index f2f9367a4..bea48eddd 100644 --- a/services/core/internal/sessions/creation.go +++ b/services/core/internal/sessions/creation.go @@ -6,7 +6,6 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "errors" "fmt" "slices" "strconv" @@ -65,40 +64,24 @@ type CreationRecord struct { type CreationTx interface { InputAdmissionTx InputActivityTx + EnvironmentCreationTx // UpsertSession inserts the Session or, under the tenant and key, returns // the stored one with Created false when its creator and identity match: // the intent hash when the stored Session has one, else the request hash. // A deleted or mismatched one is ErrIdempotencyConflict. Cursor is the // Session's event sequence before anything the transaction admits. UpsertSession(ctx context.Context, session NewSession) (Creation, error) - // LockDeployment locks and reads the sandbox deployment, serializing - // hosted creation with deployment changes, restore and node removal. - LockDeployment(ctx context.Context) (placement.Deployment, error) - // LockSkills locks the tenant's Skills in ID order, whatever the order of - // ids, and returns them by ID. A missing one is ErrNotFound. - LockSkills(ctx context.Context, ids []string) (map[string]skills.Skill, error) - // ReadSkillVersion opens and verifies a version of a locked Skill. A - // missing one is ErrNotFound; one that does not open or verify is an - // internal error. - ReadSkillVersion(ctx context.Context, skill string, version int64) (skills.Content, error) + // AttachEnvironment locks the tenant's Environment and attaches the new + // Session to it. A missing one, or a Session-owned one whose Session is + // gone, is ErrNotFound; one with a live Session, Session-owned or not, is + // ErrEnvironmentAttached; and a failed or expired one is + // ErrEnvironmentTerminal. It runs before the deployment lock. + AttachEnvironment(ctx context.Context, environment string) error // SaveModelExecution seals the Session's model provider bundle. SaveModelExecution(ctx context.Context, provider v1.ModelProviderInput) error // SaveExecutionConfiguration records the frozen execution projection and, // unless it is uuid.Nil, the deployment provider revision it resolved. 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 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 the Environment's configuration. - SaveSetup(ctx context.Context, setup environmentconfig.Setup) 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. @@ -150,88 +133,68 @@ func (s *Service) CreateSession(ctx context.Context, tenant string, input Create return result, nil } -// createResources freezes what the new Session keeps, creates its Environment -// and admits its initial inputs, in lock order: the hosted deployment, the -// Skills, then the Session's own rows. It returns the resources created. +// createResources creates the new Session's Environment, freezes what the +// Session keeps and admits its initial inputs, in lock order: the Environment's +// steps, then the Session's own rows. It returns the resources created. func (s *Service) createResources(ctx context.Context, tx CreationTx, session Session, input CreateSession, batch []Input, encoded json.RawMessage) ([]writeaudit.Resource, error) { - var configuration struct { - Environment struct { - Type string `json:"type"` - } `json:"environment"` - } - if err := json.Unmarshal(session.Configuration, &configuration); err != nil { - return nil, err - } - hosted := configuration.Environment.Type == "openai_hosted" - var deployment placement.Deployment - if hosted { - if s.rules == nil { - return nil, errors.New("hosted Session creation requires placement rules") - } - var err error - if deployment, err = tx.LockDeployment(ctx); err != nil { - return nil, err - } - if err := s.rules.CheckAdmission(deployment, ""); err != nil { - return nil, err - } - } - setup, err := freezeSkills(ctx, tx, input.Initialization) + created := []writeaudit.Resource{{Type: writeaudit.ResourceSession, ID: session.ID}} + creates, err := createsEnvironment(session.Configuration) if err != nil { return nil, err } - if input.ModelProvider != nil { - if err := tx.SaveModelExecution(ctx, *input.ModelProvider); err != nil { + environment, retained := input.EnvironmentID, false + var target sandbox.DeploymentSpec + if environment != "" { + // Lock order is Session, Environment, deployment. Attaching starts hosted + // work, so it meets the same deployment state as a new Environment; the + // Environment's own admission already ran. + if err := tx.AttachEnvironment(ctx, environment); err != nil { return nil, err } - } - if input.ExecutionConfiguration != nil { - projection, revision, err := freezeProjection(session, *input.ExecutionConfiguration, input.ModelProvider, input.ModelProviderSource, input.DeploymentProviderRevision) - if err != nil { + if _, err := s.admitHosted(ctx, tx); err != nil { return nil, err } - if err := tx.SaveExecutionConfiguration(ctx, projection, revision); err != nil { + } else if creates { + var snapshot struct { + Environment struct { + Type string `json:"type"` + } `json:"environment"` + } + if err := json.Unmarshal(session.Configuration, &snapshot); err != nil { 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 { + hosted := snapshot.Environment.Type == "openai_hosted" + var deployment placement.Deployment + if environment, deployment, err = s.createEnvironment(ctx, tx, environmentPreparation{Hosted: hosted, Configuration: configuration, Initialization: input.Initialization, InitialFiles: input.InitialFiles}); 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 - } - if err := tx.SaveInitialFiles(ctx, input.InitialFiles); err != nil { - return nil, err + if hosted { + if err := json.Unmarshal(deployment.Specification, &target); err != nil { + return nil, err + } + retained = true } } - if !setup.Empty() { - if err := tx.SaveSetup(ctx, setup); err != nil { + if input.ModelProvider != nil { + if err := tx.SaveModelExecution(ctx, *input.ModelProvider); err != nil { return nil, err } } - if hosted { - if err := s.rules.CheckPublicOrigin(deployment.Provider); err != nil { + if input.ExecutionConfiguration != nil { + projection, revision, err := freezeProjection(session, *input.ExecutionConfiguration, input.ModelProvider, input.ModelProviderSource, input.DeploymentProviderRevision) + if err != nil { return nil, err } - var target sandbox.DeploymentSpec - if err := json.Unmarshal(deployment.Specification, &target); err != nil { + if err := tx.SaveExecutionConfiguration(ctx, projection, revision); err != nil { return nil, err } + } + if retained { if err := ValidateRetainedHistory(target.Workspace != nil, input.SupportsRetainedNativeHistory); err != nil { return nil, err } @@ -267,7 +230,7 @@ func (s *Service) createResources(ctx context.Context, tx CreationTx, session Se // selects, read under the Skill locks, so the result no longer depends on any // Skill. The locks serialize selection with default changes and version and // Skill deletion. -func freezeSkills(ctx context.Context, tx CreationTx, setup environmentconfig.Setup) (environmentconfig.Setup, error) { +func freezeSkills(ctx context.Context, tx EnvironmentCreationTx, setup environmentconfig.Setup) (environmentconfig.Setup, error) { if setup.Validate() != nil { return environmentconfig.Setup{}, ErrInvalidInput } diff --git a/services/core/internal/sessions/creation_test.go b/services/core/internal/sessions/creation_test.go index 9d45582ac..48b2bb70a 100644 --- a/services/core/internal/sessions/creation_test.go +++ b/services/core/internal/sessions/creation_test.go @@ -34,6 +34,7 @@ type fakeCreationTx struct { saveInitialFiles func() error saveSetup func() error createEnvironment func() (string, error) + attachEnvironment func() error pruneChanges func() error auditCreation func() error loadSession func() (Session, error) @@ -85,6 +86,11 @@ func (f *fakeCreationTx) SaveSetup(_ context.Context, setup environmentconfig.Se return f.saveSetup() } +func (f *fakeCreationTx) AttachEnvironment(_ context.Context, environment string) error { + f.record("AttachEnvironment", f.attachEnvironment != nil, environment) + return f.attachEnvironment() +} + func (f *fakeCreationTx) CreateEnvironment(context.Context, json.RawMessage, bool) (string, error) { f.record("CreateEnvironment", f.createEnvironment != nil) return f.createEnvironment() @@ -124,6 +130,11 @@ func (s *fakeStorage) FindCreation(_ context.Context, tenant, key string) (Creat return s.findCreation() } +func (s *fakeStorage) WithEnvironmentCreation(context.Context, string, EnvironmentIdentity, func(context.Context, StandaloneCreationTx) error) error { + s.record("WithEnvironmentCreation", false) + return nil +} + // fingerprints is a fake FingerprintProviderKey that keeps keys apart. func fingerprints(secret string) (string, error) { return "fp-" + secret, nil } @@ -410,8 +421,8 @@ func TestCreateSession(t *testing.T) { if err != nil || !result.Created || result.Cursor != 4 || result.Session.Engine != "loaded" { 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(), "CreateEnvironment", "SaveInitialFiles 1", "SaveSetup skill_a@3", + want := []string{"UpsertSession create", "LockSkills skill_a", "ReadSkillVersion skill_a 3", "CreateEnvironment", "SaveInitialFiles 1", "SaveSetup skill_a@3", + "SaveModelExecution https://model.example/v1", "SaveExecutionConfiguration available " + uuid.Nil.String(), "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_creation.go b/services/core/internal/sessions/environment_creation.go new file mode 100644 index 000000000..0023c0c66 --- /dev/null +++ b/services/core/internal/sessions/environment_creation.go @@ -0,0 +1,242 @@ +package sessions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" +) + +// EnvironmentCreationTx is the part of a creation transaction that creates and +// prepares an Environment, shared by Session creation and standalone +// Environment creation. +type EnvironmentCreationTx interface { + // LockDeployment locks and reads the sandbox deployment, serializing + // hosted creation with deployment changes, restore and node removal. + LockDeployment(ctx context.Context) (placement.Deployment, error) + // LockSkills locks the tenant's Skills in ID order, whatever the order of + // ids, and returns them by ID. A missing one is ErrNotFound. + LockSkills(ctx context.Context, ids []string) (map[string]skills.Skill, error) + // ReadSkillVersion opens and verifies a version of a locked Skill. A + // missing one is ErrNotFound; one that does not open or verify is an + // internal error. + ReadSkillVersion(ctx context.Context, skill string, version int64) (skills.Content, error) + // CreateEnvironment creates the Environment with the configuration, + // pending preparation when prepared, and returns its ID. A Session's + // Environment is attached to the Session. It precedes the setup and + // initial files, which are saved for it. + CreateEnvironment(ctx context.Context, configuration json.RawMessage, prepared bool) (string, error) + // SaveInitialFiles copies the validated initial files, a file_id one from + // its locked source File, seals them under the Environment and records + // their metadata in its 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 Environment and records + // its metadata in its configuration. + SaveSetup(ctx context.Context, setup environmentconfig.Setup) error +} + +// environmentPreparation is what creating an Environment freezes. +type environmentPreparation struct { + Hosted bool + Configuration json.RawMessage + Initialization environmentconfig.Setup + InitialFiles []environmentconfig.InitialFile +} + +// admitHosted locks the sandbox deployment and checks that it admits new +// hosted work, which a pending reset or a closed deployment refuses. +func (s *Service) admitHosted(ctx context.Context, tx EnvironmentCreationTx) (placement.Deployment, error) { + if s.rules == nil { + return placement.Deployment{}, errors.New("hosted Environment admission requires placement rules") + } + deployment, err := tx.LockDeployment(ctx) + if err != nil { + return deployment, err + } + return deployment, s.rules.CheckAdmission(deployment, "") +} + +// createEnvironment is the one path that creates and prepares an Environment, +// in lock order: the hosted deployment, then the Skills. It returns the +// Environment ID and the locked deployment, empty unless hosted. +func (s *Service) createEnvironment(ctx context.Context, tx EnvironmentCreationTx, in environmentPreparation) (string, placement.Deployment, error) { + var deployment placement.Deployment + if in.Hosted { + var err error + if deployment, err = s.admitHosted(ctx, tx); err != nil { + return "", deployment, err + } + } + setup, err := freezeSkills(ctx, tx, in.Initialization) + if err != nil { + return "", deployment, err + } + environment, err := tx.CreateEnvironment(ctx, in.Configuration, in.Hosted || len(in.InitialFiles) > 0 || !setup.Empty()) + if err != nil { + return "", deployment, err + } + if len(in.InitialFiles) > 0 { + if environmentconfig.ValidateInitialFiles(in.InitialFiles) != nil { + return "", deployment, ErrInvalidInput + } + if err := tx.SaveInitialFiles(ctx, in.InitialFiles); err != nil { + return "", deployment, err + } + } + if !setup.Empty() { + if err := tx.SaveSetup(ctx, setup); err != nil { + return "", deployment, err + } + } + if in.Hosted { + if err := s.rules.CheckPublicOrigin(deployment.Provider); err != nil { + return "", deployment, err + } + } + return environment, deployment, nil +} + +// EnvironmentCreationStorage persists standalone Environment creation. +type EnvironmentCreationStorage interface { + // WithEnvironmentCreation runs apply in one pooled transaction that + // serializes creators of the identity's key and commits only when apply + // returns nil. CreateEnvironment stores the identity with the standalone + // Environment. A malformed tenant is ErrInvalidInput. + WithEnvironmentCreation(ctx context.Context, tenant string, identity EnvironmentIdentity, apply func(context.Context, StandaloneCreationTx) error) error +} + +// EnvironmentIdentity is the retry identity of a standalone Environment. +type EnvironmentIdentity struct { + Key string + Hash string + Creator identity.Subject +} + +// EnvironmentCreationRecord is a stored standalone creation. +type EnvironmentCreationRecord struct { + EnvironmentID string + EnvironmentIdentity +} + +// StandaloneCreationTx is the standalone Environment creation transaction. +type StandaloneCreationTx interface { + EnvironmentCreationTx + // FindCreation reads the Environment stored under the transaction's + // identity key, or ErrNotFound. The transaction holds the key until it + // ends, so it either finds the stored one or is the only creator. + FindCreation(ctx context.Context) (EnvironmentCreationRecord, error) + // AuditCreation records the create write audit of the Environment, + // listing created. + AuditCreation(ctx context.Context, environment string, created ...writeaudit.Resource) error +} + +// CreateEnvironment is a validated standalone Environment creation. +type CreateEnvironment struct { + Creator identity.Subject + IdempotencyKey string + // CreationRequest is the caller's request, the retry identity. + CreationRequest json.RawMessage + Configuration json.RawMessage + Initialization environmentconfig.Setup + InitialFiles []environmentconfig.InitialFile +} + +func (s *Service) environmentIdentity(key string, creator identity.Subject, request json.RawMessage) (EnvironmentIdentity, error) { + if err := creator.Validate(); err != nil { + return EnvironmentIdentity{}, fmt.Errorf("%w: %v", ErrInvalidInput, err) + } + if key == "" || len(key) > 256 { + return EnvironmentIdentity{}, ErrInvalidInput + } + hash, err := intentHash(request, s.storage.FingerprintProviderKey) + if err != nil { + return EnvironmentIdentity{}, err + } + if hash == nil { + return EnvironmentIdentity{}, ErrInvalidInput + } + return EnvironmentIdentity{Key: key, Hash: *hash, Creator: creator}, nil +} + +// decide accepts a stored creation for the identity or reports a conflict. +func (i EnvironmentIdentity) decide(record EnvironmentCreationRecord) error { + if record.EnvironmentIdentity != i { + return ErrIdempotencyConflict + } + return nil +} + +// ReplayEnvironment returns the Environment already stored under the key, if +// any, under the creator's key for the same request. It runs before the request's +// references resolve, so a retry compares the request as sent. +func (s *Service) ReplayEnvironment(ctx context.Context, tenant string, creator identity.Subject, key string, request json.RawMessage) (string, bool, error) { + ident, err := s.environmentIdentity(key, creator, request) + if err != nil { + return "", false, err + } + var environment string + err = s.storage.WithEnvironmentCreation(ctx, tenant, ident, func(ctx context.Context, tx StandaloneCreationTx) error { + record, err := tx.FindCreation(ctx) + if errors.Is(err, ErrNotFound) { + return nil + } + if err != nil { + return err + } + if err := ident.decide(record); err != nil { + return err + } + environment = record.EnvironmentID + return nil + }) + return environment, environment != "", err +} + +// CreateEnvironment creates a standalone Environment under a key scoped to the +// Project and creator, so retries, including concurrent ones, return the stored +// Environment. The same key with another request is ErrIdempotencyConflict. +func (s *Service) CreateEnvironment(ctx context.Context, tenant string, input CreateEnvironment) (string, error) { + ident, err := s.environmentIdentity(input.IdempotencyKey, input.Creator, input.CreationRequest) + if err != nil { + return "", err + } + if len(input.Configuration) > 512*1024 { + return "", fmt.Errorf("%w: configuration exceeds 512 KiB", ErrInvalidInput) + } + configuration, err := jsonobject.Normalize(input.Configuration) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidInput, err) + } + var environment string + err = s.storage.WithEnvironmentCreation(ctx, tenant, ident, func(ctx context.Context, tx StandaloneCreationTx) error { + var created []writeaudit.Resource + record, err := tx.FindCreation(ctx) + switch { + case err == nil: + if err := ident.decide(record); err != nil { + return err + } + environment = record.EnvironmentID + case errors.Is(err, ErrNotFound): + if environment, _, err = s.createEnvironment(ctx, tx, environmentPreparation{Hosted: true, Configuration: configuration, Initialization: input.Initialization, InitialFiles: input.InitialFiles}); err != nil { + return err + } + created = []writeaudit.Resource{{Type: writeaudit.ResourceEnvironment, ID: environment}} + default: + return err + } + return tx.AuditCreation(ctx, environment, created...) + }) + if err != nil { + return "", fmt.Errorf("create environment: %w", err) + } + return environment, nil +} diff --git a/services/core/internal/sessions/errors.go b/services/core/internal/sessions/errors.go index b938dfc57..f93b5b163 100644 --- a/services/core/internal/sessions/errors.go +++ b/services/core/internal/sessions/errors.go @@ -18,6 +18,14 @@ var ErrTurnConflict = errors.New("turn state changed or cancellation was request // still waits for admission. It remains a Turn conflict for internal callers. var ErrInputPending = fmt.Errorf("%w: session input is still pending", ErrTurnConflict) +// ErrEnvironmentAttached rejects attaching a Session to an Environment that +// another live Session is attached to. +var ErrEnvironmentAttached = errors.New("environment is attached to another session") + +// ErrEnvironmentTerminal rejects attaching a Session to an Environment that +// failed or expired. +var ErrEnvironmentTerminal = errors.New("environment has failed or expired") + // ErrHostedEnvironmentFailed rejects new input on a Session whose hosted // Environment failed to provision. It remains ErrEnvironmentUnavailable for // internal callers; an expired Environment keeps that plain error. diff --git a/services/core/internal/sessions/service.go b/services/core/internal/sessions/service.go index fe4d90bdf..2241cfd65 100644 --- a/services/core/internal/sessions/service.go +++ b/services/core/internal/sessions/service.go @@ -27,6 +27,7 @@ func NewService(storage Storage, rules *placement.Rules) (*Service, error) { type Storage interface { ArtifactStorage CreationStorage + EnvironmentCreationStorage DeviceStorage ExecutorCredentialStorage SessionStorage diff --git a/services/core/internal/sessions/session.go b/services/core/internal/sessions/session.go index efde00f16..827060226 100644 --- a/services/core/internal/sessions/session.go +++ b/services/core/internal/sessions/session.go @@ -62,6 +62,9 @@ type CreateSession struct { IdempotencyKey string Configuration json.RawMessage InitialInputs []Input + // EnvironmentID names the standalone Environment the Session attaches to + // instead of creating one. Configuration then carries its composed snapshot. + EnvironmentID string } type Page struct { diff --git a/services/core/migrations/000015_standalone_environments.sql b/services/core/migrations/000015_standalone_environments.sql new file mode 100644 index 000000000..105541daa --- /dev/null +++ b/services/core/migrations/000015_standalone_environments.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- A creation key deduplicates within the Project and the creator. +ALTER TABLE environments + DROP CONSTRAINT environments_tenant_id_idempotency_key_key, + ADD CONSTRAINT environments_creation_key UNIQUE (tenant_id, creator_kind, creator_id, idempotency_key); + +-- The two facts every lifecycle path reads, derived here and nowhere else. +-- ended: no live Session holds the Environment and it is Session-owned or failed/expired, so nothing can run on it again. +-- active: not ended and not failed/expired, so it can still run work. +CREATE VIEW environment_liveness AS +SELECT x.id, x.ended, (NOT x.ended AND x.status NOT IN ('failed', 'expired')) AS active +FROM ( + SELECT e.id, e.status, + (NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = e.id AND s.deleted_at IS NULL) + AND (e.session_owned OR e.status IN ('failed', 'expired'))) AS ended + FROM environments e) x; + +-- The reset retention row of an active Environment (the view of migration 000014, reading the liveness view). +CREATE OR REPLACE 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 +JOIN environment_liveness l ON l.id = e.id AND l.active +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 e.created_at <= d.reset_requested_at + 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); diff --git a/services/core/tests/integration/environments_public_test.go b/services/core/tests/integration/environments_public_test.go new file mode 100644 index 000000000..50d0eab82 --- /dev/null +++ b/services/core/tests/integration/environments_public_test.go @@ -0,0 +1,243 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" +) + +func TestStandaloneEnvironmentOfficialClientPostgres(t *testing.T) { + python := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + s, _ := configuredStore(t) + tenant, foreignTenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + auth := newTestAuthenticator(t, []testAPIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "prewarm-owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "prewarm-foreign", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: foreignTenant}, + }) + handler, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + settings, err := json.Marshal(map[string]string{"base": server.URL, "token": token, "foreign": foreign}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_environments.py") + command.Stdin = bytes.NewReader(settings) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("standalone Environment official client: %v %s", err, output) + } + var receipt struct { + EnvironmentID string `json:"environment_id"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(output, &receipt); err != nil { + t.Fatalf("invalid acceptance receipt: %v %s", err, output) + } + t.Cleanup(func() { + if err := sessionService(t, s).DeleteSession(context.Background(), sessions.DeleteSessionCommand{TenantID: tenant, SessionID: receipt.SessionID}); err != nil { + t.Error(err) + } + }) + // The Environment, not the Session, owns the row, and survives the first Session. + var owned bool + var attached int + if err := s.pool.QueryRow(t.Context(), "SELECT e.session_owned, (SELECT count(*) FROM sessions WHERE environment_id=e.id) FROM environments e WHERE e.id=$1 AND e.tenant_id=$2", receipt.EnvironmentID, tenant).Scan(&owned, &attached); err != nil || owned || attached != 2 { + t.Fatalf("standalone Environment ownership: owned=%v sessions=%d %v", owned, attached, err) + } +} + +// publicPoster returns a request function for the public API under one Project key. +func publicPoster(t *testing.T, s *Store, tenant, subject string) func(method, path, body string, headers ...string) (int, string) { + t.Helper() + token := uuid.NewString() + auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: subject, TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) + handler, err := publicHandler(t, s, auth, "codex", fixtureDeploymentProvider()) + if err != nil { + t.Fatal(err) + } + return func(method, path, body string, headers ...string) (int, string) { + request := httptest.NewRequest(method, "/v1"+path, strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Content-Type", "application/json") + for i := 0; i+1 < len(headers); i += 2 { + request.Header.Set(headers[i], headers[i+1]) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder.Code, recorder.Body.String() + } +} + +func createStandalone(t *testing.T, request func(method, path, body string, headers ...string) (int, string)) string { + t.Helper() + code, body := request("POST", "/agents/environments", `{"environment":{"type":"openai_hosted"}}`) + var environment struct { + ID string `json:"id"` + } + if code != 201 || json.Unmarshal([]byte(body), &environment) != nil { + t.Fatal("create Environment", code, body) + } + return environment.ID +} + +// Concurrent attaches of one standalone Environment have exactly one winner, +// and the others get the attach conflict. +func TestConcurrentAttachHasOneWinnerPostgres(t *testing.T) { + s, _ := configuredStore(t) + request := publicPoster(t, s, uuid.NewString(), "attach-owner") + environment := createStandalone(t, request) + codes, bodies := make([]int, 6), make([]string, 6) + var wg sync.WaitGroup + for i := range codes { + wg.Go(func() { + codes[i], bodies[i] = request("POST", "/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"openai_hosted","environment_id":"`+environment+`"}}`) + }) + } + wg.Wait() + created := 0 + for i, code := range codes { + switch { + case code == 201: + created++ + case code == 409 && strings.Contains(bodies[i], "Environment is attached to another session."): + default: + t.Fatal("unexpected attach outcome", code, bodies[i]) + } + } + if created != 1 { + t.Fatal("attach winners", codes) + } +} + +// An attach waits for the Environment lock without holding the deployment +// lock, so the lock order stays Session, Environment, deployment. +func TestAttachWaitsForTheEnvironmentBeforeTheDeploymentPostgres(t *testing.T) { + s, _ := configuredStore(t) + request := publicPoster(t, s, uuid.NewString(), "attach-order") + environment := createStandalone(t, request) + held, err := s.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer held.Rollback(context.Background()) + if _, err := held.Exec(t.Context(), "SELECT 1 FROM environments WHERE id=$1 FOR UPDATE", environment); err != nil { + t.Fatal(err) + } + done := make(chan int, 1) + go func() { + code, _ := request("POST", "/agents/sessions", `{"agent":{"model":"fixture"},"environment":{"type":"openai_hosted","environment_id":"`+environment+`"}}`) + done <- code + }() + for deadline, waiting := time.Now().Add(10*time.Second), 0; waiting == 0; time.Sleep(10 * time.Millisecond) { + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'").Scan(&waiting); err != nil { + t.Fatal(err) + } + if waiting == 0 && time.Now().After(deadline) { + t.Fatal("the attach never waited for the Environment lock") + } + } + other, err := s.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer other.Rollback(context.Background()) + if _, err := other.Exec(t.Context(), "SELECT 1 FROM runtime_deployment WHERE singleton = true FOR UPDATE NOWAIT"); err != nil { + t.Fatal("the waiting attach holds the deployment lock", err) + } + other.Rollback(t.Context()) + held.Rollback(t.Context()) + if code := <-done; code != 201 { + t.Fatal("attach status", code) + } +} + +// Another principal in the same Project reads an empty list. +func TestStandaloneEnvironmentListIsScopedToItsCreatorPostgres(t *testing.T) { + s, _ := configuredStore(t) + tenant := uuid.NewString() + owner, other := publicPoster(t, s, tenant, "list-owner"), publicPoster(t, s, tenant, "list-other") + environment := createStandalone(t, owner) + list := func(request func(method, path, body string, headers ...string) (int, string)) []string { + code, body := request("GET", "/agents/environments", "") + var page struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if code != 200 || json.Unmarshal([]byte(body), &page) != nil { + t.Fatal("list Environments", code, body) + } + ids := []string{} + for _, item := range page.Data { + ids = append(ids, item.ID) + } + return ids + } + if ids := list(owner); len(ids) != 1 || ids[0] != environment { + t.Fatal("the creator's list", ids) + } + if ids := list(other); len(ids) != 0 { + t.Fatal("another principal's list", ids) + } + if code, _ := other("GET", "/agents/environments/"+environment, "", ""); code != 200 { + t.Fatal("retrieve stays Project-scoped", code) + } +} + +// Concurrent creates under one key return one Environment. +func TestConcurrentCreatesWithOneKeyMakeOneEnvironmentPostgres(t *testing.T) { + s, _ := configuredStore(t) + tenant := uuid.NewString() + request := publicPoster(t, s, tenant, "create-owner") + ids := make([]string, 6) + var wg sync.WaitGroup + for i := range ids { + wg.Go(func() { + code, body := request("POST", "/agents/environments", `{"environment":{"type":"openai_hosted"}}`, "Idempotency-Key", "one-key") + var environment struct { + ID string `json:"id"` + } + if code != 201 || json.Unmarshal([]byte(body), &environment) != nil { + t.Error("create", code, body) + } + ids[i] = environment.ID + }) + } + wg.Wait() + var stored int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM environments WHERE tenant_id=$1", tenant).Scan(&stored); err != nil || stored != 1 { + t.Fatal("stored Environments", stored, err) + } + for _, id := range ids { + if id != ids[0] { + t.Fatal("creates returned different Environments", ids) + } + } + // The pinned Idempotency-Key is 1 to 256 bytes. + for _, key := range []string{"", strings.Repeat("k", 257)} { + if code, body := request("POST", "/agents/environments", `{"environment":{"type":"openai_hosted"}}`, "Idempotency-Key", key); code != 400 { + t.Error("invalid key", len(key), code, body) + } + } +} diff --git a/services/core/tests/integration/runtime_allocations_test.go b/services/core/tests/integration/runtime_allocations_test.go index 4c9f474ac..1b05f2431 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.Reclaimed || retained.ID != owner.ID { + if err != nil || !retained.Ended || 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_nodes_test.go b/services/core/tests/integration/runtime_nodes_test.go index 00ce1533b..417cb9df2 100644 --- a/services/core/tests/integration/runtime_nodes_test.go +++ b/services/core/tests/integration/runtime_nodes_test.go @@ -98,6 +98,10 @@ func reserveSessionPlacement(t *testing.T, s, w *Store, session sessions.Session if err != nil { return err } + // Like the manager's reservation step, release the placements of ended Environments first. + if err = deploymentExecution(t, w).ReleaseInactivePlacements(t.Context()); err != nil { + return err + } _, err = deploymentExecution(t, w).EnsurePlacement(t.Context(), deployment.AllocationKey{TenantID: session.TenantID, EnvironmentID: session.Environment.ID}, setup.InstallationID) return err } diff --git a/services/core/tests/integration/runtime_suspension_test.go b/services/core/tests/integration/runtime_suspension_test.go index d0b0541b0..00afbbc23 100644 --- a/services/core/tests/integration/runtime_suspension_test.go +++ b/services/core/tests/integration/runtime_suspension_test.go @@ -259,7 +259,7 @@ func TestRuntimeSuspensionRetentionAndDeletedSession(t *testing.T) { t.Fatal(err) } deleted, err := deploymentStore(s).EnvironmentAllocation(t.Context(), deployment.AllocationKey{TenantID: owner.TenantID, EnvironmentID: owner.EnvironmentID}) - if err != nil || !deleted.Reclaimed || deleted.ComputeWakeRequested { + if err != nil || !deleted.Ended || 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) { diff --git a/services/core/tests/integration/sandbox_deployment_setup_test.go b/services/core/tests/integration/sandbox_deployment_setup_test.go index d066964d3..a0a92011e 100644 --- a/services/core/tests/integration/sandbox_deployment_setup_test.go +++ b/services/core/tests/integration/sandbox_deployment_setup_test.go @@ -45,7 +45,7 @@ func TestSandboxDeploymentSetupPersistsWithoutExecution(t *testing.T) { } } setup, err := deploymentService(t, s).Setup(t.Context()) - if err != nil || setup.Suspension == nil || setup.Suspension.IdleSeconds != 300 || setup.Suspension.RetentionSeconds != 86400 || !sha256Hex(setup.BackendFingerprint) { + if err != nil || setup.Suspension == nil || setup.Suspension.RetentionSeconds != 86400 || !sha256Hex(setup.BackendFingerprint) { t.Fatal(setup, err) } var sideEffects int diff --git a/services/core/tests/integration/sandbox_deployment_view_test.go b/services/core/tests/integration/sandbox_deployment_view_test.go index 84498ff6c..6482607bd 100644 --- a/services/core/tests/integration/sandbox_deployment_view_test.go +++ b/services/core/tests/integration/sandbox_deployment_view_test.go @@ -38,7 +38,8 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { for _, want := range []string{ `"specification":{"resources":{"cpus":2,"memory_mib":2048},"network":{"public":true,"domains":[]}}`, `"template_build":{"status":"ready","resources":{"cpus":2,"memory_mib":2048,"root_disk_mib":24063}}`, - `"suspension":{"idle_seconds":300,"retention_seconds":86400}`, + `"idle_seconds":300`, + `"suspension":{"retention_seconds":86400}`, } { if !bytes.Contains(raw, []byte(want)) { t.Fatalf("E2B view lacks %s: %s", want, raw) @@ -72,7 +73,7 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { t.Fatalf("identical PUT did not record the build: %s %v", raw, err) } view, err = resetAndSelect(t, w, id, 1, sandbox.Selection{DeploymentSpec: SandboxDeploymentTestSpec("microsandbox"), Provider: "microsandbox"}) - if err != nil || string(view.Configuration) != "{}" || view.Suspension == nil || view.Suspension.IdleSeconds != 300 || view.Suspension.RetentionSeconds != 86400 { + if err != nil || string(view.Configuration) != "{}" || view.Suspension == nil || view.IdleSeconds != 300 || view.Suspension.RetentionSeconds != 86400 { t.Fatalf("microsandbox suspension view = %+v %v", view, err) } } diff --git a/services/core/tests/integration/sandbox_reset_test.go b/services/core/tests/integration/sandbox_reset_test.go index 03a3f9c21..00b459084 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, 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) + 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=$1 RETURNING id) UPDATE sessions SET environment_id=(SELECT id FROM e) WHERE id=$1`, id) if i <= 34 { runtimeSuspensionSQL(t, s.pool, `INSERT INTO turns(id,session_id,status,settings) VALUES($1,$2,'in_progress','{}')`, uuid.NewString(), id) } @@ -350,7 +350,7 @@ func TestSandboxResetPaginationSkipsBusyPrefixAndPreservesSelfHosted(t *testing. if err != nil || len(first) != 32 { t.Fatal(first, err) } - second, err := deploymentStore(w).ResetSessions(t.Context(), first[31].SessionID, true) + second, err := deploymentStore(w).ResetSessions(t.Context(), first[31].EnvironmentID, true) if err != nil || len(second) != 2 { t.Fatal(second, err) } diff --git a/services/core/tests/integration/self_hosted_initial_public_test.go b/services/core/tests/integration/self_hosted_initial_public_test.go index 329d15a6a..adcafc792 100644 --- a/services/core/tests/integration/self_hosted_initial_public_test.go +++ b/services/core/tests/integration/self_hosted_initial_public_test.go @@ -17,6 +17,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" @@ -253,6 +254,14 @@ func (unavailableAdmission) CreateSession(context.Context, string, sessions.Crea return sessions.Creation{}, execution.ErrExecutionUnavailable } +func (unavailableAdmission) ReplayEnvironment(context.Context, string, identity.Subject, string, json.RawMessage) (string, bool, error) { + return "", false, execution.ErrExecutionUnavailable +} + +func (unavailableAdmission) CreateEnvironment(context.Context, string, sessions.CreateEnvironment) (string, error) { + return "", execution.ErrExecutionUnavailable +} + func (unavailableAdmission) SubmitInputs(context.Context, string, string, string, []sessions.Input) ([]sessions.InputReceipt, error) { return nil, execution.ErrExecutionUnavailable } diff --git a/services/core/tests/integration/session_deletion_test.go b/services/core/tests/integration/session_deletion_test.go index e6315a6cb..dcb6956c7 100644 --- a/services/core/tests/integration/session_deletion_test.go +++ b/services/core/tests/integration/session_deletion_test.go @@ -406,6 +406,9 @@ func TestSessionDeletionKeepsProvisioningInputPlacementUntilSettled(t *testing.T if err := sessionService(t, s).DeleteSession(ctx, sessions.DeleteSessionCommand{TenantID: tenant, SessionID: session.ID}); err != nil { t.Fatal(err) } + if err := deploymentExecution(t, w).ReleaseInactivePlacements(ctx); err != nil { + t.Fatal(err) + } deleted := read() if !deleted.deleted.Valid || !deleted.released.Valid || deleted.retained != 0 || deleted.reserved != 0 { t.Fatal("allowed deletion kept the placement", deleted) diff --git a/services/core/tests/integration/template_composition_public_test.go b/services/core/tests/integration/template_composition_public_test.go index 497dcfa44..efde5ee45 100644 --- a/services/core/tests/integration/template_composition_public_test.go +++ b/services/core/tests/integration/template_composition_public_test.go @@ -138,7 +138,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { {"SELECT count(*) FROM sessions WHERE 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 initial_environment_files f JOIN sessions s ON s.environment_id=f.environment_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 1736df52e..38712502e 100644 --- a/services/core/tests/integration/template_null_selection_public_test.go +++ b/services/core/tests/integration/template_null_selection_public_test.go @@ -139,7 +139,7 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { {"SELECT count(*) FROM sessions WHERE 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 initial_environment_files f JOIN sessions s ON s.environment_id=f.environment_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/official_environments.py b/services/core/tests/official_environments.py new file mode 100644 index 000000000..10ec1a017 --- /dev/null +++ b/services/core/tests/official_environments.py @@ -0,0 +1,75 @@ +"""Standalone Environment acceptance over Core HTTP, without execution.""" + +import json +import sys +from contextlib import ExitStack + +import openai +from openai import DefaultHttpxClient, OpenAI + +HOSTED = {"type": "openai_hosted"} + + +def error(call): + try: + call() + except openai.APIStatusError as failure: + body = failure.response.json()["error"] + return failure.status_code, body["code"], body.get("param"), body["message"] + raise AssertionError("request was accepted") + + +def main(): + settings = json.load(sys.stdin) + with ExitStack() as stack: + def sdk(key): + return stack.enter_context(OpenAI(base_url=settings["base"] + "/v1", api_key=key, max_retries=0, + _strict_response_validation=True, http_client=DefaultHttpxClient(trust_env=False))) + + owner, foreign = sdk(settings["token"]), sdk(settings["foreign"]) + api = owner.beta.agents.environments + + env = api.create(environment=HOSTED, idempotency_key="prewarm-1") + assert env.type == "openai_hosted" and env.status == "pending", env + assert api.create(environment=HOSTED, idempotency_key="prewarm-1").id == env.id + assert error(lambda: api.create(environment={**HOSTED, "env": {"A": "b"}}, idempotency_key="prewarm-1"))[:2] == (409, "idempotency_conflict") + assert error(lambda: api.create(environment=HOSTED, vault_ids=["vault"]))[:3] == (400, "invalid_request_error", "vault_ids") + assert api.create(environment=HOSTED, vault_ids=[]).id != env.id + + # A retry compares the request as sent, even after its Template is gone; keys run to 256 bytes. + templates = api.templates + template = templates.create(name="prewarm", env={"A": "b"}) + long_key = "k" * 256 + templated = {"type": "openai_hosted", "environment_template_id": template.id} + kept = api.create(environment=templated, idempotency_key=long_key) + templates.delete(template.id) + assert api.create(environment=templated, idempotency_key=long_key).id == kept.id + + page = api.list(limit=1, order="asc") + assert page.data[0].id == env.id and page.has_more, page + assert {env.id, kept.id} <= {item.id for item in api.list().data} + assert not foreign.beta.agents.environments.list().data + assert error(lambda: foreign.beta.agents.environments.retrieve(env.id))[0] == 404 + + # An unattached Environment has no workspace to serve. + assert error(lambda: api.files.list(env.id))[:3] == (409, "conflict_error", None) + + session = {"agent": {"model": "fixture"}} + assert error(lambda: owner.beta.agents.sessions.create(environment={**HOSTED, "environment_id": env.id, "env": {}}, **session))[:3] == (400, "invalid_request_error", "environment.env") + assert error(lambda: owner.beta.agents.sessions.create(environment={**HOSTED, "environment_id": "short"}, **session))[2] == "environment.environment_id" + assert error(lambda: foreign.beta.agents.sessions.create(environment={**HOSTED, "environment_id": env.id}, **session))[0] == 404 + + first = owner.beta.agents.sessions.create(environment={**HOSTED, "environment_id": env.id}, **session) + assert first.environment.id == env.id, first + assert error(lambda: owner.beta.agents.sessions.create(environment={**HOSTED, "environment_id": env.id}, **session))[:4] == ( + 409, "conflict_error", "environment.environment_id", "Environment is attached to another session.") + assert api.retrieve(env.id).status == "pending" + + # Deleting the Session is the only detach; the Environment takes the next Session. + owner.beta.agents.sessions.delete(first.id) + second = owner.beta.agents.sessions.create(environment={**HOSTED, "environment_id": env.id}, **session) + assert second.environment.id == env.id and second.id != first.id + print(json.dumps({"environment_id": env.id, "session_id": second.id})) + + +main() diff --git a/services/core/tools/microsandbox-provider/network.go b/services/core/tools/microsandbox-provider/network.go index 8038ea062..3fbd251b4 100644 --- a/services/core/tools/microsandbox-provider/network.go +++ b/services/core/tools/microsandbox-provider/network.go @@ -16,10 +16,6 @@ import ( sdk "github.com/superradcompany/microsandbox/sdk/go" ) -var carrierGradeNAT = netip.MustParsePrefix("100.64.0.0/10") - -func isPrivate(a netip.Addr) bool { return a.IsPrivate() || carrierGradeNAT.Contains(a) } - // resolveTimeout bounds the lookup of Core's address. const resolveTimeout = 5 * time.Second @@ -84,7 +80,7 @@ func egressNetwork(ctx context.Context, coreURL string, egress sandbox.Egress, r switch { case err != nil || (ipErr != nil && !sandbox.ValidDomain(host)): fmt.Fprintf(report, "unsupported %s\n", endpoint) - case ipErr == nil && isPrivate(ip.Unmap()): + case ipErr == nil && !sandbox.PublicAddress(ip.Unmap()): default: endpoints = append(endpoints, [2]string{host, strconv.Itoa(p)}) }