Skip to content

Commit c7295e4

Browse files
authored
chore(scripts): add debt ratchet to the session-accessor boundary guard (#101178)
* chore(scripts): add debt ratchet baseline to session-accessor boundary gate * fix(scripts): fail session-accessor debt gate until baseline ratchets down
1 parent 6bffda4 commit c7295e4

4 files changed

Lines changed: 370 additions & 41 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1684,6 +1684,7 @@
16841684
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
16851685
"lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs",
16861686
"lint:tmp:session-accessor-boundary": "node scripts/check-session-accessor-boundary.mjs",
1687+
"lint:tmp:session-accessor-boundary:gen": "node scripts/check-session-accessor-boundary.mjs --update-debt-baseline",
16871688
"lint:tmp:session-transcript-reader-boundary": "node scripts/check-session-transcript-reader-boundary.mjs",
16881689
"lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
16891690
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",

scripts/check-session-accessor-boundary.mjs

Lines changed: 234 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,43 @@ export function findSessionLifecycleCleanupBoundaryViolations(content, fileName
537537
);
538538
}
539539

540+
// Source roots shared by the enforced boundary checks in main() and the debt
541+
// ratchet below; keeping one list prevents the two scans from drifting apart.
542+
const readSourceRootPaths = [
543+
"packages/memory-host-sdk/src/host",
544+
"extensions/discord/src/monitor",
545+
"extensions/memory-core/src",
546+
"extensions/telegram/src",
547+
"extensions/voice-call/src",
548+
"src/acp",
549+
"src/agents",
550+
"src/auto-reply",
551+
"src/commands",
552+
"src/config/sessions",
553+
"src/cron",
554+
"src/gateway",
555+
"src/infra",
556+
"src/plugins",
557+
"src/tui",
558+
];
559+
const writeSourceRootPaths = [
560+
"src/acp",
561+
"src/agents",
562+
"src/auto-reply",
563+
"src/commands",
564+
"src/config/sessions",
565+
"src/gateway",
566+
"src/plugins",
567+
"src/tui",
568+
];
569+
const transcriptWriterSourceRootPaths = [
570+
"src/agents/command",
571+
"src/agents/embedded-agent-runner",
572+
"src/config/sessions",
573+
"src/gateway/server-methods",
574+
"src/sessions",
575+
];
576+
540577
function declarationName(node) {
541578
if (ts.isFunctionDeclaration(node) && node.name) {
542579
return node.name.text;
@@ -617,42 +654,166 @@ export function findMemoryHostSessionCorpusBoundaryViolations(content, fileName
617654
return violations;
618655
}
619656

657+
// Debt ratchet: the boundary checks above only scan files already on the
658+
// migrated lists, so unmigrated files could quietly gain new legacy call
659+
// sites. The checked-in baseline locks each unmigrated file's current legacy
660+
// call-site count per concern; any drift from the baseline fails the guard.
661+
export const sessionAccessorDebtBaselineRelativePath =
662+
"scripts/lib/session-accessor-debt-baseline.json";
663+
const debtBaselineRegenCommand = "pnpm lint:tmp:session-accessor-boundary:gen";
664+
665+
// Keys sorted alphabetically so the generated baseline JSON stays deterministic.
666+
const sessionAccessorDebtConcerns = [
667+
{
668+
key: "embeddedAgentSessionTarget",
669+
sourceRootPaths: ["extensions/voice-call/src"],
670+
migratedFiles: migratedEmbeddedAgentSessionTargetFiles,
671+
findViolations: findEmbeddedAgentSessionTargetViolations,
672+
},
673+
{
674+
key: "memoryHostSessionCorpus",
675+
sourceRootPaths: ["packages/memory-host-sdk/src/host"],
676+
migratedFiles: migratedMemoryHostSessionCorpusFiles,
677+
findViolations: findMemoryHostSessionCorpusBoundaryViolations,
678+
},
679+
{
680+
key: "sessionAccessorRead",
681+
sourceRootPaths: readSourceRootPaths,
682+
migratedFiles: new Set([
683+
...migratedSessionAccessorFiles,
684+
...migratedBundledPluginSessionAccessorFiles,
685+
]),
686+
findViolations: findSessionAccessorBoundaryViolations,
687+
},
688+
{
689+
key: "sessionAccessorWrite",
690+
sourceRootPaths: writeSourceRootPaths,
691+
migratedFiles: migratedSessionAccessorWriteFiles,
692+
findViolations: findSessionAccessorWriteBoundaryViolations,
693+
},
694+
{
695+
key: "sessionCompactManualTrim",
696+
sourceRootPaths: ["src/gateway/server-methods"],
697+
migratedFiles: migratedSessionCompactManualTrimFiles,
698+
findViolations: findSessionCompactManualTrimBoundaryViolations,
699+
},
700+
{
701+
key: "sessionLifecycleCleanup",
702+
sourceRootPaths: readSourceRootPaths,
703+
migratedFiles: migratedSessionLifecycleCleanupFiles,
704+
findViolations: findSessionLifecycleCleanupBoundaryViolations,
705+
},
706+
{
707+
key: "transcriptWriter",
708+
sourceRootPaths: transcriptWriterSourceRootPaths,
709+
migratedFiles: migratedTranscriptWriterFiles,
710+
findViolations: findTranscriptWriterBoundaryViolations,
711+
},
712+
];
713+
714+
function sortRecordByKey(record) {
715+
return Object.fromEntries(
716+
Object.entries(record).toSorted(([left], [right]) =>
717+
left < right ? -1 : left > right ? 1 : 0,
718+
),
719+
);
720+
}
721+
722+
/** Counts legacy call sites per unmigrated file for every debt concern. */
723+
export async function collectSessionAccessorDebtCounts(repoRoot) {
724+
const counts = {};
725+
for (const concern of sessionAccessorDebtConcerns) {
726+
const violations = await collectFileViolations({
727+
repoRoot,
728+
sourceRoots: resolveSourceRoots(repoRoot, concern.sourceRootPaths),
729+
// Inverse of the enforcement skip: migrated files are held at zero by the
730+
// boundary checks, so the ratchet tracks only the unmigrated rest.
731+
skipFile: (filePath) =>
732+
concern.migratedFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
733+
findViolations: concern.findViolations,
734+
});
735+
const fileCounts = {};
736+
for (const violation of violations) {
737+
const relativePath = normalizeRelativePath(violation.path);
738+
fileCounts[relativePath] = (fileCounts[relativePath] ?? 0) + 1;
739+
}
740+
counts[concern.key] = sortRecordByKey(fileCounts);
741+
}
742+
return sortRecordByKey(counts);
743+
}
744+
745+
/** Ratchet compare: counts above baseline are regressions, below are improvements. */
746+
export function compareSessionAccessorDebt(currentCounts, baselineCounts) {
747+
const regressions = [];
748+
const improvements = [];
749+
const concerns = [
750+
...new Set([...Object.keys(baselineCounts), ...Object.keys(currentCounts)]),
751+
].toSorted();
752+
for (const concern of concerns) {
753+
const current = currentCounts[concern] ?? {};
754+
const baseline = baselineCounts[concern] ?? {};
755+
const filePaths = [...new Set([...Object.keys(baseline), ...Object.keys(current)])].toSorted();
756+
for (const filePath of filePaths) {
757+
const currentCount = current[filePath] ?? 0;
758+
const baselineCount = baseline[filePath] ?? 0;
759+
if (currentCount === baselineCount) {
760+
continue;
761+
}
762+
const entry = { concern, path: filePath, currentCount, baselineCount };
763+
if (currentCount > baselineCount) {
764+
regressions.push(entry);
765+
} else {
766+
improvements.push(entry);
767+
}
768+
}
769+
}
770+
return { regressions, improvements };
771+
}
772+
773+
// Improvements fail the guard too: passing silently would leave the baseline
774+
// stale, letting a later change reintroduce legacy call sites up to the old
775+
// count without tripping the ratchet.
776+
export function formatSessionAccessorDebtImprovements(improvements) {
777+
return [
778+
`Legacy session accessor debt dropped below ${sessionAccessorDebtBaselineRelativePath}:`,
779+
...improvements.map(
780+
(improvement) =>
781+
`- ${improvement.path} [${improvement.concern}]: ${improvement.currentCount} legacy call site(s), stale baseline allows ${improvement.baselineCount}`,
782+
),
783+
`Run \`${debtBaselineRegenCommand}\` to ratchet the baseline down and commit it.`,
784+
];
785+
}
786+
787+
function resolveDebtBaselinePath(repoRoot) {
788+
return path.join(repoRoot, ...sessionAccessorDebtBaselineRelativePath.split("/"));
789+
}
790+
791+
async function readSessionAccessorDebtBaseline(repoRoot) {
792+
try {
793+
return JSON.parse(await fs.readFile(resolveDebtBaselinePath(repoRoot), "utf8"));
794+
} catch (error) {
795+
if (error && typeof error === "object" && error.code === "ENOENT") {
796+
return null;
797+
}
798+
throw error;
799+
}
800+
}
801+
802+
async function writeSessionAccessorDebtBaseline(repoRoot) {
803+
const counts = await collectSessionAccessorDebtCounts(repoRoot);
804+
await fs.writeFile(resolveDebtBaselinePath(repoRoot), `${JSON.stringify(counts, null, 2)}\n`);
805+
}
806+
620807
export async function main() {
621808
const repoRoot = resolveRepoRoot(import.meta.url);
622-
const readSourceRoots = resolveSourceRoots(repoRoot, [
623-
"packages/memory-host-sdk/src/host",
624-
"extensions/discord/src/monitor",
625-
"extensions/memory-core/src",
626-
"extensions/telegram/src",
627-
"extensions/voice-call/src",
628-
"src/acp",
629-
"src/agents",
630-
"src/auto-reply",
631-
"src/commands",
632-
"src/config/sessions",
633-
"src/cron",
634-
"src/gateway",
635-
"src/infra",
636-
"src/plugins",
637-
"src/tui",
638-
]);
639-
const writeSourceRoots = resolveSourceRoots(repoRoot, [
640-
"src/acp",
641-
"src/agents",
642-
"src/auto-reply",
643-
"src/commands",
644-
"src/config/sessions",
645-
"src/gateway",
646-
"src/plugins",
647-
"src/tui",
648-
]);
649-
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [
650-
"src/agents/command",
651-
"src/agents/embedded-agent-runner",
652-
"src/config/sessions",
653-
"src/gateway/server-methods",
654-
"src/sessions",
655-
]);
809+
if (process.argv.includes("--update-debt-baseline")) {
810+
await writeSessionAccessorDebtBaseline(repoRoot);
811+
console.log(`Wrote ${sessionAccessorDebtBaselineRelativePath}`);
812+
return;
813+
}
814+
const readSourceRoots = resolveSourceRoots(repoRoot, readSourceRootPaths);
815+
const writeSourceRoots = resolveSourceRoots(repoRoot, writeSourceRootPaths);
816+
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, transcriptWriterSourceRootPaths);
656817
const readViolations = await collectFileViolations({
657818
repoRoot,
658819
sourceRoots: readSourceRoots,
@@ -745,18 +906,50 @@ export async function main() {
745906
...sessionStoreRuntimeCompatViolations,
746907
];
747908

748-
if (violations.length === 0) {
909+
const baselineCounts = await readSessionAccessorDebtBaseline(repoRoot);
910+
if (!baselineCounts) {
911+
console.error(
912+
`Missing ${sessionAccessorDebtBaselineRelativePath}; run \`${debtBaselineRegenCommand}\` and commit it.`,
913+
);
914+
process.exit(1);
915+
}
916+
const debt = compareSessionAccessorDebt(
917+
await collectSessionAccessorDebtCounts(repoRoot),
918+
baselineCounts,
919+
);
920+
921+
if (violations.length === 0 && debt.regressions.length === 0 && debt.improvements.length === 0) {
749922
console.log("session accessor boundary guard passed.");
750923
return;
751924
}
752925

753-
console.error("Found legacy session store usage in session-accessor migrated files:");
754-
for (const violation of violations) {
755-
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
926+
if (violations.length > 0) {
927+
console.error("Found legacy session store usage in session-accessor migrated files:");
928+
for (const violation of violations) {
929+
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
930+
}
931+
console.error(
932+
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write and transcript-writer paths. Expand file-backed SDK compatibility only as an explicit pre-SQLite migration decision.",
933+
);
934+
}
935+
if (debt.regressions.length > 0) {
936+
console.error(
937+
`Found new legacy session call sites in unmigrated files (counts exceed ${sessionAccessorDebtBaselineRelativePath}):`,
938+
);
939+
for (const regression of debt.regressions) {
940+
console.error(
941+
`- ${regression.path} [${regression.concern}]: ${regression.currentCount} legacy call site(s), baseline allows ${regression.baselineCount}`,
942+
);
943+
}
944+
console.error(
945+
`Use src/config/sessions/session-accessor.ts helpers instead of adding legacy call sites. If the increase is an intentional seam-owner change, run \`${debtBaselineRegenCommand}\` and commit the updated baseline.`,
946+
);
947+
}
948+
if (debt.improvements.length > 0) {
949+
for (const line of formatSessionAccessorDebtImprovements(debt.improvements)) {
950+
console.error(line);
951+
}
756952
}
757-
console.error(
758-
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write and transcript-writer paths. Expand file-backed SDK compatibility only as an explicit pre-SQLite migration decision.",
759-
);
760953
process.exit(1);
761954
}
762955

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
{
2+
"embeddedAgentSessionTarget": {},
3+
"memoryHostSessionCorpus": {},
4+
"sessionAccessorRead": {
5+
"extensions/telegram/src/bot-deps.ts": 1,
6+
"extensions/telegram/src/bot-message-context.session-recreate.test-support.ts": 2,
7+
"src/acp/control-plane/manager.background-task.ts": 2,
8+
"src/acp/control-plane/manager.core.ts": 1,
9+
"src/agents/agent-command.ts": 2,
10+
"src/agents/bash-tools.exec-approval-followup.ts": 2,
11+
"src/agents/command/session.ts": 5,
12+
"src/agents/main-session-restart-recovery.ts": 2,
13+
"src/agents/subagent-announce-delivery.ts": 3,
14+
"src/agents/subagent-announce-output.ts": 3,
15+
"src/agents/subagent-announce.test-support.ts": 1,
16+
"src/agents/subagent-capabilities.ts": 2,
17+
"src/agents/subagent-list.ts": 2,
18+
"src/agents/subagent-orphan-recovery.ts": 2,
19+
"src/agents/subagent-session-reconciliation.ts": 2,
20+
"src/agents/subagent-spawn.ts": 2,
21+
"src/agents/tools/transcripts-tool.ts": 2,
22+
"src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts": 2,
23+
"src/auto-reply/reply/commands-export-common.ts": 2,
24+
"src/auto-reply/reply/commands-name.ts": 2,
25+
"src/auto-reply/reply/commands-session-store.ts": 2,
26+
"src/auto-reply/reply/dispatch-acp-transcript.runtime.ts": 4,
27+
"src/auto-reply/reply/dispatch-from-config.ts": 6,
28+
"src/auto-reply/reply/get-reply-fast-path.ts": 4,
29+
"src/auto-reply/reply/get-reply-run.ts": 2,
30+
"src/commands/doctor-heartbeat-session-target.ts": 2,
31+
"src/commands/doctor-state-integrity.ts": 2,
32+
"src/commands/doctor/shared/codex-route-warnings.ts": 3,
33+
"src/config/sessions/cleanup-service.ts": 4,
34+
"src/config/sessions/session-accessor.ts": 25,
35+
"src/config/sessions/session-registry-maintenance.ts": 2,
36+
"src/config/sessions/store-load.ts": 4,
37+
"src/config/sessions/store.ts": 15,
38+
"src/config/sessions/test-helpers.ts": 2,
39+
"src/config/sessions/transcript.ts": 6,
40+
"src/cron/isolated-agent/session.ts": 3,
41+
"src/infra/approval-request-account-binding.ts": 2,
42+
"src/infra/heartbeat-runner.ts": 4,
43+
"src/plugins/runtime/runtime-agent.ts": 1
44+
},
45+
"sessionAccessorWrite": {
46+
"src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts": 2,
47+
"src/agents/session-suspension.ts": 2,
48+
"src/agents/subagent-orphan-recovery.ts": 3,
49+
"src/agents/subagent-spawn.ts": 2,
50+
"src/auto-reply/reply/commands-name.ts": 2,
51+
"src/auto-reply/reply/dispatch-from-config.ts": 2,
52+
"src/auto-reply/reply/session-fork.ts": 2,
53+
"src/commands/doctor-heartbeat-main-session-repair.ts": 2,
54+
"src/commands/doctor-session-state-providers.ts": 2,
55+
"src/commands/doctor-state-integrity.ts": 2,
56+
"src/commands/doctor/shared/codex-route-warnings.ts": 2,
57+
"src/config/sessions/plugin-host-cleanup.ts": 2,
58+
"src/config/sessions/session-accessor.ts": 11,
59+
"src/config/sessions/session-file.ts": 2,
60+
"src/config/sessions/session-registry-maintenance.ts": 2,
61+
"src/gateway/server-methods/agent.ts": 2,
62+
"src/gateway/server-methods/chat.ts": 2,
63+
"src/gateway/server-methods/sessions.ts": 3,
64+
"src/gateway/session-lifecycle-state.ts": 2,
65+
"src/gateway/test-helpers.mocks.ts": 1,
66+
"src/plugins/runtime/runtime-agent.ts": 2
67+
},
68+
"sessionCompactManualTrim": {},
69+
"sessionLifecycleCleanup": {
70+
"src/config/sessions/store-maintenance-operations.ts": 2,
71+
"src/config/sessions/store.ts": 5
72+
},
73+
"transcriptWriter": {
74+
"src/agents/embedded-agent-runner/compaction-hooks.ts": 2,
75+
"src/agents/embedded-agent-runner/thinking-replay-repair.ts": 2,
76+
"src/agents/embedded-agent-runner/tool-result-truncation.ts": 3,
77+
"src/agents/embedded-agent-runner/transcript-rewrite.ts": 2,
78+
"src/config/sessions/session-accessor.ts": 7,
79+
"src/config/sessions/store.ts": 2
80+
}
81+
}

0 commit comments

Comments
 (0)