fix: 收敛 GC 内存终态投影

This commit is contained in:
pikastech
2026-07-22 01:03:56 +02:00
parent 844d726fc7
commit 9b0360cd73
3 changed files with 125 additions and 1 deletions
+3
View File
@@ -210,6 +210,9 @@ bun scripts/cli.ts gc remote <node> memory
- 只有 `runEligibility.allowed=true` 时才允许执行明确标记安全的内存压力候选;
- `--full` 只用于下钻完整身份明细,不得作为 scoped run 的默认前置步骤;
- `run` 返回 job id 后,重复执行 `status --job-id <id>` 直到终态;
- memory-pressure job 的默认终态 `status` 必须直接披露 `outcome`
`MemAvailable` 实际增量、`targetMet`、目标缺口和首个失败;
- 上述终态判定不得依赖 `--full`、临时 dump 或提高 stdout 上限;
- job 未终态时不得提前执行 `memory` 或重试 WebProbe
- 终态后执行 `memory`,只有不再命中 owning YAML 的 `manualComparator` 时才可重试人工启动;
- 内存处置不得改用未带 `--memory-pressure-only` 的通用 plan/run。
+3
View File
@@ -88,6 +88,9 @@ NC01 的内存压力 cgroup 回收规则:
- cgroup `reclaimBytes` 只表示内核请求上界,不是预期收益:
- plan 必须显示 `estimateKind=kernel-request-upper-bound`
- job 终态必须分别披露 cgroup 实际下降、`MemAvailable` 变化、`targetMet` 和剩余缺口;
- 默认 `status --job-id` 必须用有界摘要直接披露终态 `outcome`
`MemAvailable` 实际增量、`targetMet`、目标缺口和首个失败;
- 终态判定不得要求 `--full`、临时 dump 或提高 stdout 上限;
- 内核请求成功但目标未达到时,结果必须为 `memory-target-not-met`,不得按请求量宣称已回收;
-`plan``run --confirm``status``memory` 的顺序复核实际结果。
+119 -1
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import { type UniDeskConfig, rootPath } from "./config";
import { remoteGcDegradedFailure } from "./gc-remote-degraded";
import { readCliOutputPolicy } from "./output";
import { runSshCommandCapture } from "./ssh";
type RemoteGcAction = "plan" | "memory" | "memory-distribution" | "retention-plan" | "retention-run" | "merged-worktrees-plan" | "merged-worktrees-run" | "snapshot" | "trend" | "run" | "status" | "policy-plan" | "policy-install" | "policy-status";
@@ -428,6 +429,25 @@ function recordOrEmpty(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function boundedUtf8Text(value: unknown, maxBytes: number): { text: string; truncated: boolean } | undefined {
if (typeof value !== "string") return undefined;
const text = value.replace(/\s+/gu, " ").trim();
if (Buffer.byteLength(text, "utf8") <= maxBytes) return { text, truncated: false };
let low = 0;
let high = text.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (Buffer.byteLength(text.slice(0, middle), "utf8") <= maxBytes) low = middle;
else high = middle - 1;
}
const safeEnd = low > 0 && /[\uD800-\uDBFF]/u.test(text[low - 1] ?? "") ? low - 1 : low;
return { text: text.slice(0, safeEnd), truncated: true };
}
function positiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
}
@@ -694,13 +714,111 @@ function compactMemoryPressureCandidateScan(value: unknown): Record<string, unkn
};
}
function compactMemoryStatusSnapshot(value: unknown): Record<string, unknown> {
const snapshot = recordOrEmpty(value);
const memory = recordOrEmpty(snapshot.memory);
return {
ok: snapshot.ok,
metric: snapshot.metric,
source: snapshot.source,
swapExcluded: snapshot.swapExcluded,
availableBytes: memory.availableBytes,
};
}
function compactMemoryPressureFailure(value: unknown, errorBudgetBytes: number): Record<string, unknown> {
const failure = recordOrEmpty(value);
const error = boundedUtf8Text(failure.error ?? failure.kernelWriteError ?? failure.message, errorBudgetBytes);
return {
id: failure.id,
configId: failure.configId,
kind: failure.kind,
status: failure.status,
outcome: failure.outcome,
error: error?.text,
errorTruncated: error?.truncated,
};
}
function compactMemoryPressureStatus(value: unknown): Record<string, unknown> {
const payload = recordOrEmpty(value);
const summary = recordOrEmpty(payload.summary);
const results = Array.isArray(payload.results) ? payload.results : [];
const memoryBefore = compactMemoryStatusSnapshot(payload.memoryBefore);
const memoryAfter = compactMemoryStatusSnapshot(payload.memoryAfter);
const beforeBytes = finiteNumber(memoryBefore.availableBytes);
const afterBytes = finiteNumber(memoryAfter.availableBytes);
const actualDeltaBytes = finiteNumber(summary.actualMemoryAvailableDeltaBytes)
?? (beforeBytes !== undefined && afterBytes !== undefined ? afterBytes - beforeBytes : undefined);
const stdoutBudgetBytes = readCliOutputPolicy().maxStdoutBytes;
const errorBudgetBytes = Math.max(256, Math.floor(stdoutBudgetBytes / 8));
const resultFailure = results.find((item) => recordOrEmpty(item).status === "failed");
const jobFailure = payload.status === "failed"
? { status: payload.status, outcome: payload.outcome, error: payload.error, message: payload.message }
: undefined;
const firstFailure = resultFailure ?? jobFailure;
const outcome = payload.outcome ?? summary.outcome;
const status = payload.status;
const terminal = status === "succeeded" || status === "failed" || status === "blocked" || status === "canceled";
const statusCommand = typeof payload.statusCommand === "string" ? payload.statusCommand : undefined;
return {
ok: payload.ok,
action: payload.action,
providerId: payload.providerId,
scope: payload.scope,
status,
terminal,
outcome,
jobId: payload.jobId,
statusCommand,
statePath: payload.statePath,
dryRun: payload.dryRun,
mutation: payload.mutation,
observedAt: payload.observedAt,
startedAt: payload.startedAt,
finishedAt: payload.finishedAt,
progress: payload.progress,
worker: {
pid: payload.workerPid,
alive: payload.workerAlive,
completed: payload.workerCompleted,
heartbeatAt: payload.workerHeartbeatAt,
},
memory: {
metric: memoryAfter.metric ?? memoryBefore.metric ?? "MemAvailable",
source: memoryAfter.source ?? memoryBefore.source ?? "/proc/meminfo",
swapExcluded: memoryAfter.swapExcluded ?? memoryBefore.swapExcluded ?? true,
beforeBytes,
afterBytes,
actualDeltaBytes,
},
summary: {
attemptedCount: summary.attemptedCount,
succeededCount: summary.succeededCount,
failedCount: summary.failedCount,
targetMemAvailableBytes: summary.targetMemAvailableBytes,
targetMet: summary.targetMet,
targetGapBytes: summary.targetGapBytes,
outcome,
},
selection: payload.selection,
firstFailure: firstFailure === undefined ? null : compactMemoryPressureFailure(firstFailure, errorBudgetBytes),
drillDown: statusCommand === undefined ? undefined : { fullStatusCommand: `${statusCommand} --full` },
valuesRedacted: true,
};
}
export function projectRemoteGcResult(
value: unknown,
remoteTarget: Record<string, unknown>,
context: { action: string; memoryPressureOnly: boolean; full: boolean },
): unknown {
if (context.action !== "plan" || !context.memoryPressureOnly || context.full) return value;
const payload = recordOrEmpty(value);
if (context.full) return value;
if (context.action === "status" && payload.scope === "memory-pressure-only") {
return compactMemoryPressureStatus(payload);
}
if (context.action !== "plan" || !context.memoryPressureOnly) return value;
const scan = recordOrEmpty(payload.candidateScan);
const summary = recordOrEmpty(payload.summary);
const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];