mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 07:01:49 +00:00
tests: cron coverage and NO_REPLY delivery fixes (#53366)
* tools: extend seam audit inventory * tools: audit cron seam coverage gaps * test: add cron seam coverage tests * fix: avoid marking NO_REPLY cron deliveries as delivered * fix: clean up delete-after-run NO_REPLY cron sessions
This commit is contained in:
@@ -13,7 +13,7 @@ const testRoot = path.join(repoRoot, "test");
|
||||
const workspacePackagePaths = ["ui/package.json"];
|
||||
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
|
||||
const compareStrings = (left, right) => left.localeCompare(right);
|
||||
const HELP_TEXT = `Usage: node scripts/audit-seams.mjs [--help]
|
||||
export const HELP_TEXT = `Usage: node scripts/audit-seams.mjs [--help]
|
||||
|
||||
Audit repo seam inventory and emit JSON to stdout.
|
||||
|
||||
@@ -22,7 +22,10 @@ Sections:
|
||||
overlapFiles Production files that touch multiple seam families
|
||||
optionalClusterStaticLeaks Optional extension/plugin clusters referenced from the static graph
|
||||
missingPackages Workspace packages whose deps are not mirrored at the root
|
||||
seamTestInventory High-signal seam candidates with nearby-test gap signals
|
||||
seamTestInventory High-signal seam candidates with nearby-test gap signals,
|
||||
including cron orchestration seams for agent handoff,
|
||||
outbound/media delivery, heartbeat/followup handoff,
|
||||
and scheduler state crossings
|
||||
|
||||
Notes:
|
||||
- Output is JSON only.
|
||||
@@ -531,7 +534,135 @@ function stemFromRelativePath(relativePath) {
|
||||
return relativePath.replace(/\.(m|c)?[jt]sx?$/, "");
|
||||
}
|
||||
|
||||
function describeSeamKinds(relativePath, source) {
|
||||
function splitNameTokens(name) {
|
||||
return name
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.map((token) => token.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function escapeForRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function hasImportSource(source, specifier) {
|
||||
const escaped = escapeForRegExp(specifier);
|
||||
return new RegExp(`from\\s+["']${escaped}["']|import\\s*\\(\\s*["']${escaped}["']\\s*\\)`).test(
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyImportSource(source, specifiers) {
|
||||
return specifiers.some((specifier) => hasImportSource(source, specifier));
|
||||
}
|
||||
|
||||
function isCronProductionPath(relativePath) {
|
||||
return relativePath.startsWith("src/cron/") && isProductionLikeFile(relativePath);
|
||||
}
|
||||
|
||||
function describeCronSeamKinds(relativePath, source) {
|
||||
if (!isCronProductionPath(relativePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seamKinds = [];
|
||||
const importsAgentRunner = hasAnyImportSource(source, [
|
||||
"../../agents/cli-runner.js",
|
||||
"../../agents/pi-embedded.js",
|
||||
"../../agents/model-fallback.js",
|
||||
"../../agents/subagent-registry.js",
|
||||
"../../infra/agent-events.js",
|
||||
]);
|
||||
const importsOutboundDelivery = hasAnyImportSource(source, [
|
||||
"../infra/outbound/deliver.js",
|
||||
"../../infra/outbound/deliver.js",
|
||||
"../infra/outbound/session-context.js",
|
||||
"../../infra/outbound/session-context.js",
|
||||
"../infra/outbound/identity.js",
|
||||
"../../infra/outbound/identity.js",
|
||||
"../cli/outbound-send-deps.js",
|
||||
"../../cli/outbound-send-deps.js",
|
||||
]);
|
||||
const importsHeartbeat = hasAnyImportSource(source, [
|
||||
"../auto-reply/heartbeat.js",
|
||||
"../../auto-reply/heartbeat.js",
|
||||
"../infra/heartbeat-wake.js",
|
||||
"../../infra/heartbeat-wake.js",
|
||||
]);
|
||||
const importsFollowup = hasAnyImportSource(source, [
|
||||
"./subagent-followup.js",
|
||||
"../../agents/subagent-registry.js",
|
||||
"../../agents/tools/agent-step.js",
|
||||
"../../gateway/call.js",
|
||||
]);
|
||||
const importsSchedulerModules =
|
||||
relativePath.startsWith("src/cron/service/") &&
|
||||
hasAnyImportSource(source, [
|
||||
"./jobs.js",
|
||||
"./store.js",
|
||||
"./timer.js",
|
||||
"./state.js",
|
||||
"../schedule.js",
|
||||
"../store.js",
|
||||
"../run-log.js",
|
||||
]);
|
||||
|
||||
if (
|
||||
importsAgentRunner &&
|
||||
/\brunCliAgent\b|\brunEmbeddedPiAgent\b|\brunWithModelFallback\b|\bregisterAgentRunContext\b/.test(
|
||||
source,
|
||||
)
|
||||
) {
|
||||
seamKinds.push("cron-agent-handoff");
|
||||
}
|
||||
|
||||
if (
|
||||
importsOutboundDelivery &&
|
||||
/\bdeliverOutboundPayloads\b|\bbuildOutboundSessionContext\b|\bresolveAgentOutboundIdentity\b/.test(
|
||||
source,
|
||||
)
|
||||
) {
|
||||
seamKinds.push("cron-outbound-delivery");
|
||||
}
|
||||
|
||||
if (
|
||||
importsHeartbeat &&
|
||||
/\bstripHeartbeatToken\b|\bHeartbeat\b|\bheartbeat\b|\bnext-heartbeat\b/.test(source)
|
||||
) {
|
||||
seamKinds.push("cron-heartbeat-handoff");
|
||||
}
|
||||
|
||||
if (
|
||||
importsSchedulerModules &&
|
||||
/\bensureLoaded\b|\bpersist\b|\barmTimer\b|\brunMissedJobs\b|\bcomputeJobNextRunAtMs\b|\brecomputeNextRuns\b|\bnextWakeAtMs\b/.test(
|
||||
source,
|
||||
)
|
||||
) {
|
||||
seamKinds.push("cron-scheduler-state");
|
||||
}
|
||||
|
||||
if (
|
||||
importsOutboundDelivery &&
|
||||
/\bmediaUrl\b|\bmediaUrls\b|\bfilename\b|\baudioAsVoice\b|\bdeliveryPayloads\b|\bdeliveryPayloadHasStructuredContent\b/.test(
|
||||
source,
|
||||
)
|
||||
) {
|
||||
seamKinds.push("cron-media-delivery");
|
||||
}
|
||||
|
||||
if (
|
||||
importsFollowup &&
|
||||
/\bwaitForDescendantSubagentSummary\b|\breadDescendantSubagentFallbackReply\b|\bexpectsSubagentFollowup\b|\bcallGateway\b|\blistDescendantRunsForRequester\b/.test(
|
||||
source,
|
||||
)
|
||||
) {
|
||||
seamKinds.push("cron-followup-handoff");
|
||||
}
|
||||
|
||||
return seamKinds;
|
||||
}
|
||||
|
||||
export function describeSeamKinds(relativePath, source) {
|
||||
const seamKinds = [];
|
||||
const isReplyDeliveryPath =
|
||||
/reply-delivery|reply-dispatcher|deliver-reply|reply\/.*delivery|monitor\/(?:replies|deliver|native-command)|outbound\/deliver|outbound\/message/.test(
|
||||
@@ -565,11 +696,12 @@ function describeSeamKinds(relativePath, source) {
|
||||
}
|
||||
if (
|
||||
isReplyDeliveryPath &&
|
||||
/blockStreamingEnabled|directlySentBlockKeys/.test(source) &&
|
||||
/blockStreamingEnabled|directlySentBlockKeys|resolveSendableOutboundReplyParts/.test(source) &&
|
||||
/\bmediaUrl\b|\bmediaUrls\b/.test(source)
|
||||
) {
|
||||
seamKinds.push("streaming-media-handoff");
|
||||
}
|
||||
seamKinds.push(...describeCronSeamKinds(relativePath, source));
|
||||
return [...new Set(seamKinds)].toSorted(compareStrings);
|
||||
}
|
||||
|
||||
@@ -593,17 +725,6 @@ async function buildTestIndex(testFiles) {
|
||||
);
|
||||
}
|
||||
|
||||
function splitNameTokens(name) {
|
||||
return name
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.map((token) => token.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function escapeForRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function hasExecutableImportReference(source, importPath) {
|
||||
const escapedImportPath = escapeForRegExp(importPath);
|
||||
const suffix = String.raw`(?:\.[^"'\\\`]+)?`;
|
||||
@@ -697,7 +818,7 @@ function findRelatedTests(relativePath, testIndex) {
|
||||
});
|
||||
}
|
||||
|
||||
function determineSeamTestStatus(seamKinds, relatedTestMatches) {
|
||||
export function determineSeamTestStatus(seamKinds, relatedTestMatches) {
|
||||
if (relatedTestMatches.length === 0) {
|
||||
return {
|
||||
status: "gap",
|
||||
@@ -709,7 +830,13 @@ function determineSeamTestStatus(seamKinds, relatedTestMatches) {
|
||||
if (
|
||||
seamKinds.includes("reply-delivery-media") ||
|
||||
seamKinds.includes("streaming-media-handoff") ||
|
||||
seamKinds.includes("tool-result-media")
|
||||
seamKinds.includes("tool-result-media") ||
|
||||
seamKinds.includes("cron-agent-handoff") ||
|
||||
seamKinds.includes("cron-outbound-delivery") ||
|
||||
seamKinds.includes("cron-heartbeat-handoff") ||
|
||||
seamKinds.includes("cron-scheduler-state") ||
|
||||
seamKinds.includes("cron-media-delivery") ||
|
||||
seamKinds.includes("cron-followup-handoff")
|
||||
) {
|
||||
return {
|
||||
status: "partial",
|
||||
@@ -765,22 +892,29 @@ async function buildSeamTestInventory() {
|
||||
});
|
||||
}
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
if (args.has("--help") || args.has("-h")) {
|
||||
process.stdout.write(`${HELP_TEXT}\n`);
|
||||
process.exit(0);
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const args = new Set(argv);
|
||||
if (args.has("--help") || args.has("-h")) {
|
||||
process.stdout.write(`${HELP_TEXT}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await collectWorkspacePackagePaths();
|
||||
const inventory = await collectCorePluginSdkImports();
|
||||
const optionalClusterStaticLeaks = await collectOptionalClusterStaticLeaks();
|
||||
const staticLeakClusters = new Set(optionalClusterStaticLeaks.map((entry) => entry.cluster));
|
||||
const result = {
|
||||
duplicatedSeamFamilies: buildDuplicatedSeamFamilies(inventory),
|
||||
overlapFiles: buildOverlapFiles(inventory),
|
||||
optionalClusterStaticLeaks: buildOptionalClusterStaticLeaks(optionalClusterStaticLeaks),
|
||||
missingPackages: await buildMissingPackages({ staticLeakClusters }),
|
||||
seamTestInventory: await buildSeamTestInventory(),
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
|
||||
await collectWorkspacePackagePaths();
|
||||
const inventory = await collectCorePluginSdkImports();
|
||||
const optionalClusterStaticLeaks = await collectOptionalClusterStaticLeaks();
|
||||
const staticLeakClusters = new Set(optionalClusterStaticLeaks.map((entry) => entry.cluster));
|
||||
const result = {
|
||||
duplicatedSeamFamilies: buildDuplicatedSeamFamilies(inventory),
|
||||
overlapFiles: buildOverlapFiles(inventory),
|
||||
optionalClusterStaticLeaks: buildOptionalClusterStaticLeaks(optionalClusterStaticLeaks),
|
||||
missingPackages: await buildMissingPackages({ staticLeakClusters }),
|
||||
seamTestInventory: await buildSeamTestInventory(),
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
const entryFilePath = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
||||
if (entryFilePath === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as modelSelection from "../agents/model-selection.js";
|
||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import {
|
||||
createCliDeps,
|
||||
expectDirectTelegramDelivery,
|
||||
@@ -407,6 +408,60 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mark NO_REPLY output as delivered when no direct send occurs", async () => {
|
||||
await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
|
||||
mockAgentPayloads([{ text: "NO_REPLY" }]);
|
||||
const res = await runTelegramAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
deps,
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(false);
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the isolated cron session after NO_REPLY when deleteAfterRun is enabled", async () => {
|
||||
await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
|
||||
mockAgentPayloads([{ text: "NO_REPLY" }]);
|
||||
vi.mocked(callGateway).mockClear();
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: {
|
||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
||||
deleteAfterRun: true,
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(false);
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "sessions.delete",
|
||||
params: expect.objectContaining({
|
||||
key: "agent:main:cron:job-1",
|
||||
deleteTranscript: true,
|
||||
emitLifecycleHooks: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when structured direct delivery fails and best-effort is disabled", async () => {
|
||||
await expectStructuredTelegramFailure({
|
||||
payload: { text: "hello from cron", mediaUrl: "https://example.com/img.png" },
|
||||
|
||||
@@ -538,11 +538,12 @@ export async function dispatchCronDelivery(
|
||||
});
|
||||
}
|
||||
if (synthesizedText.toUpperCase() === SILENT_REPLY_TOKEN.toUpperCase()) {
|
||||
await cleanupDirectCronSessionIfNeeded();
|
||||
return params.withRunSession({
|
||||
status: "ok",
|
||||
summary,
|
||||
outputText,
|
||||
delivered: true,
|
||||
delivered: false,
|
||||
...params.telemetry,
|
||||
});
|
||||
}
|
||||
|
||||
80
src/cron/service/ops.test.ts
Normal file
80
src/cron/service/ops.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setupCronServiceSuite, writeCronStoreSnapshot } from "../service.test-harness.js";
|
||||
import type { CronJob } from "../types.js";
|
||||
import { start, stop } from "./ops.js";
|
||||
import { createCronServiceState } from "./state.js";
|
||||
|
||||
const { logger, makeStorePath } = setupCronServiceSuite({
|
||||
prefix: "cron-service-ops-seam",
|
||||
});
|
||||
|
||||
function createInterruptedMainJob(now: number): CronJob {
|
||||
return {
|
||||
id: "startup-interrupted",
|
||||
name: "startup interrupted",
|
||||
enabled: true,
|
||||
createdAtMs: now - 86_400_000,
|
||||
updatedAtMs: now - 30 * 60_000,
|
||||
schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "should not replay on startup" },
|
||||
state: {
|
||||
nextRunAtMs: now - 60_000,
|
||||
runningAtMs: now - 30 * 60_000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("cron service ops seam coverage", () => {
|
||||
it("start clears stale running markers, skips startup replay, persists, and arms the timer", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-03-23T12:00:00.000Z");
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await writeCronStoreSnapshot({
|
||||
storePath,
|
||||
jobs: [createInterruptedMainJob(now)],
|
||||
});
|
||||
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
|
||||
await start(state);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: "startup-interrupted" }),
|
||||
"cron: clearing stale running marker on startup",
|
||||
);
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
expect(state.timer).not.toBeNull();
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf8")) as {
|
||||
jobs: CronJob[];
|
||||
};
|
||||
const job = persisted.jobs[0];
|
||||
expect(job).toBeDefined();
|
||||
expect(job?.state.runningAtMs).toBeUndefined();
|
||||
expect(job?.state.lastStatus).toBeUndefined();
|
||||
expect((job?.state.nextRunAtMs ?? 0) > now).toBe(true);
|
||||
|
||||
const delays = timeoutSpy.mock.calls
|
||||
.map(([, delay]) => delay)
|
||||
.filter((delay): delay is number => typeof delay === "number");
|
||||
expect(delays.some((delay) => delay > 0)).toBe(true);
|
||||
|
||||
timeoutSpy.mockRestore();
|
||||
stop(state);
|
||||
});
|
||||
});
|
||||
70
src/cron/service/state.test.ts
Normal file
70
src/cron/service/state.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCronServiceState } from "./state.js";
|
||||
|
||||
describe("cron service state seam coverage", () => {
|
||||
it("threads heartbeat and session-store dependencies into internal state", () => {
|
||||
const nowMs = vi.fn(() => 123_456);
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const runHeartbeatOnce = vi.fn();
|
||||
const resolveSessionStorePath = vi.fn((agentId?: string) => `/tmp/${agentId ?? "main"}.json`);
|
||||
|
||||
const state = createCronServiceState({
|
||||
nowMs,
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
storePath: "/tmp/cron/jobs.json",
|
||||
cronEnabled: true,
|
||||
defaultAgentId: "ops",
|
||||
sessionStorePath: "/tmp/sessions.json",
|
||||
resolveSessionStorePath,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runHeartbeatOnce,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
|
||||
expect(state.store).toBeNull();
|
||||
expect(state.timer).toBeNull();
|
||||
expect(state.running).toBe(false);
|
||||
expect(state.warnedDisabled).toBe(false);
|
||||
expect(state.storeLoadedAtMs).toBeNull();
|
||||
expect(state.storeFileMtimeMs).toBeNull();
|
||||
|
||||
expect(state.deps.storePath).toBe("/tmp/cron/jobs.json");
|
||||
expect(state.deps.cronEnabled).toBe(true);
|
||||
expect(state.deps.defaultAgentId).toBe("ops");
|
||||
expect(state.deps.sessionStorePath).toBe("/tmp/sessions.json");
|
||||
expect(state.deps.resolveSessionStorePath).toBe(resolveSessionStorePath);
|
||||
expect(state.deps.enqueueSystemEvent).toBe(enqueueSystemEvent);
|
||||
expect(state.deps.requestHeartbeatNow).toBe(requestHeartbeatNow);
|
||||
expect(state.deps.runHeartbeatOnce).toBe(runHeartbeatOnce);
|
||||
expect(state.deps.nowMs()).toBe(123_456);
|
||||
});
|
||||
|
||||
it("defaults nowMs to Date.now when not provided", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(789_000);
|
||||
|
||||
const state = createCronServiceState({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
storePath: "/tmp/cron/jobs.json",
|
||||
cronEnabled: false,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
|
||||
expect(state.deps.nowMs()).toBe(789_000);
|
||||
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
101
src/cron/service/store.test.ts
Normal file
101
src/cron/service/store.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setupCronServiceSuite } from "../service.test-harness.js";
|
||||
import { createCronServiceState } from "./state.js";
|
||||
import { ensureLoaded, persist } from "./store.js";
|
||||
|
||||
const { logger, makeStorePath } = setupCronServiceSuite({
|
||||
prefix: "cron-service-store-seam",
|
||||
});
|
||||
|
||||
describe("cron service store seam coverage", () => {
|
||||
it("loads, normalizes legacy stored jobs, recomputes next runs, and persists the migrated shape", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-03-23T12:00:00.000Z");
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: "legacy-current-job",
|
||||
name: "legacy current job",
|
||||
enabled: true,
|
||||
createdAtMs: now - 60_000,
|
||||
updatedAtMs: now - 60_000,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "current",
|
||||
wakeMode: "next-heartbeat",
|
||||
message: "legacy message-only payload",
|
||||
provider: "telegram",
|
||||
to: "123",
|
||||
deliver: true,
|
||||
state: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
|
||||
await ensureLoaded(state);
|
||||
|
||||
const job = state.store?.jobs[0];
|
||||
expect(job).toBeDefined();
|
||||
expect(job?.sessionTarget).toBe("isolated");
|
||||
expect(job?.payload.kind).toBe("agentTurn");
|
||||
if (job?.payload.kind === "agentTurn") {
|
||||
expect(job.payload.message).toBe("legacy message-only payload");
|
||||
expect(job.payload.channel).toBeUndefined();
|
||||
expect(job.payload.to).toBeUndefined();
|
||||
expect(job.payload.deliver).toBeUndefined();
|
||||
}
|
||||
expect(job?.delivery).toMatchObject({
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
});
|
||||
expect(job?.state.nextRunAtMs).toBe(now);
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf8")) as {
|
||||
jobs: Array<Record<string, unknown>>;
|
||||
};
|
||||
const persistedJob = persisted.jobs[0];
|
||||
expect(persistedJob?.message).toBeUndefined();
|
||||
expect(persistedJob?.provider).toBeUndefined();
|
||||
expect(persistedJob?.to).toBeUndefined();
|
||||
expect(persistedJob?.deliver).toBeUndefined();
|
||||
expect(persistedJob?.payload).toMatchObject({
|
||||
kind: "agentTurn",
|
||||
message: "legacy message-only payload",
|
||||
});
|
||||
expect(persistedJob?.delivery).toMatchObject({
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
});
|
||||
|
||||
const firstMtime = state.storeFileMtimeMs;
|
||||
expect(typeof firstMtime).toBe("number");
|
||||
|
||||
await persist(state);
|
||||
expect(typeof state.storeFileMtimeMs).toBe("number");
|
||||
expect((state.storeFileMtimeMs ?? 0) >= (firstMtime ?? 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
80
src/cron/service/timer.test.ts
Normal file
80
src/cron/service/timer.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setupCronServiceSuite, writeCronStoreSnapshot } from "../../cron/service.test-harness.js";
|
||||
import { createCronServiceState } from "../../cron/service/state.js";
|
||||
import { onTimer } from "../../cron/service/timer.js";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
|
||||
const { logger, makeStorePath } = setupCronServiceSuite({
|
||||
prefix: "cron-service-timer-seam",
|
||||
});
|
||||
|
||||
function createDueMainJob(params: { now: number; wakeMode: CronJob["wakeMode"] }): CronJob {
|
||||
return {
|
||||
id: "main-heartbeat-job",
|
||||
name: "main heartbeat job",
|
||||
enabled: true,
|
||||
createdAtMs: params.now - 60_000,
|
||||
updatedAtMs: params.now - 60_000,
|
||||
schedule: { kind: "every", everyMs: 60_000, anchorMs: params.now - 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: params.wakeMode,
|
||||
payload: { kind: "systemEvent", text: "heartbeat seam tick" },
|
||||
sessionKey: "agent:main:main",
|
||||
state: { nextRunAtMs: params.now - 1 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("cron service timer seam coverage", () => {
|
||||
it("persists the next schedule and hands off next-heartbeat main jobs", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const now = Date.parse("2026-03-23T12:00:00.000Z");
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await writeCronStoreSnapshot({
|
||||
storePath,
|
||||
jobs: [createDueMainJob({ now, wakeMode: "next-heartbeat" })],
|
||||
});
|
||||
|
||||
const state = createCronServiceState({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
log: logger,
|
||||
nowMs: () => now,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
|
||||
await onTimer(state);
|
||||
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("heartbeat seam tick", {
|
||||
agentId: undefined,
|
||||
sessionKey: "agent:main:main",
|
||||
contextKey: "cron:main-heartbeat-job",
|
||||
});
|
||||
expect(requestHeartbeatNow).toHaveBeenCalledWith({
|
||||
reason: "cron:main-heartbeat-job",
|
||||
agentId: undefined,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf8")) as {
|
||||
jobs: CronJob[];
|
||||
};
|
||||
const job = persisted.jobs[0];
|
||||
expect(job).toBeDefined();
|
||||
expect(job?.state.lastStatus).toBe("ok");
|
||||
expect(job?.state.runningAtMs).toBeUndefined();
|
||||
expect(job?.state.nextRunAtMs).toBe(now + 60_000);
|
||||
|
||||
const delays = timeoutSpy.mock.calls
|
||||
.map(([, delay]) => delay)
|
||||
.filter((delay): delay is number => typeof delay === "number");
|
||||
expect(delays).toContain(60_000);
|
||||
|
||||
timeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
106
test/scripts/audit-seams.test.ts
Normal file
106
test/scripts/audit-seams.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HELP_TEXT,
|
||||
describeSeamKinds,
|
||||
determineSeamTestStatus,
|
||||
} from "../../scripts/audit-seams.mjs";
|
||||
|
||||
describe("audit-seams cron seam classification", () => {
|
||||
it("detects cron agent handoff and outbound delivery boundaries", () => {
|
||||
const source = `
|
||||
import { runCliAgent } from "../../agents/cli-runner.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
||||
|
||||
export async function runCronIsolatedAgentTurn() {
|
||||
registerAgentRunContext({});
|
||||
await runWithModelFallback(() => runCliAgent({}));
|
||||
await deliverOutboundPayloads({ payloads: [{ text: "done" }] });
|
||||
return buildOutboundSessionContext({});
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/cron/isolated-agent/run.ts", source)).toEqual([
|
||||
"cron-agent-handoff",
|
||||
"cron-outbound-delivery",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects scheduler-state seams in cron service orchestration", () => {
|
||||
const source = `
|
||||
import { recomputeNextRuns, computeJobNextRunAtMs } from "./jobs.js";
|
||||
import { ensureLoaded, persist } from "./store.js";
|
||||
import { armTimer, runMissedJobs } from "./timer.js";
|
||||
|
||||
export async function start(state) {
|
||||
await ensureLoaded(state);
|
||||
recomputeNextRuns(state);
|
||||
await persist(state);
|
||||
armTimer(state);
|
||||
await runMissedJobs(state);
|
||||
return computeJobNextRunAtMs(state.store.jobs[0], Date.now());
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/cron/service/ops.ts", source)).toContain("cron-scheduler-state");
|
||||
});
|
||||
|
||||
it("detects heartbeat, media, and followup handoff seams", () => {
|
||||
const source = `
|
||||
import { stripHeartbeatToken } from "../../auto-reply/heartbeat.js";
|
||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { waitForDescendantSubagentSummary } from "./subagent-followup.js";
|
||||
|
||||
export async function dispatchCronDelivery(payloads) {
|
||||
const heartbeat = stripHeartbeatToken(payloads[0]?.text ?? "", { mode: "heartbeat" });
|
||||
await waitForDescendantSubagentSummary({ sessionKey: "agent:main:cron:job-1", timeoutMs: 1 });
|
||||
await callGateway({ method: "agent.wait", params: { runId: "run-1" } });
|
||||
return { heartbeat, mediaUrl: payloads[0]?.mediaUrl, sent: deliverOutboundPayloads };
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/cron/isolated-agent/delivery-dispatch.ts", source)).toEqual([
|
||||
"cron-followup-handoff",
|
||||
"cron-heartbeat-handoff",
|
||||
"cron-media-delivery",
|
||||
"cron-outbound-delivery",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores pure cron helpers without subsystem crossings", () => {
|
||||
const source = `
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
|
||||
export function normalizeOptionalText(raw) {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
return truncateUtf16Safe(raw.trim(), 40);
|
||||
}
|
||||
`;
|
||||
|
||||
expect(describeSeamKinds("src/cron/service/normalize.ts", source)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit-seams cron status/help", () => {
|
||||
it("keeps cron seam statuses conservative when nearby tests exist", () => {
|
||||
expect(
|
||||
determineSeamTestStatus(
|
||||
["cron-agent-handoff"],
|
||||
[{ file: "src/cron/service.issue-regressions.test.ts", matchQuality: "path-nearby" }],
|
||||
),
|
||||
).toEqual({
|
||||
status: "partial",
|
||||
reason:
|
||||
"Nearby tests exist (best match: path-nearby), but this inventory does not prove cross-layer seam coverage end to end.",
|
||||
});
|
||||
});
|
||||
|
||||
it("documents cron seam coverage in help text", () => {
|
||||
expect(HELP_TEXT).toContain("cron orchestration seams");
|
||||
expect(HELP_TEXT).toContain("agent handoff");
|
||||
expect(HELP_TEXT).toContain("heartbeat/followup handoff");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user