fix: 收敛 GC 内存终态投影
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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
@@ -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 : [];
|
||||
|
||||
Reference in New Issue
Block a user