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
+107 -1
View File
@@ -1,4 +1,4 @@
import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, FailureKind, JsonRecord, QueueAttemptRef, QueueCommanderSnapshot, QueueReadCursorRecord, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, SessionEventPage, SessionListResult, SessionListState, SessionReadCursorRecord, SessionRecord, SessionRef, SessionSummary, TerminalStatus } from "../common/types.js";
import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, FailureKind, JsonRecord, ListGcExpiredSessionsInput, QueueAttemptRef, QueueCommanderSnapshot, QueueReadCursorRecord, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, SessionEventPage, SessionListResult, SessionListState, SessionReadCursorRecord, SessionRecord, SessionRef, SessionStoragePatch, SessionSummary, TerminalStatus, UpsertSessionInput } from "../common/types.js";
import { AgentRunError } from "../common/errors.js";
import { newId, nowIso, stableHash } from "../common/validation.js";
import { redactJson } from "../common/redaction.js";
@@ -44,6 +44,10 @@ export interface AgentRunStore {
listSessionTrace(sessionId: string, input: SessionEventPageInput): MaybePromise<SessionEventPage>;
listSessionOutput(sessionId: string, input: SessionEventPageInput): MaybePromise<SessionEventPage>;
markSessionRead(sessionId: string, readerId: string): MaybePromise<SessionReadCursorRecord>;
upsertSession(input: UpsertSessionInput): MaybePromise<SessionRecord>;
refreshSessionStorage(input: SessionStoragePatch): MaybePromise<SessionRecord>;
markSessionStorageEvicted(input: { sessionId: string; pvcName: string }): MaybePromise<SessionRecord>;
listGcExpiredSessions(input: ListGcExpiredSessionsInput): MaybePromise<SessionRecord[]>;
createQueueTask(input: CreateQueueTaskInput): MaybePromise<QueueTaskRecord>;
listQueueTasks(input: ListQueueTasksInput): MaybePromise<QueueTaskListResult>;
getQueueTask(taskId: string): MaybePromise<QueueTaskRecord>;
@@ -344,6 +348,108 @@ export class MemoryAgentRunStore implements AgentRunStore {
return record;
}
upsertSession(input: UpsertSessionInput): SessionRecord {
const at = nowIso();
const existing = this.sessions.get(input.sessionId);
if (existing) {
const next: SessionRecord = {
...existing,
tenantId: input.tenantId,
projectId: input.projectId,
backendProfile: input.backendProfile,
conversationId: input.conversationId,
threadId: input.threadId,
metadata: input.metadata,
expiresAt: input.expiresAt,
codexRolloutSubdir: input.codexRolloutSubdir,
version: existing.version + 1,
updatedAt: at,
};
this.sessions.set(input.sessionId, next);
this.sessionVersion = Math.max(this.sessionVersion, next.version) + 1;
return next;
}
const next: SessionRecord = {
sessionId: input.sessionId,
tenantId: input.tenantId,
projectId: input.projectId,
backendProfile: input.backendProfile,
conversationId: input.conversationId,
threadId: input.threadId,
metadata: input.metadata,
version: 1,
executionState: "idle",
lastRunId: null,
lastCommandId: null,
activeRunId: null,
activeCommandId: null,
lastEventSeq: 0,
terminalStatus: null,
failureKind: null,
title: null,
summary: {},
lastActivityAt: null,
createdAt: at,
updatedAt: at,
expiresAt: input.expiresAt,
storageKind: "none",
codexRolloutSubdir: input.codexRolloutSubdir,
};
this.sessions.set(input.sessionId, next);
this.sessionVersion = Math.max(this.sessionVersion, next.version) + 1;
return next;
}
refreshSessionStorage(input: SessionStoragePatch): SessionRecord {
const session = this.sessions.get(input.sessionId);
if (!session) throw new AgentRunError("schema-invalid", `session ${input.sessionId} was not found`, { httpStatus: 404 });
const at = nowIso();
const next: SessionRecord = {
...session,
storageKind: input.storageKind,
storagePvcName: input.pvcName ?? null,
storageNamespace: input.storageNamespace ?? null,
storagePvcPhase: input.pvcPhase ?? null,
storageSizeBytes: input.storageSizeBytes ?? null,
storageFilesCount: input.storageFilesCount ?? null,
storageSha256: input.storageSha256 ?? null,
codexRolloutSubdir: input.codexRolloutSubdir ?? session.codexRolloutSubdir ?? "sessions",
storageUpdatedAt: at,
version: session.version + 1,
updatedAt: at,
};
this.sessions.set(input.sessionId, next);
this.sessionVersion = Math.max(this.sessionVersion, next.version) + 1;
return next;
}
markSessionStorageEvicted(input: { sessionId: string; pvcName: string }): SessionRecord {
const session = this.sessions.get(input.sessionId);
if (!session) throw new AgentRunError("schema-invalid", `session ${input.sessionId} was not found`, { httpStatus: 404 });
const at = nowIso();
const next: SessionRecord = {
...session,
storageKind: "evicted",
storagePvcName: input.pvcName,
storageEvictedAt: at,
storageUpdatedAt: at,
version: session.version + 1,
updatedAt: at,
};
this.sessions.set(input.sessionId, next);
this.sessionVersion = Math.max(this.sessionVersion, next.version) + 1;
return next;
}
listGcExpiredSessions(input: ListGcExpiredSessionsInput): SessionRecord[] {
const now = input.now;
return Array.from(this.sessions.values())
.filter((session) => session.storageKind === "pvc" && Boolean(session.storagePvcName))
.filter((session) => session.expiresAt !== null && Date.parse(session.expiresAt ?? "") <= now)
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))
.slice(0, Math.max(1, input.limit));
}
createQueueTask(input: CreateQueueTaskInput): QueueTaskRecord {
const payloadHash = stableHash(input.payload);
if (input.idempotencyKey) {