From 88d2c979469c7f7018e5406769d0acc6c00d6a8a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 11:19:30 +0000 Subject: [PATCH 1/2] Report a node allocation's attached Session as nullable --- apps/web/src/features/sandbox/NodeDetail.tsx | 2 +- contracts/agents-api/core.openapi.yaml | 2 ++ contracts/agents-api/sandbox-deployment.md | 2 +- contracts/agents-api/zh/sandbox-deployment.md | 4 ++-- packages/agents-client/src/generated/core-api.ts | 2 +- packages/agents-client/src/sandbox-client.test.ts | 1 + packages/agents-client/src/sandbox-client.ts | 4 ++-- services/core/internal/deployment/allocation.go | 9 +++++---- .../persistence/postgres/deploymentpg/allocations.go | 7 ++++++- 9 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index ca3429095..6e925cf3e 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -114,7 +114,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale {own.map((allocation) => ( - + {allocation.session_id ? : MISSING} {allocation.deployment_generation} {sandboxStateLabel(allocation.state, shortLocale)} {allocation.compute_phase ? sandboxStateLabel(allocation.compute_phase, shortLocale) : MISSING} diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 33ab1e18c..c2b659c7d 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1412,7 +1412,9 @@ definitions: node_id: type: string session_id: + description: The live Session attached to the Environment, or null when none is. type: string + x-nullable: true state: type: string tenant_id: diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index be7a5b743..8df9c16a1 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -224,7 +224,7 @@ A node manages generations, so its `provider_ready` follows its serving generati `PATCH /core/v1/sandbox/nodes/{node_id}` takes `{name, max_active, max_retained}`; lowering a limit stops no running sandbox. `DELETE /core/v1/sandbox/nodes/{node_id}` refuses with 409 `runtime_node_in_use` while the node holds allocations, snapshots, reservations or pending cleanup, including while it is offline. Removal deletes no compute and retires the node's identity; the host can come back only as a new node. There is no node drain. -`GET /core/v1/sandbox/nodes/{node_id}/allocations` lists the node's unreleased allocations. Each item's `compute_phase_changed_at` is when the allocation entered its current `compute_phase`, or null when unknown. This is a phase transition time, not a snapshot expiry. The snapshot deadline is not exposed by this response and must not be inferred from `suspension.retention_seconds`. +`GET /core/v1/sandbox/nodes/{node_id}/allocations` lists the node's unreleased allocations. Each item's `session_id` is the live Session attached to the allocation's Environment, or null when none is attached. Each item's `compute_phase_changed_at` is when the allocation entered its current `compute_phase`, or null when unknown. This is a phase transition time, not a snapshot expiry. The snapshot deadline is not exposed by this response and must not be inferred from `suspension.retention_seconds`. ### Recommended capacity diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 70b077bc3..42f118fb3 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: 9ae9f467b9e62291dcb31e3b5a8c1c64c21ef3f790ad804c85a2939119c0a218 +source_hash: c7b69e705061c6c0ee95d9a89d93f82e20304e3f4f12b5ed0442614950867806 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -229,7 +229,7 @@ stateDiagram-v2 `PATCH /core/v1/sandbox/nodes/{node_id}` 接受 `{name, max_active, max_retained}`;降低限制不会停止任何正在运行的沙箱。当节点仍持有分配、快照、预留或待处理清理时,包括节点离线期间,`DELETE /core/v1/sandbox/nodes/{node_id}` 会拒绝操作并返回 409 `runtime_node_in_use`。移除操作不会删除任何计算资源,并且会停用该节点的身份;该主机只能作为新节点重新加入。不存在节点排空过程。 -`GET /core/v1/sandbox/nodes/{node_id}/allocations` 列出节点尚未释放的分配。每个项的 `compute_phase_changed_at` 表示该分配进入当前 `compute_phase` 的时间;未知时为 null。该时间是阶段切换时间,不是快照到期时间。此响应不提供快照截止时间,不应根据 `suspension.retention_seconds` 推算。 +`GET /core/v1/sandbox/nodes/{node_id}/allocations` 列出节点尚未释放的分配。每个项的 `session_id` 是该分配的 Environment 当前挂接的存活 Session;没有挂接时为 null。每个项的 `compute_phase_changed_at` 表示该分配进入当前 `compute_phase` 的时间;未知时为 null。该时间是阶段切换时间,不是快照到期时间。此响应不提供快照截止时间,不应根据 `suspension.retention_seconds` 推算。 ### 推荐容量 {#recommended-capacity} diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 1fbfd4a0e..283f34ff0 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -480,7 +480,7 @@ export interface NodeAllocation { id: string; initialization: string; node_id: string; - session_id: string; + session_id: string | null; state: string; tenant_id: string; } diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index e40917252..e594288e8 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -82,6 +82,7 @@ describe("strict sandbox administration projections", () => { ["nodes", "ready and unready", { data: [node, unready] }], ["nodes", "empty", { data: [] }], ["detail", "observed", detail], ["unobserved", "never observed", unobserved], ["allocations", "known and unknown phase times", { data: [allocation, { ...allocation, compute_phase: "suspended", compute_phase_changed_at: created, diagnostic: "node_unavailable" }] }], + ["allocations", "no attached Session", { data: [{ ...allocation, session_id: null }] }], ["deployment", "unconfigured", unconfigured], ["deployment", "microsandbox", microsandbox], ["deployment", "external workspace", externalDeployment], ["deployment", "E2B", e2bDeployment], ])("accepts %s as Core serializes it: %s", async (name, _, body) => { expect(await read(name, body)).toEqual(body); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 1ad53aec2..65672b4d6 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -203,14 +203,14 @@ function projectNodeDetail(value: unknown, nodeId: string): SandboxNodeDetail { return { ...node, host: { ...observed } as unknown as NodeHost, history: { resolution_seconds: buckets.resolution_seconds as number, points } }; } -const allocationStrings = ["id", "node_id", "tenant_id", "session_id", "environment_id", "state", "compute_phase", "initialization", "diagnostic"]; +const allocationStrings = ["id", "node_id", "tenant_id", "environment_id", "state", "compute_phase", "initialization", "diagnostic"]; function projectAllocations(value: unknown, nodeId: string): { data: SandboxAllocation[] } { const list = members(value, sandboxAllocationListFields); valid(Array.isArray(list.data)); return { data: (list.data as unknown[]).map((entry) => { const allocation = members(entry, nodeAllocationFields); valid(isNonnegativeInteger(allocation.deployment_generation) && strings(allocation, allocationStrings) && sameResourceId(allocation.node_id as string, nodeId) && isOneOf(nodeAllocationDiagnosticValues, allocation.diagnostic) && - nullable(timestamp)(allocation.compute_phase_changed_at) && timestamp(allocation.created_at)); + nullable(timestamp)(allocation.compute_phase_changed_at) && (allocation.session_id === null || typeof allocation.session_id === "string") && timestamp(allocation.created_at)); return { ...allocation } as unknown as SandboxAllocation; }) }; } diff --git a/services/core/internal/deployment/allocation.go b/services/core/internal/deployment/allocation.go index d1d262651..48456e4b3 100644 --- a/services/core/internal/deployment/allocation.go +++ b/services/core/internal/deployment/allocation.go @@ -158,10 +158,11 @@ type NodeAllocation struct { ID string `json:"id" binding:"required"` NodeID string `json:"node_id" binding:"required"` TenantID string `json:"tenant_id" binding:"required"` - SessionID string `json:"session_id" binding:"required"` - EnvironmentID string `json:"environment_id" binding:"required"` - State string `json:"state" binding:"required"` - ComputePhase string `json:"compute_phase" binding:"required"` + // The live Session attached to the Environment, or null when none is. + SessionID *string `json:"session_id" extensions:"x-nullable" binding:"required"` + EnvironmentID string `json:"environment_id" binding:"required"` + State string `json:"state" binding:"required"` + ComputePhase string `json:"compute_phase" binding:"required"` // The time the allocation entered its current compute_phase, or null when unknown; an allocation that existed before Core recorded it reports null until its next phase change. For a suspended allocation, this time plus the deployment's snapshot retention tells roughly when Core reclaims it. ComputePhaseChangedAt *time.Time `json:"compute_phase_changed_at" extensions:"x-nullable" binding:"required"` Initialization string `json:"initialization" binding:"required"` diff --git a/services/core/internal/persistence/postgres/deploymentpg/allocations.go b/services/core/internal/persistence/postgres/deploymentpg/allocations.go index 4d0bc7538..7b3d48097 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/allocations.go +++ b/services/core/internal/persistence/postgres/deploymentpg/allocations.go @@ -486,9 +486,14 @@ func (s *Store) NodeAllocations(ctx context.Context, nodeID string) ([]deploymen } result := make([]deployment.NodeAllocation, 0, len(rows)) for _, a := range rows { + var session *string + if a.AttachedSessionID.Valid { + id := uuidString(a.AttachedSessionID) + session = &id + } result = append(result, deployment.NodeAllocation{ DeploymentGeneration: uint64(a.DeploymentGeneration.Int64), Diagnostic: a.ObservationError, ID: uuidString(a.ID), NodeID: uuidString(a.NodeID), - TenantID: uuidString(a.TenantID), SessionID: uuidString(a.AttachedSessionID), EnvironmentID: uuidString(a.EnvironmentID), State: a.State, + TenantID: uuidString(a.TenantID), SessionID: session, EnvironmentID: uuidString(a.EnvironmentID), State: a.State, ComputePhase: a.ComputePhase, ComputePhaseChangedAt: timestamp(a.ComputePhaseChangedAt), Initialization: a.Initialization, CreatedAt: a.CreatedAt.Time, }) } From 6a6c504705397364156e97fd646846efd789deb3 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 11:32:43 +0000 Subject: [PATCH 2/2] Cover a null allocation Session in Playwright, its mock Core and the store test --- apps/web/e2e/fixture-console.mjs | 1 + apps/web/e2e/sandbox-generation.spec.ts | 4 ++-- contracts/agents-api/zh/sandbox-deployment.md | 2 +- .../persistence/postgres/deploymentpg/allocations.go | 3 +-- .../core/tests/integration/runtime_suspension_test.go | 9 ++++++++- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 0bef73c42..5337a0ae1 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -253,6 +253,7 @@ async function adminWrite(request, response, path) { const index = collection.findIndex((entry) => entry.id === resourceId && a.owner.get(`${kind.slice(0, -1)}:${entry.id}`) === project.id); if (index < 0) return error(response, 404, "No such resource."); collection.splice(index, 1); + if (kind === "sessions") for (const allocation of state.allocations) if (allocation.session_id === resourceId) allocation.session_id = null; return send(response, 200, { id: resourceId, object: kind === "agents" ? "agent.deleted" : "agent.session.deleted", deleted: true }); } return error(response, 404, "Not found."); diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts index f84af2adc..11a9115ef 100644 --- a/apps/web/e2e/sandbox-generation.spec.ts +++ b/apps/web/e2e/sandbox-generation.spec.ts @@ -141,7 +141,7 @@ test("unknown target preparation preserves live old-generation service while an test("a generation-only change preserves mixed allocation ownership through failed inventory reads", async ({ page, request }) => { await openConsole(page, request, "settings?id=sandbox"); await expect(rollout(page)).toBeVisible(); - let expected: { session: string; generation: string }[] = []; + let expected: { session: string | null; generation: string }[] = []; await page.route("**/core/v1/sandbox/nodes/node-local/allocations", async (route) => { const response = await route.fetch(); const body = await response.json() as { data: SandboxAllocation[] }; @@ -158,7 +158,7 @@ test("a generation-only change preserves mixed allocation ownership through fail await expect(table.getByRole("columnheader", { name: "Configuration generation", exact: true })).toBeVisible(); await expect.poll(() => [...new Set(expected.map((entry) => entry.generation))].sort()).toEqual(["1", "2"]); const ownership = () => table.locator("tbody tr").evaluateAll((rows) => rows.map((row) => ({ - session: row.querySelector("th code")?.getAttribute("title"), + session: row.querySelector("th code")?.getAttribute("title") ?? null, generation: row.querySelector("td")?.textContent, }))); await expect.poll(ownership).toEqual(expected); diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 42f118fb3..5ee7e2c50 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -229,7 +229,7 @@ stateDiagram-v2 `PATCH /core/v1/sandbox/nodes/{node_id}` 接受 `{name, max_active, max_retained}`;降低限制不会停止任何正在运行的沙箱。当节点仍持有分配、快照、预留或待处理清理时,包括节点离线期间,`DELETE /core/v1/sandbox/nodes/{node_id}` 会拒绝操作并返回 409 `runtime_node_in_use`。移除操作不会删除任何计算资源,并且会停用该节点的身份;该主机只能作为新节点重新加入。不存在节点排空过程。 -`GET /core/v1/sandbox/nodes/{node_id}/allocations` 列出节点尚未释放的分配。每个项的 `session_id` 是该分配的 Environment 当前挂接的存活 Session;没有挂接时为 null。每个项的 `compute_phase_changed_at` 表示该分配进入当前 `compute_phase` 的时间;未知时为 null。该时间是阶段切换时间,不是快照到期时间。此响应不提供快照截止时间,不应根据 `suspension.retention_seconds` 推算。 +`GET /core/v1/sandbox/nodes/{node_id}/allocations` 列出节点尚未释放的分配。每个项的 `session_id` 是该分配的 Environment 当前挂载的存活 Session;没有挂载时为 null。每个项的 `compute_phase_changed_at` 表示该分配进入当前 `compute_phase` 的时间;未知时为 null。该时间是阶段切换时间,不是快照到期时间。此响应不提供快照截止时间,不应根据 `suspension.retention_seconds` 推算。 ### 推荐容量 {#recommended-capacity} diff --git a/services/core/internal/persistence/postgres/deploymentpg/allocations.go b/services/core/internal/persistence/postgres/deploymentpg/allocations.go index 7b3d48097..12ff5dac3 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/allocations.go +++ b/services/core/internal/persistence/postgres/deploymentpg/allocations.go @@ -488,8 +488,7 @@ func (s *Store) NodeAllocations(ctx context.Context, nodeID string) ([]deploymen for _, a := range rows { var session *string if a.AttachedSessionID.Valid { - id := uuidString(a.AttachedSessionID) - session = &id + session = new(uuidString(a.AttachedSessionID)) } result = append(result, deployment.NodeAllocation{ DeploymentGeneration: uint64(a.DeploymentGeneration.Int64), Diagnostic: a.ObservationError, ID: uuidString(a.ID), NodeID: uuidString(a.NodeID), diff --git a/services/core/tests/integration/runtime_suspension_test.go b/services/core/tests/integration/runtime_suspension_test.go index 98d6de933..d0b0541b0 100644 --- a/services/core/tests/integration/runtime_suspension_test.go +++ b/services/core/tests/integration/runtime_suspension_test.go @@ -397,7 +397,7 @@ func TestRuntimeComputePhaseChangedAt(t *testing.T) { } } -// The node allocation list reports the phase time, and null when it is unknown. +// The node allocation list reports the attached Session and the phase time, each null when absent. func TestRuntimeComputePhaseChangedAtInNodeAllocations(t *testing.T) { s, w, d := managerFixture(t, 1, 4) tenant := uuid.NewString() @@ -427,6 +427,13 @@ func TestRuntimeComputePhaseChangedAtInNodeAllocations(t *testing.T) { if encoded, _ := json.Marshal(listed()); !strings.Contains(string(encoded), `"compute_phase_changed_at":null`) { t.Fatal("an unknown phase time was not null", string(encoded)) } + if got := listed().SessionID; got == nil || *got != session.ID { + t.Fatal("the attached Session was not reported", got) + } + runtimeSuspensionSQL(t, s.pool, "UPDATE sessions SET deleted_at=clock_timestamp() WHERE id=$1", session.ID) + if encoded, _ := json.Marshal(listed()); !strings.Contains(string(encoded), `"session_id":null`) { + t.Fatal("a deleted Session was reported", string(encoded)) + } } func TestNeverStartedSessionIdleStartsAfterInitialization(t *testing.T) {