From 5f0332cb43bb81702c3e15b3d6602dc0b064f47e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 17 Jul 2026 23:46:27 +0200 Subject: [PATCH] fix(gc): compact remote memory pressure plan --- .agents/skills/unidesk-gc/SKILL.md | 4 +- config/unidesk-cli.yaml | 2 + docs/reference/gc.md | 5 +- scripts/src/gc-remote-memory-pressure.test.ts | 108 ++++++++++ scripts/src/gc-remote.ts | 186 +++++++++++++++++- 5 files changed, 302 insertions(+), 3 deletions(-) diff --git a/.agents/skills/unidesk-gc/SKILL.md b/.agents/skills/unidesk-gc/SKILL.md index 1d370559..c79b7a17 100644 --- a/.agents/skills/unidesk-gc/SKILL.md +++ b/.agents/skills/unidesk-gc/SKILL.md @@ -176,7 +176,9 @@ bun scripts/cli.ts gc remote memory ``` - 顺序约束: - - 先复核 scoped plan,只允许执行其中明确标记安全的内存压力候选; + - 默认 scoped plan 必须直接披露 YAML 限量候选摘要和 `runEligibility`,一次输出即可判断; + - 只有 `runEligibility.allowed=true` 时才允许执行明确标记安全的内存压力候选; + - `--full` 只用于下钻完整身份明细,不得作为 scoped run 的默认前置步骤; - `run` 返回 job id 后,重复执行 `status --job-id ` 直到终态; - job 未终态时不得提前执行 `memory` 或重试 WebProbe; - 终态后执行 `memory`,只有 `MemAvailable > 4 GiB` 才可重试人工启动; diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index 8e7d05ab..4a929f45 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -152,6 +152,7 @@ gc: enabled: false reason: NC01 不是通用构建主机,通用远端 GC 不得清理其活跃 Docker 状态。 memoryPressure: + planPreviewLimit: 6 processPatterns: - k3s-server - containerd @@ -330,6 +331,7 @@ gc: enabled: false reason: JD01 host Docker stores the provider-gateway/trans runtime; Docker builder prune previously preceded snapshot/lowerdir corruption. memoryPressure: + planPreviewLimit: 6 processPatterns: - chrome - chromium diff --git a/docs/reference/gc.md b/docs/reference/gc.md index 8dfecc6c..fe8184cf 100644 --- a/docs/reference/gc.md +++ b/docs/reference/gc.md @@ -24,6 +24,8 @@ - 不得让单次 SSH 等待完整 registry GC 或其他长清理。 - `gc remote plan --memory-pressure-only` / `gc remote run --confirm --memory-pressure-only`: - 只规划或执行 WebProbe observer/Chrome 内存压力候选; + - 默认 plan 直接披露 YAML 限量的可执行候选摘要与 fail-closed 的 `runEligibility`; + - `--full` 只用于下钻完整候选身份明细,不作为 scoped run 的前置步骤; - 不得混入通用磁盘 GC 候选; - 执行入口必须显式携带 `--confirm`。 - `gc remote memory`: @@ -209,7 +211,8 @@ bun scripts/cli.ts gc remote memory ``` - 人工处置顺序: - - 先复核 `plan --memory-pressure-only` 的候选、保护对象和预计回收量; + - 先复核 `plan --memory-pressure-only` 的候选、保护对象、预计回收量和 `runEligibility`; + - 只有 `runEligibility.allowed=true` 时才允许继续; - 只执行参数完全匹配的 `run --confirm --memory-pressure-only`; - `run` 返回 job id 后,重复执行 `status --job-id ` 直到明确终态; - job 未终态时,禁止提前执行 `memory` 或重试 WebProbe; diff --git a/scripts/src/gc-remote-memory-pressure.test.ts b/scripts/src/gc-remote-memory-pressure.test.ts index 5bf6c751..0cd2ab8e 100644 --- a/scripts/src/gc-remote-memory-pressure.test.ts +++ b/scripts/src/gc-remote-memory-pressure.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import { test } from "bun:test"; import { rootPath } from "./config"; +import { projectRemoteGcResult } from "./gc-remote"; const webObserveSource = readFileSync(rootPath("scripts/src/gc-remote-web-observe.py"), "utf8"); const runnerSource = readFileSync(rootPath("scripts/src/gc-remote-runner.py"), "utf8"); @@ -159,3 +160,110 @@ print(json.dumps({"ids": [item["id"] for item in candidates], "swappiness": [ite assert.match(webObserveSource, /minimum SwapFree reserve/u); assert.match(webObserveSource, /memory\.reclaim/u); }); + +test("default memory-pressure plan projects an actionable YAML-bounded preview below stdout limit", () => { + const processCandidate = (index: number): Record => ({ + id: `browser-orphan-process-tree:${1000 + index}:12345`, + kind: "browser-orphan-process-tree", + risk: "medium", + reason: "browser-session-without-observer-record", + rootPid: 1000 + index, + sid: 1000 + index, + rootStartTicks: 12345 + index, + processCount: 20, + processIdentities: Array.from({ length: 20 }, (_, member) => ({ + pid: 1000 + index * 100 + member, + ppid: member === 0 ? 1 : 1000 + index * 100, + sid: 1000 + index, + startTicks: 12345 + index * 100 + member, + rssBytes: 16 * 1024 * 1024, + commandPreview: `/usr/bin/chromium --fixture-${"x".repeat(160)}`, + })), + expectedMemoryRssBytes: 320 * 1024 * 1024, + expectedMemoryRss: "320 MiB", + activeObserverIntersection: false, + treeClosure: "unique-root-ppid-sid-closed", + action: { op: "term-wait-kill-exact-process-tree", allowlist: "yaml-web-probe-memory-pressure" }, + }); + const cgroupCandidate = (index: number): Record => ({ + id: `cgroup-memory-reclaim:file-pages-${index}`, + configId: `file-pages-${index}`, + kind: "cgroup-memory-reclaim", + risk: "medium", + reason: "host-memavailable-below-yaml-target", + path: "/sys/fs/cgroup/system.slice/k3s.service", + reclaimBytes: 256 * 1024 * 1024, + swappiness: 0, + targetMemAvailableBytes: 4 * 1024 * 1024 * 1024, + minimumSwapFreeBytesAfterReclaim: 384 * 1024 * 1024, + memoryCurrentBytes: 6 * 1024 * 1024 * 1024, + memorySwapCurrentBytes: 0, + expectedMemoryRssBytes: 256 * 1024 * 1024, + expectedMemoryRss: "256 MiB", + action: { op: "cgroup-v2-memory-reclaim", allowlist: "yaml-exact-cgroup-path" }, + }); + const candidates = [cgroupCandidate(1), cgroupCandidate(2), ...Array.from({ length: 4 }, (_, index) => processCandidate(index))]; + const payload = { + ok: true, + action: "gc remote plan", + providerId: "NC01", + scope: "memory-pressure-only", + dryRun: true, + mutation: false, + observedAt: "2026-07-17T20:00:00Z", + memoryBefore: { ok: true, memory: { availableBytes: 2 * 1024 * 1024 * 1024 }, swap: { freeBytes: 1024 * 1024 * 1024 } }, + summary: { candidateCount: 6, returnedCandidateCount: 6, expectedMemoryRssBytes: 1_610_612_736, byKind: {} }, + candidateScan: { + ok: true, + blocked: false, + processPressure: { processCount: 800, zombieCount: 0, topRss: [] }, + observeRoots: { configuredCount: 2, readableCount: 2, missingCount: 0, minimumReadable: 1 }, + cgroupReclaim: { configuredCount: 2, eligibleCount: 2, protected: [] }, + policySource: "config/unidesk-cli.yaml#gc.remote.targets.NC01.memoryPressure", + }, + candidates, + protected: { + activeObserverIntersection: "excluded", + processIdentity: "pre-term:pid-ppid-sid-startTicks;post-term:pid-startTicks", + treeClosure: "unique-root-ppid-sid-closed", + }, + policy: { + requiresRunConfirm: true, + runCommand: "bun scripts/cli.ts gc remote NC01 run --confirm --memory-pressure-only", + }, + }; + const config = Bun.YAML.parse(readFileSync(rootPath("config/unidesk-cli.yaml"), "utf8")) as Record; + const remoteTarget = config.gc.remote.targets.NC01 as Record; + const projected = projectRemoteGcResult(payload, remoteTarget, { action: "plan", memoryPressureOnly: true, full: false }) as Record; + + assert.equal(projected.runEligibility.allowed, true); + assert.equal(projected.runEligibility.allCandidatesEvaluated, true); + assert.equal(projected.candidatePreview.limit, remoteTarget.memoryPressure.planPreviewLimit); + assert.equal(projected.candidatePreview.candidates.length, 6); + assert.equal(projected.candidatePreview.candidates[2].identityClosure.identitiesComplete, true); + assert.equal(projected.candidatePreview.candidates[2].processIdentities, undefined); + assert.equal(projected.policy.runCommand, "bun scripts/cli.ts gc remote NC01 run --confirm --memory-pressure-only"); + assert.equal(projected.valuesRedacted, true); + const renderedEnvelope = `${JSON.stringify({ + ok: true, + command: "gc remote NC01 plan --memory-pressure-only", + data: projected, + }, null, 2)}\n`; + assert.ok(Buffer.byteLength(renderedEnvelope, "utf8") < config.output.maxStdoutBytes); + assert.equal(projectRemoteGcResult(payload, remoteTarget, { action: "plan", memoryPressureOnly: true, full: true }), payload); +}); + +test("memory-pressure plan projection fails closed when candidate page or YAML preview policy is incomplete", () => { + const payload = { + ok: true, + summary: { candidateCount: 2 }, + candidateScan: { ok: true, blocked: false }, + candidates: [], + }; + const projected = projectRemoteGcResult(payload, { memoryPressure: {} }, { action: "plan", memoryPressureOnly: true, full: false }) as Record; + assert.equal(projected.runEligibility.allowed, false); + assert.deepEqual(projected.runEligibility.reasons, [ + "candidate-page-incomplete", + "yaml-plan-preview-limit-invalid", + ]); +}); diff --git a/scripts/src/gc-remote.ts b/scripts/src/gc-remote.ts index 3100d6a6..11733861 100644 --- a/scripts/src/gc-remote.ts +++ b/scripts/src/gc-remote.ts @@ -344,6 +344,186 @@ function loadRemoteGcTargetConfig(providerId: string): Record { return {}; } +function recordOrEmpty(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function positiveInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; +} + +function compactMemoryPressureCandidate(value: unknown): Record { + const candidate = recordOrEmpty(value); + const action = recordOrEmpty(candidate.action); + const base = { + id: candidate.id, + kind: candidate.kind, + reason: candidate.reason, + risk: candidate.risk, + expectedMemoryRssBytes: candidate.expectedMemoryRssBytes, + expectedMemoryRss: candidate.expectedMemoryRss, + action: { op: action.op, allowlist: action.allowlist }, + }; + if (candidate.kind === "cgroup-memory-reclaim") { + return { + ...base, + configId: candidate.configId, + path: candidate.path, + reclaimBytes: candidate.reclaimBytes, + swappiness: candidate.swappiness, + targetMemAvailableBytes: candidate.targetMemAvailableBytes, + minimumSwapFreeBytesAfterReclaim: candidate.minimumSwapFreeBytesAfterReclaim, + memoryCurrentBytes: candidate.memoryCurrentBytes, + memorySwapCurrentBytes: candidate.memorySwapCurrentBytes, + }; + } + const identities = Array.isArray(candidate.processIdentities) ? candidate.processIdentities.map(recordOrEmpty) : []; + const processCount = positiveInteger(candidate.processCount); + const identitiesComplete = processCount !== null + && identities.length === processCount + && identities.every((identity) => positiveInteger(identity.pid) !== null + && typeof identity.ppid === "number" && Number.isInteger(identity.ppid) && identity.ppid >= 0 + && positiveInteger(identity.sid) !== null + && positiveInteger(identity.startTicks) !== null); + return { + ...base, + rootPid: candidate.rootPid, + sid: candidate.sid, + rootStartTicks: candidate.rootStartTicks, + processCount: candidate.processCount, + activeObserverIntersection: candidate.activeObserverIntersection, + treeClosure: candidate.treeClosure, + identityClosure: { + identityCount: identities.length, + identitiesComplete, + basis: "pre-term:pid-ppid-sid-startTicks;post-term:pid-startTicks", + }, + }; +} + +function memoryPressureCandidateAllowed(value: unknown): boolean { + const candidate = recordOrEmpty(value); + const action = recordOrEmpty(candidate.action); + if (typeof candidate.id !== "string" || candidate.id.length === 0 || positiveInteger(candidate.expectedMemoryRssBytes) === null) return false; + if (candidate.kind === "cgroup-memory-reclaim") { + return typeof candidate.configId === "string" && candidate.configId.length > 0 + && typeof candidate.path === "string" && candidate.path.startsWith("/") + && positiveInteger(candidate.reclaimBytes) !== null + && action.op === "cgroup-v2-memory-reclaim" + && action.allowlist === "yaml-exact-cgroup-path"; + } + if (candidate.kind !== "browser-orphan-process-tree" && candidate.kind !== "stale-web-observer-process-tree") return false; + const compact = compactMemoryPressureCandidate(candidate); + const identityClosure = recordOrEmpty(compact.identityClosure); + return positiveInteger(candidate.rootPid) !== null + && positiveInteger(candidate.sid) !== null + && positiveInteger(candidate.rootStartTicks) !== null + && candidate.activeObserverIntersection === false + && candidate.treeClosure === "unique-root-ppid-sid-closed" + && identityClosure.identitiesComplete === true + && action.op === "term-wait-kill-exact-process-tree" + && action.allowlist === "yaml-web-probe-memory-pressure"; +} + +function compactMemoryPressureCandidateScan(value: unknown): Record { + const scan = recordOrEmpty(value); + const processPressure = recordOrEmpty(scan.processPressure); + const observeRoots = recordOrEmpty(scan.observeRoots); + const cgroupReclaim = recordOrEmpty(scan.cgroupReclaim); + const cgroupProtected = Array.isArray(cgroupReclaim.protected) ? cgroupReclaim.protected : []; + return { + ok: scan.ok, + blocked: scan.blocked, + configError: scan.configError, + observeScanTruncated: scan.observeScanTruncated, + processScanTruncated: scan.processScanTruncated, + observeScanError: scan.observeScanError, + processScanError: scan.processScanError, + processPressure: { + processCount: processPressure.processCount, + zombieCount: processPressure.zombieCount, + zombieKindCount: Array.isArray(processPressure.zombiesByComm) ? processPressure.zombiesByComm.length : 0, + zombieParentCount: Array.isArray(processPressure.zombiesByParent) ? processPressure.zombiesByParent.length : 0, + topRssSampleCount: Array.isArray(processPressure.topRss) ? processPressure.topRss.length : 0, + }, + observeRoots: { + configuredCount: observeRoots.configuredCount, + readableCount: observeRoots.readableCount, + missingCount: observeRoots.missingCount, + minimumReadable: observeRoots.minimumReadable, + }, + cgroupReclaim: { + configuredCount: cgroupReclaim.configuredCount, + eligibleCount: cgroupReclaim.eligibleCount, + protectedCount: cgroupProtected.length, + protected: cgroupProtected.map((item) => { + const protectedItem = recordOrEmpty(item); + return { id: protectedItem.id, reason: protectedItem.reason, memoryCurrentBytes: protectedItem.memoryCurrentBytes }; + }), + }, + policySource: scan.policySource, + }; +} + +export function projectRemoteGcResult( + value: unknown, + remoteTarget: Record, + context: { action: string; memoryPressureOnly: boolean; full: boolean }, +): unknown { + if (context.action !== "plan" || !context.memoryPressureOnly || context.full) return value; + const payload = recordOrEmpty(value); + const scan = recordOrEmpty(payload.candidateScan); + const summary = recordOrEmpty(payload.summary); + const candidates = Array.isArray(payload.candidates) ? payload.candidates : []; + const memoryPressure = recordOrEmpty(remoteTarget.memoryPressure); + const previewLimit = positiveInteger(memoryPressure.planPreviewLimit); + const totalCandidateCount = positiveInteger(summary.candidateCount) ?? 0; + const allCandidatesEvaluated = totalCandidateCount > 0 && totalCandidateCount === candidates.length; + const reasons: string[] = []; + if (payload.ok !== true) reasons.push("plan-not-ok"); + if (scan.ok !== true || scan.blocked === true) reasons.push("candidate-scan-blocked"); + if (totalCandidateCount === 0) reasons.push("no-candidates"); + if (!allCandidatesEvaluated) reasons.push("candidate-page-incomplete"); + if (!candidates.every(memoryPressureCandidateAllowed)) reasons.push("unsupported-or-incomplete-candidate"); + if (previewLimit === null) reasons.push("yaml-plan-preview-limit-invalid"); + const protectedState = recordOrEmpty(payload.protected); + const compactScan = compactMemoryPressureCandidateScan(scan); + const compactCgroup = recordOrEmpty(compactScan.cgroupReclaim); + return { + ok: payload.ok, + action: payload.action, + providerId: payload.providerId, + scope: payload.scope, + dryRun: payload.dryRun, + mutation: payload.mutation, + observedAt: payload.observedAt, + memoryBefore: payload.memoryBefore, + summary: payload.summary, + candidateScan: compactScan, + runEligibility: { + allowed: reasons.length === 0, + reasons, + evaluatedCandidateCount: candidates.length, + totalCandidateCount, + allCandidatesEvaluated, + }, + candidatePreview: { + limit: previewLimit, + returnedCount: previewLimit === null ? 0 : Math.min(candidates.length, previewLimit), + omittedCount: previewLimit === null ? candidates.length : Math.max(0, candidates.length - previewLimit), + candidates: previewLimit === null ? [] : candidates.slice(0, previewLimit).map(compactMemoryPressureCandidate), + }, + protected: { + activeObserverIntersection: protectedState.activeObserverIntersection, + processIdentity: protectedState.processIdentity, + treeClosure: protectedState.treeClosure, + cgroupProtectedCount: compactCgroup.protectedCount, + }, + policy: payload.policy, + valuesRedacted: true, + }; +} + async function runRemoteGc(config: UniDeskConfig, providerId: string, action: RemoteGcAction, options: RemoteGcOptions): Promise { const remoteTarget = loadRemoteGcTargetConfig(providerId); const scriptConfig = Buffer.from(JSON.stringify({ providerId, action, options, remoteTarget }), "utf8").toString("base64"); @@ -368,7 +548,11 @@ async function runRemoteGc(config: UniDeskConfig, providerId: string, action: Re }; } try { - return JSON.parse(result.stdout) as unknown; + return projectRemoteGcResult(JSON.parse(result.stdout) as unknown, remoteTarget, { + action, + memoryPressureOnly: options.memoryPressureOnly, + full: options.full, + }); } catch (error) { return { ok: false,