From b936394e1bcdcf6886dd23eeec23a8c811c8f6f4 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 19:28:09 +0000 Subject: [PATCH] Derive idle suspension from the checkpoint declaration Core owns one idle suspension policy (300 seconds idle, 86400 seconds retention) and reports it for every Provider that declares checkpoint support. The microsandbox registry constant, its copy in the provider description and the idle_seconds/retention_seconds deployment columns are deleted; migration 000094 drops the columns and rewrites runtime_deployment_setup_check without them. Registration keeps only the declared rule that checkpoint support needs a nodes registration. The API suspension shape is unchanged; its description names the declaration rule instead of a vendor. --- contracts/agents-api/core.openapi.yaml | 2 +- contracts/agents-api/sandbox-deployment.md | 2 +- contracts/agents-api/zh/sandbox-deployment.md | 4 +-- docs/sandbox-provider.md | 8 ++--- docs/zh/sandbox-provider.md | 10 +++--- .../db/queries/sandbox_deployment_setup.sql | 2 +- .../internal/db/queries/sandbox_reset.sql | 3 +- services/core/internal/db/sqlc/models.go | 2 -- .../db/sqlc/runtime_deployment.sql.go | 4 +-- .../internal/db/sqlc/runtime_nodes.sql.go | 4 +-- .../db/sqlc/sandbox_deployment_setup.sql.go | 8 ++--- .../internal/db/sqlc/sandbox_reset.sql.go | 9 ++--- .../core/internal/deployment/execution.go | 2 +- services/core/internal/deployment/service.go | 28 ++++++++------- services/core/internal/deployment/setup.go | 4 +-- services/core/internal/deployment/storage.go | 3 -- services/core/internal/deployment/view.go | 6 ++-- .../postgres/deploymentpg/records.go | 3 +- .../persistence/postgres/deploymentpg/tx.go | 2 +- services/core/internal/sandbox/deployment.go | 5 ++- .../sandbox/providers/registration.go | 15 +++----- .../sandbox/providers/registration_test.go | 35 ++++--------------- .../internal/sandbox/providers/registry.go | 22 ++++++------ .../sandbox/providers/registry_test.go | 13 ++++--- .../000094_suspension_from_declaration.sql | 26 ++++++++++++++ 25 files changed, 99 insertions(+), 123 deletions(-) create mode 100644 services/core/migrations/000094_suspension_from_declaration.sql diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index e26119271..957cba8d8 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1272,7 +1272,7 @@ definitions: suspension: allOf: - $ref: '#/definitions/deployment.Suspension' - description: Idle suspension policy; microsandbox only, otherwise null. + description: Idle suspension policy; null unless the selected Provider declares checkpoint support. x-nullable: true type: object projects.APIKey: diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 94e62f090..ba0db8e65 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -86,7 +86,7 @@ For E2B, post `{"configuration": {"api_url": "…", "domain": "…"}, "credentia 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`. Docker and microsandbox return 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}` for microsandbox, the only provider Core suspends (currently 300 and 86400); Docker, E2B and unconfigured deployments return null. +- `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. - 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. - An unconfigured deployment has an empty provider and no specification. Docker and microsandbox use `mode: nodes`; E2B uses `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`. diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index cf026eb4b..ae1d9a97b 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: 6f765be45518f23ace6384938616eb12aba7554e7f8fbfadb89738f26c692dd5 +source_hash: 7a1bc927bb14e9599e800833f10a5fd123621e3f95bfcbdffa75e7a63654ffe3 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness;部署可以保持未配置状态,既无节点,也不接受托管准入。 @@ -88,7 +88,7 @@ E2B 使用 `template-id:build-uuid` 形式的 `configuration.template`;构建 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`。Docker 和 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 也不会刷新它。 -- `suspension` 对 microsandbox 而言为 `{idle_seconds, retention_seconds}`,microsandbox 是 Core 唯一会暂停的提供商(当前为 300 和 86400);Docker、E2B 和未配置的部署返回 null。 +- 所选 Provider 声明 checkpoint 支持时,`suspension` 为 `{idle_seconds, retention_seconds}`,即 Core 的 [suspension policy](../../../docs/zh/sandbox-provider.md#suspension);其他部署(包括未配置的部署)返回 null。 - 请求中的 `resources` 和响应中的 `specification.resources` 是每个沙箱的限制。响应中的 `resources.allocations` 和 `resources.pending` 分别计算尚未释放的分配,以及尚未分配沙箱的待处理托管 Environment。 - 未配置的部署具有空的 provider 且没有 specification。Docker 和 microsandbox 使用 `mode: nodes`;E2B 使用 `mode: direct`,且没有合成节点。 - `generation` 标识已保存的选择。`owner_epoch` 用于对执行所有者和节点连接进行栅栏隔离;它不能替代 `expected_generation`。 diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 08ea0f988..4cfb26d3a 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -102,14 +102,14 @@ Hosted and self-hosted Environments use the same Runtime preparation; a provider ## Register the provider kind -`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), suspension defaults, the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading. +`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading. A new provider takes these steps: 1. Implement the operation contracts in the adapter package, with native contract tests. 2. Add its specification and resource validators. 3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential and a public Core origin are required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. -4. Register its constructor, policies, configuration adapter, operation declaration and defaults in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The installer's projection combines the registered policies with the shared field bounds in `sandbox/deployment_contract.go`; regenerate it with `go run ./services/core/cmd/specification-contract -write`. +4. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The installer's projection combines the registered policies with the shared field bounds in `sandbox/deployment_contract.go`; regenerate it with `go run ./services/core/cmd/specification-contract -write`. 5. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. **Known design gap:** Web's setup views carry provider-specific options, such as E2B's views. Exposing another provider through that surface currently requires a shared Web edit. This coupling does not meet [Complexity stays in the adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter); new integrations must express their configuration through the protocol and keep vendor-specific behavior in the adapter. Never add a Session or Turn scheduling path, a vendor column or API field, or a vendor switch in the store. @@ -123,7 +123,7 @@ A new provider takes these steps: - A `nodes` registration has only `BuildLocal`, and a `direct` registration only `BuildDirect`; missing, mixed or unknown modes are rejected. - The specification and resource validators, the configuration adapter and a complete operation declaration are mandatory, so an incomplete registration cannot publish a partial installer projection. - The Runtime input policy either accepts the pinned Runtime or gives the adapter's fixed reason for rejecting it, never both. -- Checkpoint is admitted only for a `nodes` registration, because the common lifecycle suspends only node allocations; registration rejects a `direct` Provider that declares it. Checkpoint support also requires positive idle and retention defaults that fit Runtime durations, and a provider without checkpoint support configures no suspension defaults. +- Checkpoint is admitted only for a `nodes` registration, because the common lifecycle suspends only node allocations; registration rejects a `direct` Provider that declares it. A registration carries no suspension values: Core applies its one [suspension policy](#suspension) to every Provider that declares checkpoint support. The configuration adapter must be non-nil, including its concrete value. Every `ConfigurationRequirements` field needs an explicit valid decision: `Credential` and `PublicOrigin` are `Required` or `NotRequired`, and `Discovery`, `SelectionDiscovery` and `CredentialVerification` use the shared supported or unsupported declaration with a safe reason. A new requirement field needs an explicit validation update and never inherits an existing decision. Requiring a credential does not promise the `VerifyCredential` operation. These checks establish complete registration, not correct native SDK behavior; constructor and adapter contract tests still apply. @@ -181,7 +181,7 @@ The deployment's CPU, memory and disk settings, `max_active`, `max_retained` and ### Suspension -A provider with checkpoint support can suspend idle work; the deployment's [`suspension`](../contracts/agents-api/sandbox-deployment.md#safe-response) policy sets the idle time and snapshot retention. Core suspends only after at least one Turn is terminal, when no root or Subagent Turn is queued, in progress or waiting, no input, file operation or initialization is pending, and real activity has been idle for the configured interval. For node allocations Core records the first root or child terminal transition with the database clock in the same transaction. Candidate filtering and the Session-locked recheck compare elapsed database time with the idle duration, and the initial snapshot retention deadline is anchored to the same database observation, so Core and database host clocks need not agree. Native completion timestamps stay unchanged in public history but never drive idle admission, and heartbeats never reset activity. Before acknowledging a planned suspension, the daemon closes admission and drains native cleanup, output receipts and file work. +Core suspends the idle work of every provider that declares checkpoint support, with one fixed policy: it suspends work idle for 5 minutes (300 seconds) and keeps the snapshot for 24 hours (86400 seconds). The deployment's [`suspension`](../contracts/agents-api/sandbox-deployment.md#safe-response) reports these values. Core suspends only after at least one Turn is terminal, when no root or Subagent Turn is queued, in progress or waiting, no input, file operation or initialization is pending, and real activity has been idle for that time. For node allocations Core records the first root or child terminal transition with the database clock in the same transaction. Candidate filtering and the Session-locked recheck compare elapsed database time with the idle duration, and the initial snapshot retention deadline is anchored to the same database observation, so Core and database host clocks need not agree. Native completion timestamps stay unchanged in public history but never drive idle admission, and heartbeats never reset activity. Before acknowledging a planned suspension, the daemon closes admission and drains native cleanup, output receipts and file work. The Worker lease, the Session lock and the per-node gates own suspension for every provider. New Turn claims, file-write intents and capture admission serialize under the Session lock and share one compute-phase check; new pending work cancels a capture and wakes the same source. Normal preparation waits for the compute phase to be running, after the authenticated resume handshake, and pending input stays pending when its promotion conflicts with a lifecycle transition. Compute phases and revision-checked receipts live on the allocation. Core persists quiesce, capture and restore intent before the effect, only a fresh receipt performs a capture or restore, and recovery observes the exact attempt without retrying an unknown creation, capture or restore. A consumed snapshot never rolls a running generation back. Deletion, revocation and retention expiry win over wake, up to the final database compare-and-swap, and unknown cleanup identities are kept until owned resources are confirmed absent. Consumed artifacts and old compute are deleted, so suspension cycles never build a chain of writable disks. diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 91dc2f563..2b0bd3a3f 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "添加 Sandbox Provider" source: docs/sandbox-provider.md -source_hash: 952ffc561734637ecbae9ad46782575feefaccdfe1c41ed8c6ae1a82c2ff170b +source_hash: 159c2a7366edd784386ca7bd5748752451fd0a3ea543d813cefd29921014d294 --- **Sandbox Provider** 为 Core 管理的 Environment 提供 Runtime daemon 运行所需的外层计算资源,以及启动 daemon 的有界引导流程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。 @@ -104,14 +104,14 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` ## 注册 provider kind {#register-the-provider-kind} -`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、suspension 默认值、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。 +`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。 新 provider 执行以下步骤: 1. 在 adapter 包中实现 operation 契约,并编写原生契约测试。 2. 添加 specification 和 resource validator。 3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据和公开 Core origin,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node provider 仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 -4. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter、operation 声明和默认值。其键即 provider kind,也用于标记该 Provider 的观测;checkpoint 支持读取此项。installer 投影组合已注册 policy 与 `sandbox/deployment_contract.go` 中的共享 field bound;通过 `go run ./services/core/cmd/specification-contract -write` 重新生成。 +4. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即 provider kind,也用于标记该 Provider 的观测;checkpoint 支持读取此项。installer 投影组合已注册 policy 与 `sandbox/deployment_contract.go` 中的共享 field bound;通过 `go run ./services/core/cmd/specification-contract -write` 重新生成。 5. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约向运维人员提供 provider。 **已知设计缺口:** Web 的 setup view 携带 provider 专有选项,如 E2B 的 view。通过该界面提供另一 provider 目前需要修改共享的 Web。此耦合不符合[复杂性留在 adapter 内](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#complexity-stays-in-the-adapter);新集成必须通过协议表达配置,把厂商专有行为留在 adapter。不得添加 Session 或 Turn 调度路径、厂商专有 column 或 API field,或 store 中的厂商 switch。 @@ -125,7 +125,7 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` - `nodes` 注册仅有 `BuildLocal`,`direct` 注册仅有 `BuildDirect`;缺失、混合或未知 mode 被拒绝。 - specification 和 resource validator、configuration adapter 与完整 operation 声明都是必需项,因此不完整注册不能发布部分 installer projection。 - Runtime input policy 要么接受固定 Runtime,要么给出 adapter 拒绝它的固定原因,不能两者兼有。 -- Checkpoint 仅准入 `nodes` 注册,因为公共 lifecycle 只暂停 node allocation;声明 checkpoint 的 `direct` Provider 会被注册拒绝。Checkpoint 支持还要求适合 Runtime duration 的正 idle、retention 默认值,不支持 checkpoint 的 provider 不配置 suspension 默认值。 +- Checkpoint 仅准入 `nodes` 注册,因为公共 lifecycle 只暂停 node allocation;声明 checkpoint 的 `direct` Provider 会被注册拒绝。注册不携带 suspension 数值:Core 对每个声明 checkpoint 支持的 Provider 应用同一个 [suspension policy](#suspension)。 configuration adapter 必须非 nil,包括其具体值。每个 `ConfigurationRequirements` 字段都需要明确有效的决定:`Credential` 和 `PublicOrigin` 为 `Required` 或 `NotRequired`,`Discovery`、`SelectionDiscovery` 和 `CredentialVerification` 使用共享 supported 或 unsupported 声明并携带安全 reason。新增 requirement field 需要明确更新验证,不继承已有决定。要求凭据不承诺支持 `VerifyCredential` 操作。这些检查证明注册完整,不证明原生 SDK 行为正确;constructor 和 adapter 契约测试仍然适用。 @@ -183,7 +183,7 @@ placement 自动完成:environment-to-node placement 与 Session 创建及其 ### 暂停 {#suspension} -支持 checkpoint 的 provider 可以暂停空闲工作;部署 [`suspension`](../../contracts/agents-api/zh/sandbox-deployment.md#safe-response) policy 设置 idle time 和 snapshot retention。Core 仅在至少一个 Turn 已终结、没有 root 或 Subagent Turn 排队、进行中或等待、没有 pending input、file operation 或 initialization,且真实 activity 已空闲达到配置间隔后暂停。对于 node allocation,Core 在同一事务中用数据库时钟记录首个 root 或 child terminal transition。candidate filter 和 Session-locked recheck 比较数据库已过时间与 idle duration,初始 snapshot retention deadline 也锚定同一数据库观测,因此 Core 与数据库主机时钟无需一致。原生 completion timestamp 在公开历史中保持不变,但不驱动 idle admission,heartbeat 不重置 activity。确认计划暂停前,daemon 关闭 admission 并排空 native cleanup、output receipt 和 file work。 +Core 用同一个固定 policy 暂停每个声明 checkpoint 支持的 provider 的空闲工作:工作空闲 5 分钟(300 秒)后暂停,snapshot 保留 24 小时(86400 秒)。部署的 [`suspension`](../../contracts/agents-api/zh/sandbox-deployment.md#safe-response) 报告这两个值。Core 仅在至少一个 Turn 已终结、没有 root 或 Subagent Turn 排队、进行中或等待、没有 pending input、file operation 或 initialization,且真实 activity 已空闲达到该时间后暂停。对于 node allocation,Core 在同一事务中用数据库时钟记录首个 root 或 child terminal transition。candidate filter 和 Session-locked recheck 比较数据库已过时间与 idle duration,初始 snapshot retention deadline 也锚定同一数据库观测,因此 Core 与数据库主机时钟无需一致。原生 completion timestamp 在公开历史中保持不变,但不驱动 idle admission,heartbeat 不重置 activity。确认计划暂停前,daemon 关闭 admission 并排空 native cleanup、output receipt 和 file work。 Worker lease、Session lock 与 per-node gate 对每个 provider 负责 suspension。新 Turn claim、file-write intent 和 capture admission 在 Session lock 下串行化,共享一个 compute-phase 检查;新 pending work 取消 capture 并唤醒同一 source。正常 preparation 在经过认证的 resume handshake 后等待 compute phase 为 running;pending input 的 promotion 与 lifecycle transition 冲突时保持 pending。compute phase 和 revision-checked receipt 位于 allocation。Core 在 effect 前持久化 quiesce、capture 和 restore intent,仅新 receipt 执行 capture 或 restore,恢复观察精确 attempt,不重试未知 creation、capture 或 restore。已消费 snapshot 不让 running generation 回滚。删除、撤销和 retention expiry 优先于 wake,一直持续到最终数据库 compare-and-swap;未知 cleanup identity 保留,直到确认所属资源不存在。已消费 artifact 和旧 compute 被删除,因此暂停循环不累积可写磁盘链。 diff --git a/services/core/internal/db/queries/sandbox_deployment_setup.sql b/services/core/internal/db/queries/sandbox_deployment_setup.sql index 09bd91540..6365c7e0f 100644 --- a/services/core/internal/db/queries/sandbox_deployment_setup.sql +++ b/services/core/internal/db/queries/sandbox_deployment_setup.sql @@ -4,7 +4,7 @@ owner_epoch=owner_epoch+1, updated_at=clock_timestamp() WHERE singleton=true; -- name: InitializeSandboxDeployment :exec UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2, -idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, +generation=$3, mode=$4, provider_config=sqlc.arg(provider_config),provider_metadata=sqlc.arg(provider_metadata),provider_credential=sqlc.arg(provider_credential),specification=sqlc.arg(specification), updated_at=clock_timestamp() WHERE singleton=true; diff --git a/services/core/internal/db/queries/sandbox_reset.sql b/services/core/internal/db/queries/sandbox_reset.sql index 0c2938bb1..5b53b06e5 100644 --- a/services/core/internal/db/queries/sandbox_reset.sql +++ b/services/core/internal/db/queries/sandbox_reset.sql @@ -21,8 +21,7 @@ WHERE singleton = true; -- name: CompleteSandboxReset :exec UPDATE runtime_deployment SET provider_kind = '', backend_fingerprint = '', mode = '', - specification = '{}', idle_seconds = 0, retention_seconds = 0, - provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, + specification = '{}', provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, generation = generation + 1, owner_epoch = owner_epoch + 1, reset_clear = NULL, reset_requested_at = NULL, reset_deadline_at = NULL, reset_forced_at = NULL, reset_audit = NULL, diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index 048878096..b5936f30b 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -242,8 +242,6 @@ type RuntimeDeployment struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` ProviderKind string `json:"provider_kind"` OwnerEpoch int64 `json:"owner_epoch"` - IdleSeconds int64 `json:"idle_seconds"` - RetentionSeconds int64 `json:"retention_seconds"` Generation int64 `json:"generation"` Mode string `json:"mode"` ProviderCredential []byte `json:"provider_credential"` diff --git a/services/core/internal/db/sqlc/runtime_deployment.sql.go b/services/core/internal/db/sqlc/runtime_deployment.sql.go index 15d71eef4..56e51e53a 100644 --- a/services/core/internal/db/sqlc/runtime_deployment.sql.go +++ b/services/core/internal/db/sqlc/runtime_deployment.sql.go @@ -53,7 +53,7 @@ func (q *Queries) CountRuntimeDeploymentResources(ctx context.Context) (CountRun } const lockRuntimeDeployment = `-- name: LockRuntimeDeployment :one -SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true FOR UPDATE +SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true FOR UPDATE ` func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) { @@ -66,8 +66,6 @@ func (q *Queries) LockRuntimeDeployment(ctx context.Context) (RuntimeDeployment, &i.UpdatedAt, &i.ProviderKind, &i.OwnerEpoch, - &i.IdleSeconds, - &i.RetentionSeconds, &i.Generation, &i.Mode, &i.ProviderCredential, diff --git a/services/core/internal/db/sqlc/runtime_nodes.sql.go b/services/core/internal/db/sqlc/runtime_nodes.sql.go index e8ee97976..f6bc5b7d7 100644 --- a/services/core/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/core/internal/db/sqlc/runtime_nodes.sql.go @@ -119,7 +119,7 @@ func (q *Queries) DisconnectRuntimeNode(ctx context.Context, arg DisconnectRunti } const getRuntimeDeployment = `-- name: GetRuntimeDeployment :one -SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton=true +SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton=true ` func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, error) { @@ -132,8 +132,6 @@ func (q *Queries) GetRuntimeDeployment(ctx context.Context) (RuntimeDeployment, &i.UpdatedAt, &i.ProviderKind, &i.OwnerEpoch, - &i.IdleSeconds, - &i.RetentionSeconds, &i.Generation, &i.Mode, &i.ProviderCredential, diff --git a/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go b/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go index de2ee8d6f..9b169c9bf 100644 --- a/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go +++ b/services/core/internal/db/sqlc/sandbox_deployment_setup.sql.go @@ -32,16 +32,14 @@ func (q *Queries) ClaimWebSandboxDeployment(ctx context.Context, installationID const initializeSandboxDeployment = `-- name: InitializeSandboxDeployment :exec UPDATE runtime_deployment SET provider_kind=$1, backend_fingerprint=$2, -idle_seconds=$3, retention_seconds=$4, generation=$5, mode=$6, -provider_config=$7,provider_metadata=$8,provider_credential=$9,specification=$10, +generation=$3, mode=$4, +provider_config=$5,provider_metadata=$6,provider_credential=$7,specification=$8, updated_at=clock_timestamp() WHERE singleton=true ` type InitializeSandboxDeploymentParams struct { ProviderKind string `json:"provider_kind"` BackendFingerprint string `json:"backend_fingerprint"` - IdleSeconds int64 `json:"idle_seconds"` - RetentionSeconds int64 `json:"retention_seconds"` Generation int64 `json:"generation"` Mode string `json:"mode"` ProviderConfig []byte `json:"provider_config"` @@ -54,8 +52,6 @@ func (q *Queries) InitializeSandboxDeployment(ctx context.Context, arg Initializ _, err := q.db.Exec(ctx, initializeSandboxDeployment, arg.ProviderKind, arg.BackendFingerprint, - arg.IdleSeconds, - arg.RetentionSeconds, arg.Generation, arg.Mode, arg.ProviderConfig, diff --git a/services/core/internal/db/sqlc/sandbox_reset.sql.go b/services/core/internal/db/sqlc/sandbox_reset.sql.go index 3791f0e5d..8929d242b 100644 --- a/services/core/internal/db/sqlc/sandbox_reset.sql.go +++ b/services/core/internal/db/sqlc/sandbox_reset.sql.go @@ -25,8 +25,7 @@ func (q *Queries) CancelSandboxReset(ctx context.Context) error { const completeSandboxReset = `-- name: CompleteSandboxReset :exec UPDATE runtime_deployment SET provider_kind = '', backend_fingerprint = '', mode = '', - specification = '{}', idle_seconds = 0, retention_seconds = 0, - provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, + specification = '{}', provider_config = '{}'::jsonb, provider_metadata = '{}'::jsonb, provider_credential = NULL, generation = generation + 1, owner_epoch = owner_epoch + 1, reset_clear = NULL, reset_requested_at = NULL, reset_deadline_at = NULL, reset_forced_at = NULL, reset_audit = NULL, @@ -51,7 +50,7 @@ func (q *Queries) ForceSandboxReset(ctx context.Context) error { } const getSandboxDeploymentSnapshot = `-- name: GetSandboxDeploymentSnapshot :one -WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, idle_seconds, retention_seconds, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true LIMIT 1), +WITH deployment AS MATERIALIZED (SELECT singleton, installation_id, backend_fingerprint, updated_at, provider_kind, owner_epoch, generation, mode, provider_credential, specification, reset_clear, reset_requested_at, reset_deadline_at, reset_forced_at, reset_audit, provider_config, provider_metadata FROM runtime_deployment WHERE singleton = true LIMIT 1), 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, @@ -89,7 +88,7 @@ held AS ( ), offline AS ( SELECT node_id, name, count(*)::bigint AS resources FROM classified WHERE offline GROUP BY node_id, name ) -SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.updated_at, d.provider_kind, d.owner_epoch, d.idle_seconds, d.retention_seconds, d.generation, d.mode, d.provider_credential, d.specification, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit, d.provider_config, d.provider_metadata, +SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.updated_at, d.provider_kind, d.owner_epoch, d.generation, d.mode, d.provider_credential, d.specification, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit, d.provider_config, d.provider_metadata, (SELECT count(*) FROM classified WHERE NOT pending)::bigint AS allocations, (SELECT count(*) FROM classified WHERE pending)::bigint AS pending, jsonb_build_object( @@ -131,8 +130,6 @@ func (q *Queries) GetSandboxDeploymentSnapshot(ctx context.Context) (GetSandboxD &i.RuntimeDeployment.UpdatedAt, &i.RuntimeDeployment.ProviderKind, &i.RuntimeDeployment.OwnerEpoch, - &i.RuntimeDeployment.IdleSeconds, - &i.RuntimeDeployment.RetentionSeconds, &i.RuntimeDeployment.Generation, &i.RuntimeDeployment.Mode, &i.RuntimeDeployment.ProviderCredential, diff --git a/services/core/internal/deployment/execution.go b/services/core/internal/deployment/execution.go index 0c1ef29fa..b91abceb3 100644 --- a/services/core/internal/deployment/execution.go +++ b/services/core/internal/deployment/execution.go @@ -298,7 +298,7 @@ func (e *ExecutionOperations) saveSelection(tx DeploymentTx, d Record, input san return err } return tx.SaveSelection(SelectionRecord{InstallationID: d.InstallationID, Provider: input.Provider, BackendFingerprint: description.BackendFingerprint, Mode: description.Mode, - Generation: d.Generation + 1, IdleSeconds: description.IdleSeconds, RetentionSeconds: description.RetentionSeconds, Specification: specification, + Generation: d.Generation + 1, Specification: specification, Configuration: sandbox.ConfigurationRecord{Public: configurationJSON(record.Public), Metadata: configurationJSON(record.Metadata), Secret: record.Secret}}) } diff --git a/services/core/internal/deployment/service.go b/services/core/internal/deployment/service.go index 575f0d79f..c9e06fd89 100644 --- a/services/core/internal/deployment/service.go +++ b/services/core/internal/deployment/service.go @@ -64,13 +64,9 @@ func (s *Service) view(snapshot Snapshot) (View, error) { result.Configuration = configurationJSON(record.Public) result.Metadata = configurationJSON(record.Metadata) result.CredentialConfigured = d.CredentialStored - checkpoint, err := s.registry.SupportsCheckpoint(d.Provider) - if err != nil { + if result.Suspension, err = s.suspension(d.Provider); err != nil { return View{}, err } - if checkpoint { - result.Suspension = &Suspension{IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds} - } } if d.Reset != nil { result.Reset = &Reset{Clear: d.Reset.Clear, RequestedAt: d.Reset.RequestedAt, DeadlineAt: d.Reset.DeadlineAt, ForcedAt: d.Reset.ForcedAt, Remaining: snapshot.Remaining} @@ -111,24 +107,20 @@ func (s *Service) setup(d Record) (Setup, error) { if err != nil { return Setup{}, ErrConflict } - return s.describe(result, d.IdleSeconds, d.RetentionSeconds) + return s.describe(result) } // describe adds the provider's declared operations, suspension policy and // credential use. -func (s *Service) describe(setup Setup, idleSeconds, retentionSeconds int64) (Setup, error) { +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() - checkpoint, err := s.registry.SupportsCheckpoint(setup.Provider) - if err != nil { + if setup.Suspension, err = s.suspension(setup.Provider); err != nil { return Setup{}, err } - if checkpoint { - setup.Suspension = &Suspension{IdleSeconds: idleSeconds, RetentionSeconds: retentionSeconds} - } setup.UsesCredential, err = s.registry.UsesCredential(setup.Provider) if err != nil { return Setup{}, err @@ -254,7 +246,17 @@ func (s *Service) SetupForSelection(installationID string, input sandbox.Selecti return Setup{}, err } result := Setup{InstallationID: installationID, Provider: input.Provider, Mode: description.Mode, Specification: normalized.DeploymentSpec, Configuration: normalized.Configuration, BackendFingerprint: description.BackendFingerprint} - return s.describe(result, description.IdleSeconds, description.RetentionSeconds) + 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) (*Suspension, error) { + checkpoint, err := s.registry.SupportsCheckpoint(provider) + if err != nil || !checkpoint { + return nil, err + } + return &Suspension{IdleSeconds: 5 * 60, RetentionSeconds: 24 * 60 * 60}, nil } // validateSelection rejects a selection its provider cannot normalize. diff --git a/services/core/internal/deployment/setup.go b/services/core/internal/deployment/setup.go index 2f17624b0..dae24b6c7 100644 --- a/services/core/internal/deployment/setup.go +++ b/services/core/internal/deployment/setup.go @@ -21,8 +21,8 @@ type Setup struct { // transport proxies. Operations providercontract.Operations Configuration sandbox.Configuration `json:"-"` - // Suspension is the idle suspension policy of a provider that suspends - // sandboxes, and nil otherwise. + // Suspension is the idle suspension policy of a provider that declares + // checkpoint support, and nil otherwise. Suspension *Suspension // UsesCredential reports whether the provider's configuration carries a // credential. diff --git a/services/core/internal/deployment/storage.go b/services/core/internal/deployment/storage.go index e3b9dc761..662f7e926 100644 --- a/services/core/internal/deployment/storage.go +++ b/services/core/internal/deployment/storage.go @@ -342,8 +342,6 @@ type Record struct { Generation uint64 OwnerEpoch uint64 Mode string - IdleSeconds int64 - RetentionSeconds int64 // Specification is the stored specification document. Specification json.RawMessage // Configuration holds the public configuration and metadata and, when a @@ -378,7 +376,6 @@ type Snapshot struct { type SelectionRecord struct { InstallationID, Provider, BackendFingerprint, Mode string Generation uint64 - IdleSeconds, RetentionSeconds int64 Specification json.RawMessage // Configuration carries the secret in plaintext; the adapter seals it. Configuration sandbox.ConfigurationRecord diff --git a/services/core/internal/deployment/view.go b/services/core/internal/deployment/view.go index 806a138fd..228fc8e7e 100644 --- a/services/core/internal/deployment/view.go +++ b/services/core/internal/deployment/view.go @@ -18,7 +18,7 @@ type View struct { Configuration json.RawMessage `json:"configuration,omitempty" swaggertype:"object"` Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` CredentialConfigured bool `json:"credential_configured"` - // Idle suspension policy; microsandbox only, otherwise null. + // Idle suspension policy; null unless the selected Provider declares checkpoint support. Suspension *Suspension `json:"suspension" extensions:"x-nullable"` InstallationID string `json:"installation_id"` Provider string `json:"provider"` @@ -34,8 +34,8 @@ type Resources struct { Pending int64 `json:"pending"` } -// Suspension is the idle suspension policy. Only microsandbox suspends -// sandboxes; Docker and E2B deployments return null. +// Suspension is Core's idle suspension policy, which applies to every Provider +// that declares checkpoint support. type Suspension struct { IdleSeconds int64 `json:"idle_seconds"` RetentionSeconds int64 `json:"retention_seconds"` diff --git a/services/core/internal/persistence/postgres/deploymentpg/records.go b/services/core/internal/persistence/postgres/deploymentpg/records.go index 84eed9174..c1e2cb3c5 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/records.go +++ b/services/core/internal/persistence/postgres/deploymentpg/records.go @@ -60,8 +60,7 @@ func translate(err error) error { // authenticate is an internal decryption error. func record(d sqlc.RuntimeDeployment, cipher *credentialcrypto.Cipher, open bool) deployment.Record { r := deployment.Record{InstallationID: uuidString(d.InstallationID), Provider: d.ProviderKind, BackendFingerprint: d.BackendFingerprint, - Generation: uint64(d.Generation), OwnerEpoch: uint64(d.OwnerEpoch), Mode: d.Mode, - IdleSeconds: d.IdleSeconds, RetentionSeconds: d.RetentionSeconds, Specification: d.Specification, + Generation: uint64(d.Generation), OwnerEpoch: uint64(d.OwnerEpoch), Mode: d.Mode, Specification: d.Specification, Configuration: sandbox.ConfigurationRecord{Public: d.ProviderConfig, Metadata: d.ProviderMetadata}, CredentialStored: len(d.ProviderCredential) > 0} if r.CredentialStored && open { if cipher == nil { diff --git a/services/core/internal/persistence/postgres/deploymentpg/tx.go b/services/core/internal/persistence/postgres/deploymentpg/tx.go index e0952eb19..f48a83cbf 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/tx.go +++ b/services/core/internal/persistence/postgres/deploymentpg/tx.go @@ -301,7 +301,7 @@ func (t *deploymentTx) SaveSelection(selection deployment.SelectionRecord) error if selection.Generation > math.MaxInt64 { return deployment.ErrInvalidInput } - params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: selection.Provider, BackendFingerprint: selection.BackendFingerprint, Generation: int64(selection.Generation), Mode: selection.Mode, IdleSeconds: selection.IdleSeconds, RetentionSeconds: selection.RetentionSeconds, ProviderConfig: selection.Configuration.Public, ProviderMetadata: selection.Configuration.Metadata, Specification: selection.Specification} + params := sqlc.InitializeSandboxDeploymentParams{ProviderKind: selection.Provider, BackendFingerprint: selection.BackendFingerprint, Generation: int64(selection.Generation), Mode: selection.Mode, ProviderConfig: selection.Configuration.Public, ProviderMetadata: selection.Configuration.Metadata, Specification: selection.Specification} if len(selection.Configuration.Secret) > 0 { if t.cipher == nil { return credentialcrypto.ErrUnavailable diff --git a/services/core/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go index 712d9e27b..af749281f 100644 --- a/services/core/internal/sandbox/deployment.go +++ b/services/core/internal/sandbox/deployment.go @@ -109,8 +109,7 @@ func (s DeploymentSpec) Digest(provider string) string { } // Description is what a provider registration says about a deployment of it: -// its mode, its backend namespace fingerprint and its checkpoint timing. +// its mode and its backend namespace fingerprint. type Description struct { - Mode, BackendFingerprint string - IdleSeconds, RetentionSeconds int64 + Mode, BackendFingerprint string } diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 712283e55..3962f0f6e 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -2,7 +2,6 @@ package providers import ( "fmt" - "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -51,16 +50,10 @@ func ValidateRegistration(a Adapter) error { if err := sandbox.ValidateOperations(operations); err != nil { return err } - // The current common lifecycle admits checkpoint suspension only on nodes, - // and creates its policy whenever checkpoint support is declared. - if operations["Initial"].State == providercontract.Supported { - const maximumSeconds = int64((1<<63 - 1) / time.Second) - if a.Mode != "nodes" || a.IdleSeconds < 1 || a.RetentionSeconds < 1 || - a.IdleSeconds > maximumSeconds || a.RetentionSeconds > maximumSeconds { - return invalid("checkpoint policy") - } - } else if a.IdleSeconds != 0 || a.RetentionSeconds != 0 { - return invalid("non-checkpoint policy") + // The common lifecycle suspends only node allocations, so only a nodes + // registration may declare checkpoint support. + if operations["Initial"].State == providercontract.Supported && a.Mode != "nodes" { + return invalid("checkpoint support outside nodes mode") } return nil } diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index f30c39072..b8121f38d 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -165,35 +165,12 @@ func TestCompleteRegistrationsPreserveConstruction(t *testing.T) { } } -// Idle time is measured before suspension, retention after suspension. Neither -// duration needs to be greater than the other. -func TestRegistrationCheckpointPolicy(t *testing.T) { +// Only a nodes registration may declare checkpoint support. +func TestRegistrationCheckpointRequiresNodes(t *testing.T) { registry := Builtin() - for _, tc := range []struct { - name string - kind string - idle, retention int64 - direct, valid bool - }{ - {"negative idle", "microsandbox", -1, 20, false, false}, - {"missing idle", "microsandbox", 0, 20, false, false}, - {"missing retention", "microsandbox", 20, 0, false, false}, - {"overflow", "microsandbox", 1<<63 - 1, 20, false, false}, - {"direct suspension", "microsandbox", 20, 20, true, false}, - {"unsupported suspension", "docker", 20, 20, false, false}, - {"independent durations", "microsandbox", 300, 30, false, true}, - {"no suspension", "docker", 0, 0, false, true}, - } { - t.Run(tc.name, func(t *testing.T) { - a := registry.adapters[tc.kind] - a.IdleSeconds, a.RetentionSeconds = tc.idle, tc.retention - if tc.direct { - a.Mode, a.BuildLocal, a.BuildDirect = "direct", nil, registry.adapters["e2b"].BuildDirect - } - err := ValidateRegistration(a) - if (err == nil) != tc.valid || err != nil && !errors.Is(err, providercontract.ErrContract) { - t.Fatal(err) - } - }) + a := registry.adapters["microsandbox"] + a.Mode, a.BuildLocal, a.NodeArtifacts, a.BuildDirect = "direct", nil, nil, registry.adapters["e2b"].BuildDirect + if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) || !strings.Contains(err.Error(), "checkpoint") { + t.Fatal("direct checkpoint registration accepted", err) } } diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index 29bb86b4f..d7bf67337 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -17,16 +17,15 @@ import ( // Adapter describes configuration and transport independently of compute operations. // Native operation support comes from the adapter-owned complete declaration. type Adapter struct { - NodeArtifacts []providerassets.Artifact - Policy sandbox.DeploymentPolicy - Configuration sandbox.ConfigurationAdapter - BuildLocal func(Config, LocalOptions, *Built) (func(), error) - BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) - Mode string - Operations func() providercontract.Operations - IdleSeconds, RetentionSeconds int64 - ValidateSpecification func(sandbox.DeploymentSpec) error - ValidateResources func(sandbox.Resources) error + NodeArtifacts []providerassets.Artifact + Policy sandbox.DeploymentPolicy + Configuration sandbox.ConfigurationAdapter + BuildLocal func(Config, LocalOptions, *Built) (func(), error) + BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) + Mode string + Operations func() providercontract.Operations + ValidateSpecification func(sandbox.DeploymentSpec) error + ValidateResources func(sandbox.Resources) error } // Registry holds the registered adapters. Core and the node program each build @@ -51,7 +50,6 @@ func Builtin() *Registry { {Path: "native/microsandbox/msb", Suffix: "msb", Role: "runtime"}, {Path: "native/microsandbox/libkrunfw.so.5.6.1", Suffix: "libkrunfw.so.5.6.1", Role: "runtime"}}, Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: buildMicrosandbox, - IdleSeconds: 300, RetentionSeconds: 86400, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, Configuration: nodeConfigurationAdapter{microsandbox.ValidateSpecification}, }, @@ -141,7 +139,7 @@ func (r *Registry) Describe(kind, installation string) (sandbox.Description, err if a.Mode == "direct" { namespace = kind } - return sandbox.Description{Mode: a.Mode, BackendFingerprint: BackendFingerprint(kind, namespace+":"+installation), IdleSeconds: a.IdleSeconds, RetentionSeconds: a.RetentionSeconds}, nil + return sandbox.Description{Mode: a.Mode, BackendFingerprint: BackendFingerprint(kind, namespace+":"+installation)}, nil } // PythonDeploymentContract projects the same registered adapter policies into diff --git a/services/core/internal/sandbox/providers/registry_test.go b/services/core/internal/sandbox/providers/registry_test.go index 6b3078902..bd008e63e 100644 --- a/services/core/internal/sandbox/providers/registry_test.go +++ b/services/core/internal/sandbox/providers/registry_test.go @@ -14,17 +14,16 @@ func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { installation := uuid.NewString() for _, tc := range []struct { kind, mode, namespace string - idle, retention int64 checkpoint bool }{ - {"docker", "nodes", "nodes", 0, 0, false}, - {"microsandbox", "nodes", "nodes", 300, 86400, true}, - {"e2b", "direct", "e2b", 0, 0, false}, + {"docker", "nodes", "nodes", false}, + {"microsandbox", "nodes", "nodes", true}, + {"e2b", "direct", "e2b", false}, } { t.Run(tc.kind, func(t *testing.T) { d, err := registry.Describe(tc.kind, installation) - if err != nil || d.Mode != tc.mode || d.IdleSeconds != tc.idle || d.RetentionSeconds != tc.retention || d.BackendFingerprint != BackendFingerprint(tc.kind, tc.namespace+":"+installation) { - t.Fatalf("wrong namespace or defaults: %+v %v", d, err) + if err != nil || d.Mode != tc.mode || d.BackendFingerprint != BackendFingerprint(tc.kind, tc.namespace+":"+installation) { + t.Fatalf("wrong mode or namespace: %+v %v", d, err) } a, err := registry.Lookup(tc.kind) checkpoint, checkpointErr := registry.SupportsCheckpoint(tc.kind) @@ -75,7 +74,7 @@ func TestNewRegistrationDoesNotNeedCoreDispatchChanges(t *testing.T) { t.Fatal("new entry did not follow shared boundary", err, checkpointErr) } d, err := registry.Describe(kind, uuid.NewString()) - if err != nil || d.Mode != "nodes" || d.IdleSeconds != 0 { + if err != nil || d.Mode != "nodes" { t.Fatal(d, err) } if _, err := registry.Normalize(sandbox.Selection{Provider: kind, Configuration: &e2b.DeploymentConfiguration{APIKey: "wrong-provider"}}); !errors.Is(err, sandbox.ErrInvalid) { diff --git a/services/core/migrations/000094_suspension_from_declaration.sql b/services/core/migrations/000094_suspension_from_declaration.sql new file mode 100644 index 000000000..67bd968aa --- /dev/null +++ b/services/core/migrations/000094_suspension_from_declaration.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Core derives the idle suspension policy from the Provider's checkpoint +-- declaration, so the deployment no longer stores it. +ALTER TABLE runtime_deployment + DROP CONSTRAINT runtime_deployment_setup_check, + DROP COLUMN idle_seconds, + DROP COLUMN retention_seconds, + ADD CONSTRAINT runtime_deployment_setup_check CHECK ( + installation_id IS NULL OR + (provider_kind = '' AND mode = '' AND generation >= 0) OR + (provider_kind <> '' AND mode IN ('nodes','direct') AND generation > 0) + ); + +-- +goose Down +ALTER TABLE runtime_deployment + DROP CONSTRAINT runtime_deployment_setup_check, + ADD COLUMN idle_seconds bigint NOT NULL DEFAULT 0 CHECK (idle_seconds >= 0), + ADD COLUMN retention_seconds bigint NOT NULL DEFAULT 0 CHECK (retention_seconds >= 0); +UPDATE runtime_deployment SET idle_seconds = 300, retention_seconds = 86400 WHERE provider_kind = 'microsandbox'; +ALTER TABLE runtime_deployment + ADD CONSTRAINT runtime_deployment_setup_check CHECK ( + installation_id IS NULL OR + (provider_kind = '' AND mode = '' AND generation >= 0 AND idle_seconds = 0 AND retention_seconds = 0) OR + (provider_kind <> '' AND mode IN ('nodes','direct') AND generation > 0 AND + ((idle_seconds = 0 AND retention_seconds = 0) OR (idle_seconds > 0 AND retention_seconds > 0))) + );