diff --git a/.agents/skills/unidesk-gc/SKILL.md b/.agents/skills/unidesk-gc/SKILL.md index b47aa683..4e02ca16 100644 --- a/.agents/skills/unidesk-gc/SKILL.md +++ b/.agents/skills/unidesk-gc/SKILL.md @@ -210,6 +210,9 @@ bun scripts/cli.ts gc remote memory - 只有 `runEligibility.allowed=true` 时才允许执行明确标记安全的内存压力候选; - `--full` 只用于下钻完整身份明细,不得作为 scoped run 的默认前置步骤; - `run` 返回 job id 后,重复执行 `status --job-id ` 直到终态; + - memory-pressure job 的默认终态 `status` 必须直接披露 `outcome`、 + `MemAvailable` 实际增量、`targetMet`、目标缺口和首个失败; + - 上述终态判定不得依赖 `--full`、临时 dump 或提高 stdout 上限; - job 未终态时不得提前执行 `memory` 或重试 WebProbe; - 终态后执行 `memory`,只有不再命中 owning YAML 的 `manualComparator` 时才可重试人工启动; - 内存处置不得改用未带 `--memory-pressure-only` 的通用 plan/run。 diff --git a/docs/reference/gc.md b/docs/reference/gc.md index 98f047c7..5c79a3d0 100644 --- a/docs/reference/gc.md +++ b/docs/reference/gc.md @@ -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` 的顺序复核实际结果。 diff --git a/scripts/src/gc-remote.ts b/scripts/src/gc-remote.ts index ef1acb87..9dee9819 100644 --- a/scripts/src/gc-remote.ts +++ b/scripts/src/gc-remote.ts @@ -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 { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } +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 { + 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 { + 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 { + 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, 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 : [];