compaction: the service — settle kick, turn-start gate, registry mutex (round 14 slice 2)

- src/server/compaction.ts: ensureCompacted (the turn-start gate — awaits
  in-flight compaction, compacts synchronously when still over threshold),
  kickCompaction (the settle kick — summary starts inside the learner's
  reading/answering pause; question/problem poses end runs so the pause is
  always a between-runs window), registry mutex on globalThis, anti-churn
  guard (empty-span skip), summary via the zero-tool one-shot sub-agent
  seam on the session's OWN engine (owner ruling), usage to the turns log
  keyed by the marker, record-changed + compaction-changed events
- chat-handler: the gate rides with the BYOK/key gates (before appends,
  beginTurn; record re-read after); the kick at the onFinish tail
  (re-resolves engine fresh; silent no-op when engineless)
- events: compaction-changed {chatId, running} — running=true is the
  client send-gate signal
- settings: compaction.thresholdTokens (factory 150k, floor 32k,
  clamp-never-reject) resolved concrete in EffectiveSettings
- effective threshold = min(setting, contextWindow − 16k) over the live
  catalog index
- overflow recovery needs no special path: the next turn's gate compacts
- 8 service pins (mutex, events order, chained spans, failure re-arm)
This commit is contained in:
amosblomqvist
2026-10-05 22:17:33 +03:00
parent 7e9597d969
commit dd159cc8fb
6 changed files with 612 additions and 2 deletions
+38
View File
@@ -328,6 +328,24 @@ export interface GraphSettings {
extractionChunkTokens?: number;
}
/**
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic's
* user-facing knob. One number — the trigger threshold in context tokens.
* The effective trigger is min(this, model contextWindow − 16k reserve)
* (owner ruling: on big models the setting rules; on small ones the
* window rules before the provider overflows).
*
* FACTORY DEFAULT 150_000 (owner ruling).
*
* FLOOR 32_000 (implementer's call, the chunk-budget discipline): below
* the kept tail (20k) + a summary the chat would re-compact every turn —
* a burn, not a preference. NO CEILING: a huge threshold just means "fit
* everything" until the window rule binds.
*/
export interface CompactionSettings {
thresholdTokens?: number;
}
/** THE record. Every section optional (delta); unknown top-level keys are
* preserved verbatim by the sanitizer (forward-compat with newer records). */
export interface UserSettings {
@@ -338,6 +356,7 @@ export interface UserSettings {
models?: ModelsSettings;
appearance?: AppearanceSettings;
graph?: GraphSettings;
compaction?: CompactionSettings;
[key: string]: unknown;
}
@@ -362,6 +381,7 @@ export interface FactorySettings {
};
};
graph: Required<GraphSettings>;
compaction: Required<CompactionSettings>;
}
export const FACTORY_SETTINGS: FactorySettings = {
@@ -376,6 +396,7 @@ export const FACTORY_SETTINGS: FactorySettings = {
mdRendering: { headingWeight: 900, boldColor: null, italicColor: null },
},
graph: { enabled: true, extractionChunkTokens: 8192 },
compaction: { thresholdTokens: 150_000 },
};
/** The chunk budget's validated floor (GraphSettings.extractionChunkTokens
@@ -552,6 +573,20 @@ function sanitizeGraph(raw: Record<string, unknown>): GraphSettings {
return out;
}
/** clamp-never-reject (the sanitizer's discipline): a finite positive
* number floors to COMPACTION_THRESHOLD_FLOOR, anything else drops (the
* factory default resolves at read time). */
const COMPACTION_THRESHOLD_FLOOR = 32_000;
function sanitizeCompaction(raw: Record<string, unknown>): CompactionSettings {
const out: CompactionSettings = {};
const rawTokens = raw.thresholdTokens;
if (typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens > 0) {
out.thresholdTokens = Math.max(COMPACTION_THRESHOLD_FLOOR, Math.floor(rawTokens));
}
return out;
}
/**
* Coerce arbitrary JSON (disk file, network body) into a valid delta record:
* known fields validated strictly (invalid ones drop/clamp per module doc),
@@ -579,6 +614,9 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
}
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
out.compaction = sanitizeCompaction(r.compaction as Record<string, unknown>);
}
}
// Unknown top-level keys ride along untouched (forward-compat). One
// known-DEAD key is stripped instead of riding: `chat` (the old