feat(v0.1): add mgr session PVC lifecycle for true session state persistence

PR B for #770: mgr/session-pvc.ts + server endpoints + selftest.

- 新模块 src/mgr/session-pvc.ts: createSessionPvc / getSessionPvcSummary / deleteSessionPvc / refreshSessionPvcSummary / runSessionStorageGc / startSessionStorageGcLoop
- Server 增量 4 个 endpoint:
  * POST /api/v1/sessions: 创建 session 同步创建 PVC
  * GET /api/v1/sessions/:id/storage: 查询 PVC 摘要
  * DELETE /api/v1/sessions/:id/storage: 删 PVC + storage_kind=evicted
  * POST /api/v1/sessions/:id/storage/refresh: runner 上报 PVC 摘要
  * POST /api/v1/sessions/storage/gc: 手动触发 GC
- mgr SA RBAC 已在 PR A 增加;manager server 不直连 Kubernetes API(kubectl 由 mgr 容器内执行)
- SessionRecord 增量 storageKind / storagePvcName / storageNamespace / storageSizeBytes / storageFilesCount / storageSha256 / storageUpdatedAt / storagePvcPhase / storageEvictedAt / codexRolloutSubdir
- kubernetes-runner-job 短路:run 引用 evicted session 时直接返回 session-store-evicted,不创建 runner Job
- KubectlHandler 可注入,selftest 覆盖 create / summary / refresh / eviction / gc / REST 路径
- GC loop 默认 5min(AGENTRUN_SESSION_GC_INTERVAL_MS 可调)

runner / backend / HWLAB adapter 在 PR C / PR D 落地。
This commit is contained in:
Codex
2026-06-03 19:14:42 +08:00
parent 87beb00bdb
commit e8cfa4c692
7 changed files with 752 additions and 4 deletions
+88
View File
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { startManagerServer } from "../../mgr/server.js";
import { MemoryAgentRunStore } from "../../mgr/store.js";
import { ManagerClient } from "../../mgr/client.js";
import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc, sessionPvcNameFor } from "../../mgr/session-pvc.js";
import type { KubectlHandler, SessionPvcOptions } from "../../mgr/session-pvc.js";
import type { SelfTestCase } from "../harness.js";
function makeFakeKubectl(): KubectlHandler {
return ({ args, stdin }) => {
if (args[0] === "create") {
const manifest = stdin ? JSON.parse(stdin) : {};
return { stdout: JSON.stringify({ apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: manifest.metadata?.name, namespace: manifest.metadata?.namespace }, status: { phase: "Bound" } }), stderr: "", exitCode: 0 };
}
if (args[0] === "get") {
return { stdout: JSON.stringify({ apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: args[2] }, status: { phase: "Bound", capacity: { storage: "1Gi" } } }), stderr: "", exitCode: 0 };
}
if (args[0] === "delete") {
return { stdout: "", stderr: "", exitCode: 0 };
}
return { stdout: "{}", stderr: "", exitCode: 0 };
};
}
const selfTest: SelfTestCase = async () => {
const fakeKubectl = makeFakeKubectl();
const opts: SessionPvcOptions = { kubectlHandler: fakeKubectl, defaultCodexRolloutSubdir: "sessions" };
const store = new MemoryAgentRunStore();
const sessionId = "sess_test_pvc_001";
store.upsertSession({ sessionId, tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex", conversationId: null, threadId: null, metadata: {}, expiresAt: new Date(Date.now() + 86_400_000).toISOString(), codexRolloutSubdir: "sessions" });
const summary = await createSessionPvc({ store, sessionId, options: opts });
assert.equal(summary.pvcName, sessionPvcNameFor(sessionId));
assert.equal(summary.pvcPhase, "Bound");
assert.equal(summary.codexRolloutSubdir, "sessions");
const after = await store.getSession(sessionId);
assert.equal(after?.storageKind, "pvc");
assert.equal(after?.storagePvcName, summary.pvcName);
const readSummary = await getSessionPvcSummary({ store, sessionId, options: opts });
assert.equal(readSummary.pvcName, summary.pvcName);
assert.equal(readSummary.pvcPhase, "Bound");
assert.equal(readSummary.storageSizeBytes, 1024 * 1024 * 1024);
await refreshSessionPvcSummary({ store, sessionId, summary: { ...summary, storageFilesCount: 7, storageSha256: "abc123" }, options: opts });
const refreshed = await store.getSession(sessionId);
assert.equal(refreshed?.storageFilesCount, 7);
assert.equal(refreshed?.storageSha256, "abc123");
const evicted = await deleteSessionPvc({ store, sessionId, options: opts });
assert.equal(evicted.storageKind, "evicted");
const evictedSession = await store.getSession(sessionId);
assert.equal(evictedSession?.storageKind, "evicted");
assert.ok(evictedSession?.storageEvictedAt);
const now = Date.now();
const expiredId = "sess_expired_001";
const activeId = "sess_active_001";
store.upsertSession({ sessionId: expiredId, tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex", conversationId: null, threadId: null, metadata: {}, expiresAt: new Date(now - 60_000).toISOString(), codexRolloutSubdir: "sessions" });
await store.refreshSessionStorage({ sessionId: expiredId, storageKind: "pvc", pvcName: sessionPvcNameFor(expiredId), codexRolloutSubdir: "sessions" });
store.upsertSession({ sessionId: activeId, tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex", conversationId: null, threadId: null, metadata: {}, expiresAt: new Date(now - 60_000).toISOString(), codexRolloutSubdir: "sessions" });
await store.refreshSessionStorage({ sessionId: activeId, storageKind: "pvc", pvcName: sessionPvcNameFor(activeId), codexRolloutSubdir: "sessions" });
const activeRun = store.createRun({ tenantId: "hwlab", projectId: "pikasTech/HWLAB", workspaceRef: { kind: "opaque", path: "." }, providerId: "G14", backendProfile: "codex", executionPolicy: { sandbox: "worktree", approval: "never", timeoutMs: 600_000, network: "restricted", secretScope: {} }, traceSink: null, sessionRef: { sessionId: activeId }, resourceBundleRef: null });
store.createCommand(activeRun.id, { type: "turn", payload: { prompt: "test" } });
const cycle = await runSessionStorageGc({ store, options: opts, now, maxSessions: 10 });
assert.ok(cycle.scanned >= 1);
assert.ok(cycle.deleted >= 1);
const expiredAfter = await store.getSession(expiredId);
assert.equal(expiredAfter?.storageKind, "evicted");
const activeAfter = await store.getSession(activeId);
assert.equal(activeAfter?.storageKind, "pvc");
const restStore = new MemoryAgentRunStore();
const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store: restStore, sessionPvcOptions: { kubectlHandler: fakeKubectl } });
try {
const client = new ManagerClient(server.baseUrl);
const create = await client.post("/api/v1/sessions", { sessionId: "sess_rest_create_001", tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex" }) as { action: string; pvc: { pvcName: string; pvcPhase: string } };
assert.equal(create.action, "session-created");
assert.ok(create.pvc.pvcName.startsWith("agentrun-v01-session-"));
const existing = await client.post("/api/v1/sessions", { sessionId: "sess_rest_create_001", tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex" }) as { action: string };
assert.equal(existing.action, "session-exists");
} finally {
if (server.server.listening) await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
return { name: "mgr-session-pvc", tests: ["session-pvc-create-summary-eviction", "session-pvc-gc", "session-pvc-rest-create"] };
};
export default selfTest;