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:
+152
-1
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user