Files
rea/src/domain/process/processCaptureCoverage.ts
T
morluto 1d826f304f fix(process): bound normalized terminal retention before allocation
Pre-count ordered normalization stages before materializing output. Charge original and normalized terminal bytes independently, omit whole over-budget chunks and preserve exact raw counts plus explicit unknown final sizes. Bound rendered normalization, retain legacy capture compatibility, and remove temporary per-match output arrays.

Validation: UTF-8 surrogate boundaries, shrinking/no-op/literal rules, capture validation and public MCP expansion/equal-size controls with verified cleanup. CPU, process-family memory and token changes are unmeasured; no performance speedup claimed. Nonterminal derived payload bounds remain separate work.

Fixes #3168
2026-10-14 16:54:08 +08:00

136 lines
5.1 KiB
TypeScript

import { z } from "zod";
import type { UnverifiedProcessCapture } from "./processCapture.js";
import type { ProcessObservationSource } from "./processObservation.js";
const byteRetentionSchema = z.strictObject({
budget_bytes: z.number().int().nonnegative(),
observed_bytes: z.number().int().nonnegative(),
retained_bytes: z.number().int().nonnegative(),
observed_frames: z.number().int().nonnegative(),
retained_frames: z.number().int().nonnegative(),
unknown_size_frames: z.number().int().nonnegative().optional(),
});
/** Filesystem enumeration and whole-file digest coverage for one checkpoint. */
export const filesystemCoverageSchema = z.strictObject({
files_limit: z.number().int().nonnegative(),
depth_limit: z.number().int().nonnegative(),
enumeration_truncated: z.boolean(),
enumeration_reasons: z.array(
z.enum([
"files_limit",
"depth_limit",
"identity_changed",
"directory_unavailable",
]),
),
enumeration_omissions: z.array(
z.strictObject({
path: z.string(),
reason: z.literal("directory_unavailable"),
system_code: z.string().nullable(),
}),
),
hash_budget_bytes: z.number().int().nonnegative(),
hashed_bytes: z.number().int().nonnegative(),
hash_omissions: z.array(
z.strictObject({
path: z.string(),
size_bytes: z.number().int().nonnegative(),
remaining_budget_bytes: z.number().int().nonnegative(),
reason: z.enum([
"file_bytes_budget",
"file_changed_or_short_read",
"file_unavailable",
]),
system_code: z.string().nullable(),
}),
),
});
export type FilesystemCoverage = z.infer<typeof filesystemCoverageSchema>;
/** Actual budget accounting; rendered bytes count state and visible lines, not JSON. */
export const processCaptureTruncationDetailsSchema = z.strictObject({
raw_terminal: byteRetentionSchema.describe(
"UTF-8 bytes of original PTY chunks; whole chunks are retained or omitted.",
),
rendered_terminal: byteRetentionSchema.describe(
"Cumulative UTF-8 bytes of serialized state plus visible lines, evaluated from retained PTY input; whole frames are retained or omitted. When normalization exceeds a bound before the frame size is known, observed_bytes contains the exact total for size-known frames and unknown_size_frames counts the other omissions.",
),
normalized_terminal: z
.strictObject({
budget_bytes: z.number().int().nonnegative(),
retained_bytes: z.number().int().nonnegative(),
omitted_frames: z.number().int().nonnegative(),
})
.optional()
.describe(
"UTF-8 bytes of normalized raw-chunk text retained under output_bytes; omitted frames can have unknown final size when normalization exceeded an intermediate bound.",
),
filesystem_before: filesystemCoverageSchema,
filesystem_after: filesystemCoverageSchema,
process: z.strictObject({
sample_limit: z.number().int().nonnegative(),
sampling_partial: z.boolean(),
retained_samples: z.number().int().nonnegative(),
sample_limit_reached: z.boolean(),
sampling_failures: z.number().int().nonnegative(),
first_sampling_failure: z.string().nullable(),
coverage: z.literal("sampled"),
}),
});
export type ProcessCaptureTruncationDetails = z.infer<
typeof processCaptureTruncationDetailsSchema
>;
export type TerminalRetention = ProcessCaptureTruncationDetails["raw_terminal"];
export type NormalizedTerminalRetention = NonNullable<
ProcessCaptureTruncationDetails["normalized_terminal"]
>;
export type ProcessSamplingCoverage =
ProcessCaptureTruncationDetails["process"];
const omitted = (retention: TerminalRetention): boolean =>
retention.observed_frames > retention.retained_frames;
const filesystemTruncated = (coverage: FilesystemCoverage): boolean =>
coverage.enumeration_truncated ||
coverage.enumeration_omissions.length > 0 ||
coverage.hash_omissions.length > 0;
/** Derive the aggregate truncation flag from independent producer observations. */
export const hasCaptureTruncation = (
details: ProcessCaptureTruncationDetails,
): boolean =>
omitted(details.raw_terminal) ||
omitted(details.rendered_terminal) ||
(details.normalized_terminal?.omitted_frames ?? 0) > 0 ||
(details.rendered_terminal.unknown_size_frames ?? 0) > 0 ||
filesystemTruncated(details.filesystem_before) ||
filesystemTruncated(details.filesystem_after) ||
details.process.sampling_partial;
/** Whether producer accounting reports a gap in the selected observation source. */
export const processSourceTruncated = (
capture: UnverifiedProcessCapture,
source: ProcessObservationSource,
): boolean => {
const details = capture.truncation_details;
switch (source) {
case "terminal_raw":
return omitted(details.raw_terminal);
case "terminal_rendered":
return (
omitted(details.raw_terminal) || omitted(details.rendered_terminal)
);
case "filesystem":
return (
filesystemTruncated(details.filesystem_before) ||
filesystemTruncated(details.filesystem_after)
);
case "process":
return details.process.sampling_partial;
case "interaction":
case "lifecycle":
return false;
}
};