diff --git a/scripts/audit-seams.mjs b/scripts/audit-seams.mjs index 68bf390a550..70d4225fcf9 100644 --- a/scripts/audit-seams.mjs +++ b/scripts/audit-seams.mjs @@ -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(); +} diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 9a5adcc2627..77e8ba0e206 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -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" }, diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index eda32740e4a..924c7b86aed 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -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, }); } diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts new file mode 100644 index 00000000000..8b2627d01d9 --- /dev/null +++ b/src/cron/service/ops.test.ts @@ -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); + }); +}); diff --git a/src/cron/service/state.test.ts b/src/cron/service/state.test.ts new file mode 100644 index 00000000000..f36e2a2b0b3 --- /dev/null +++ b/src/cron/service/state.test.ts @@ -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(); + }); +}); diff --git a/src/cron/service/store.test.ts b/src/cron/service/store.test.ts new file mode 100644 index 00000000000..0cd7636a714 --- /dev/null +++ b/src/cron/service/store.test.ts @@ -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>; + }; + 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); + }); +}); diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts new file mode 100644 index 00000000000..c5888aff5db --- /dev/null +++ b/src/cron/service/timer.test.ts @@ -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(); + }); +}); diff --git a/test/scripts/audit-seams.test.ts b/test/scripts/audit-seams.test.ts new file mode 100644 index 00000000000..f9c79b22712 --- /dev/null +++ b/test/scripts/audit-seams.test.ts @@ -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"); + }); +});