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
+152 -1
View File
@@ -3,7 +3,7 @@ import { Pool } from "pg";
import type { PoolClient, QueryResultRow } from "pg";
import { AgentRunError } from "../common/errors.js";
import { redactJson } from "../common/redaction.js";
import type { BackendProfile, BackendTurnResult, CommandRecord, CommandState, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, EventType, FailureKind, JsonRecord, JsonValue, QueueCommanderSnapshot, QueueReadCursorRecord, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, RunStatus, SessionEventPage, SessionListResult, SessionReadCursorRecord, SessionRecord, SessionRef, SessionSummary, TerminalStatus } from "../common/types.js";
import type { BackendProfile, BackendTurnResult, CommandRecord, CommandState, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, EventType, FailureKind, JsonRecord, JsonValue, ListGcExpiredSessionsInput, QueueCommanderSnapshot, QueueReadCursorRecord, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, RunStatus, SessionEventPage, SessionListResult, SessionReadCursorRecord, SessionRecord, SessionRef, SessionStoragePatch, SessionSummary, TerminalStatus, UpsertSessionInput } from "../common/types.js";
import { newId, nowIso, stableHash } from "../common/validation.js";
import type { AgentRunStore, ListQueueTasksInput, ListSessionsInput, SaveRunnerJobInput, SessionEventPageInput, StoreHealth, UpdateQueueTaskAttemptInput } from "./store.js";
import { assertSessionBoundary, buildQueueStats, buildSessionSummary, clampQueueLimit, clampSessionLimit, commandStateFromTerminal, isLeaseExpired, isSessionOutputEvent, isTerminalCommandState, isTerminalQueueTaskState, isTerminalRunStatus, parseQueueCursor, parseSessionCursor, queueTaskSort, sessionListFilters, sessionMatchesListState, sessionRefFromRecord, sessionSort, sessionTitleFromCommand, statusFromTerminal, summarizeResourceBundleRef, summarizeSessionRef, titleFromMetadata } from "./store.js";
@@ -710,6 +710,147 @@ CREATE TABLE IF NOT EXISTS agentrun_schema_migrations (
});
}
async upsertSession(input: UpsertSessionInput): Promise<SessionRecord> {
return this.withTransaction(async (client) => {
const existing = await client.query<QueryResultRow>("SELECT * FROM agentrun_sessions WHERE session_id = $1 FOR UPDATE", [input.sessionId]);
const at = nowIso();
if (existing.rows[0]) {
const session = sessionFromRow(existing.rows[0]);
const next: SessionRecord = {
...session,
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: session.version + 1,
updatedAt: at,
};
await client.query(
`UPDATE agentrun_sessions
SET tenant_id = $2, project_id = $3, backend_profile = $4, conversation_id = $5, thread_id = $6,
metadata = $7, expires_at = $8, codex_rollout_subdir = $9, version = $10, updated_at = $11
WHERE session_id = $1`,
[next.sessionId, next.tenantId, next.projectId, next.backendProfile, next.conversationId, next.threadId,
JSON.stringify(next.metadata), next.expiresAt, next.codexRolloutSubdir, next.version, next.updatedAt],
);
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,
};
await client.query(
`INSERT INTO agentrun_sessions (session_id, tenant_id, project_id, backend_profile, conversation_id, thread_id,
metadata, version, execution_state, last_run_id, last_command_id, active_run_id,
active_command_id, last_event_seq, terminal_status, failure_kind, title, summary,
last_activity_at, created_at, updated_at, expires_at, storage_kind, codex_rollout_subdir)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'idle', null, null, null, null, 0, null, null, null, '{}'::jsonb, null, $9, $10, $11, 'none', $12)`,
[next.sessionId, next.tenantId, next.projectId, next.backendProfile, next.conversationId, next.threadId,
JSON.stringify(next.metadata), next.version, next.createdAt, next.updatedAt, next.expiresAt, next.codexRolloutSubdir],
);
return next;
});
}
async refreshSessionStorage(input: SessionStoragePatch): Promise<SessionRecord> {
return this.withTransaction(async (client) => {
const existing = await client.query("SELECT * FROM agentrun_sessions WHERE session_id = $1 FOR UPDATE", [input.sessionId]);
if (!existing.rows[0]) throw new AgentRunError("schema-invalid", `session ${input.sessionId} was not found`, { httpStatus: 404 });
const session = sessionFromRow(existing.rows[0]);
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,
};
await client.query(
`UPDATE agentrun_sessions
SET storage_kind = $2, storage_pvc_name = $3, storage_namespace = $4, storage_pvc_phase = $5,
storage_size_bytes = $6, storage_files_count = $7, storage_sha256 = $8,
storage_updated_at = $9, codex_rollout_subdir = $10, version = $11, updated_at = $12
WHERE session_id = $1`,
[next.sessionId, next.storageKind, next.storagePvcName, next.storageNamespace, next.storagePvcPhase,
next.storageSizeBytes, next.storageFilesCount, next.storageSha256,
next.storageUpdatedAt, next.codexRolloutSubdir, next.version, next.updatedAt],
);
return next;
});
}
async markSessionStorageEvicted(input: { sessionId: string; pvcName: string }): Promise<SessionRecord> {
return this.withTransaction(async (client) => {
const existing = await client.query("SELECT * FROM agentrun_sessions WHERE session_id = $1 FOR UPDATE", [input.sessionId]);
if (!existing.rows[0]) throw new AgentRunError("schema-invalid", `session ${input.sessionId} was not found`, { httpStatus: 404 });
const session = sessionFromRow(existing.rows[0]);
const at = nowIso();
const next: SessionRecord = {
...session,
storageKind: "evicted",
storagePvcName: input.pvcName,
storageEvictedAt: at,
storageUpdatedAt: at,
version: session.version + 1,
updatedAt: at,
};
await client.query(
`UPDATE agentrun_sessions
SET storage_kind = $2, storage_pvc_name = $3, storage_evicted_at = $4, storage_updated_at = $5,
version = $6, updated_at = $7
WHERE session_id = $1`,
[next.sessionId, next.storageKind, next.storagePvcName, next.storageEvictedAt, next.storageUpdatedAt, next.version, next.updatedAt],
);
return next;
});
}
async listGcExpiredSessions(input: ListGcExpiredSessionsInput): Promise<SessionRecord[]> {
const limit = Math.max(1, input.limit);
const result = await this.pool.query<QueryResultRow>(
`SELECT * FROM agentrun_sessions
WHERE storage_kind = 'pvc' AND storage_pvc_name IS NOT NULL
AND expires_at IS NOT NULL AND expires_at <= to_timestamp($1)
ORDER BY updated_at ASC
LIMIT $2`,
[input.now / 1000, limit],
);
return result.rows.map(sessionFromRow);
}
async createQueueTask(input: CreateQueueTaskInput): Promise<QueueTaskRecord> {
const payloadHash = stableHash(input.payload);
return this.withTransaction(async (client) => {
@@ -1153,6 +1294,16 @@ function sessionFromRow(row: QueryResultRow): SessionRecord {
createdAt: iso(row.created_at),
updatedAt: iso(row.updated_at),
expiresAt: nullableIso(row.expires_at),
storageKind: (stringValue(row.storage_kind ?? "none") as SessionRecord["storageKind"]) ?? "none",
storagePvcName: nullableString(row.storage_pvc_name),
storageNamespace: nullableString(row.storage_namespace),
storageSizeBytes: row.storage_size_bytes !== null && row.storage_size_bytes !== undefined ? Number(row.storage_size_bytes) : null,
storageFilesCount: row.storage_files_count !== null && row.storage_files_count !== undefined ? Number(row.storage_files_count) : null,
storageSha256: nullableString(row.storage_sha256),
storagePvcPhase: nullableString(row.storage_pvc_phase),
storageUpdatedAt: nullableIso(row.storage_updated_at),
storageEvictedAt: nullableIso(row.storage_evicted_at),
codexRolloutSubdir: stringValue(row.codex_rollout_subdir ?? "sessions"),
};
}