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/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..5ee7e2c50 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..12ff5dac3 100644
--- a/services/core/internal/persistence/postgres/deploymentpg/allocations.go
+++ b/services/core/internal/persistence/postgres/deploymentpg/allocations.go
@@ -486,9 +486,13 @@ 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 {
+ 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),
- 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,
})
}
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) {