diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 3067694ac..22328c9bd 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -159,6 +159,7 @@ const DOMAIN_LAYER = new Map([ ['background', 5], ['mcp', 5], ['cron', 5], + ['cronStore', 5], // `btw` forks a single side-question sub-agent via `agentLifecycle`, // parallel to how the `Agent` tool spawns child agents. Agent-scope, L5. ['btw', 5], @@ -239,7 +240,7 @@ function domainFromRel(rel, { exemptRootFile }) { * - `swarm>agentLifecycle`: swarm spawns/manages sub-agents. * - `background>agentLifecycle`: background agent-tasks spawn sub-agents. * - `cron>agentLifecycle` : cron coordinator steers the main agent. - * - `cron>sessionActivity`: cron scheduler gates on session idle. + * - `cron>sessionContext`: cron scheduler reads session identity for store filtering. * * Post-rebase-v2 restructuring introduced cross-domain type sharing between * L3 (registries/capabilities) and L4 (agent behaviour). The tool contract @@ -264,7 +265,7 @@ const ALLOWED_EXCEPTIONS = new Set([ 'swarm>agentLifecycle', 'background>agentLifecycle', 'cron>agentLifecycle', - 'cron>sessionActivity', + 'cron>sessionContext', 'wireRecord>hooks', // L3/L4 type-sharing: tool contract + execution hook contexts now live in // `tool`; the remaining upward import is a `loop` error/event helper. diff --git a/packages/agent-core-v2/src/agent/cron/cron.ts b/packages/agent-core-v2/src/agent/cron/cron.ts deleted file mode 100644 index 3e16392a9..000000000 --- a/packages/agent-core-v2/src/agent/cron/cron.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * `cron` domain (L5) — `IAgentCronService` contract. - * - * Owns the agent's set of scheduled cron tasks and the queries the cron tools - * and the edge layer need against them. The data record (`CronTask`) lives here - * beside the contract because every method takes or returns it. Bound at Agent - * scope. - */ - -import type { ContentPart } from '#/app/llmProtocol'; - -import { createDecorator } from '#/_base/di'; -import type { Turn } from '#/agent/turn'; - -/** - * Persistent representation of a cron task. - * - * - `id` — 8-hex; jitter is keyed off this hash, so a stable id == stable - * jitter across schedule rewrites. - * - `cron` — 5-field expression, evaluated in local time. - * - `createdAt` — wall-clock epoch ms at original scheduling. NOT updated - * when the scheduler fires; recurring uses it as the baseline floor when - * no `lastFiredAt` has been recorded. Also the input to the 7-day stale - * judgment. - * - `recurring` — undefined / true means "fire repeatedly until deleted or - * auto-expired"; false means "fire once then auto-delete". - * - `lastFiredAt` — wall-clock epoch ms of the last ideal occurrence whose - * jittered delivery has actually completed. Persisted so a `kimi resume` - * does not replay already-delivered recurring fires. A value greater than - * the current wall clock is treated as corrupt and ignored. - */ -export interface CronTask { - readonly id: string; - readonly cron: string; - readonly prompt: string; - readonly createdAt: number; - readonly recurring?: boolean; - readonly lastFiredAt?: number; -} - -/** Everything the caller supplies; `id` and `createdAt` are generated by the service. */ -export type CronTaskInit = Omit; - -export interface CronLoadOptions { - readonly replace?: boolean; -} - -export interface IAgentCronService { - readonly _serviceBrand: undefined; - readonly isEnabled: boolean; - - // —— task CRUD (used by the cron tools and the edge layer) —— - addTask(init: CronTaskInit): CronTask; - removeTasks(ids: readonly string[]): readonly string[]; - getTask(id: string): CronTask | undefined; - list(): readonly CronTask[]; - - // —— scheduling queries (used by the cron tools and monitoring) —— - /** Wall-clock epoch ms read through the configured clock source. */ - now(): number; - isStale(task: CronTask): boolean; - getNextFireTime(): number | null; - getNextFireForTask(taskId: string): number | null; - - // —— lifecycle (driven by the engine, resume, and the test seam) —— - loadFromDisk(options?: CronLoadOptions): Promise; - start(): void; - stop(): Promise; - tick(): void; - flushPersist(): Promise; - handleMissed( - tasks: readonly CronTask[], - renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[], - ): Turn | undefined; - - // —— telemetry facade so the tools do not reach into ITelemetryService —— - emitScheduled(task: CronTask): void; - emitDeleted(taskId: string): void; -} - -export const IAgentCronService = createDecorator('agentCronService'); diff --git a/packages/agent-core-v2/src/agent/cron/index.ts b/packages/agent-core-v2/src/agent/cron/index.ts index d50855417..e5684e80a 100644 --- a/packages/agent-core-v2/src/agent/cron/index.ts +++ b/packages/agent-core-v2/src/agent/cron/index.ts @@ -1,9 +1,8 @@ /** - * `cron` domain barrel — re-exports the cron contract (`cron`) and its scoped - * service (`cronService`), plus a side-effect import of each cron tool so its - * `registerTool(...)` call runs at module load. Importing this barrel wires - * `IAgentCronService` into the scope registry and adds the three cron tools - * (`CronCreate` / `CronList` / `CronDelete`) to the tool contribution list. + * `cron` domain barrel — re-exports cron utilities (expression parser, jitter, + * format, clock, config) and registers the three cron tools (`CronCreate` / + * `CronList` / `CronDelete`) via side-effect imports. The cron task record + * type lives in `app/cronStore`; the scheduling engine lives in `session/cron`. */ import './configSection'; @@ -11,5 +10,8 @@ import './tools/cron-create'; import './tools/cron-delete'; import './tools/cron-list'; -export * from './cron'; -export * from './cronService'; +export * from './cron-expr'; +export * from './format'; +export * from './jitter'; +export * from './clock'; +export { CRON_SECTION, type CronConfig, DEFAULT_CRON_CONFIG } from './configSection'; diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-create.ts b/packages/agent-core-v2/src/agent/cron/tools/cron-create.ts index 184be94cf..4094a21e6 100644 --- a/packages/agent-core-v2/src/agent/cron/tools/cron-create.ts +++ b/packages/agent-core-v2/src/agent/cron/tools/cron-create.ts @@ -3,21 +3,20 @@ * at a future wall-clock time, either once (`recurring: false`) or on a * cron cadence (`recurring: true`, the default). * - * Tasks live in `AgentCronService` and are mirrored to - * `/agents//cron/.json` via - * `IAgentCronService.addTask`, so a - * `kimi resume` of the same session reloads them and the scheduler - * picks up where it left off (fires that fell during downtime are - * collapsed into a single delivery with `coalescedCount`). Tasks do + * Tasks live in `ISessionCronService` (Session scope) and are persisted + * through the App-scoped `ICronTaskStore` under the project's cron + * scope, so a `kimi resume` of the same session reloads them and the + * scheduler picks up where it left off (fires that fell during downtime + * are collapsed into a single delivery with `coalescedCount`). Tasks do * NOT carry over into a brand-new session. * * The tool itself is pure validation + bookkeeping; the firing / - * coalesce / jitter / persistence logic lives in `AgentCronService`. + * coalesce / jitter / persistence logic lives in `SessionCronService`. * This file only knows how to: * * 1. validate the request (killswitch, cron parse, 5-year window, * session cap, byte-length cap); - * 2. add it to the service (which writes through to disk on success); + * 2. add it to the service (which writes through to the store); * 3. report back the post-jitter `nextFireAt` and a human-readable * schedule for the model's benefit; * 4. emit `cron_scheduled` telemetry through the service (the tool @@ -31,7 +30,7 @@ import { registerTool } from '#/agent/toolRegistry'; import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; import { literalRulePattern } from '#/_base/tools/support/rule-match'; import { IConfigService } from '#/app/config'; -import { IAgentCronService } from '#/agent/cron/cron'; +import { ISessionCronService } from '#/session/cron'; import { CRON_SECTION, DEFAULT_CRON_CONFIG, @@ -129,7 +128,7 @@ export class CronCreateTool implements BuiltinTool { constructor( private readonly disabled: boolean = false, - @IAgentCronService private readonly cron: IAgentCronService, + @ISessionCronService private readonly cron: ISessionCronService, ) {} resolveExecution(args: CronCreateInput): ToolExecution { @@ -324,7 +323,6 @@ export class CronCreateTool implements BuiltinTool { } registerTool(CronCreateTool, { - when: (accessor) => accessor.get(IAgentCronService).isEnabled, staticArgs: (accessor) => [ accessor.get(IConfigService).get(CRON_SECTION)?.disabled ?? DEFAULT_CRON_CONFIG.disabled, diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-delete.ts b/packages/agent-core-v2/src/agent/cron/tools/cron-delete.ts index d45a159bc..5f3edf834 100644 --- a/packages/agent-core-v2/src/agent/cron/tools/cron-delete.ts +++ b/packages/agent-core-v2/src/agent/cron/tools/cron-delete.ts @@ -40,7 +40,7 @@ import { z } from 'zod'; import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool'; import { registerTool } from '#/agent/toolRegistry'; import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; -import { IAgentCronService } from '#/agent/cron/cron'; +import { ISessionCronService } from '#/session/cron'; import CRON_DELETE_DESCRIPTION from './cron-delete.md?raw'; // ── Constants ──────────────────────────────────────────────────────── @@ -72,7 +72,7 @@ export class CronDeleteTool implements BuiltinTool { CronDeleteInputSchema, ); - constructor(@IAgentCronService private readonly cron: IAgentCronService) {} + constructor(@ISessionCronService private readonly cron: ISessionCronService) {} resolveExecution(args: CronDeleteInput): ToolExecution { // Format check up front. The store would reject the lookup anyway, @@ -117,6 +117,4 @@ export class CronDeleteTool implements BuiltinTool { } } -registerTool(CronDeleteTool, { - when: (accessor) => accessor.get(IAgentCronService).isEnabled, -}); +registerTool(CronDeleteTool); diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-list.ts b/packages/agent-core-v2/src/agent/cron/tools/cron-list.ts index f2afa6886..22893fa30 100644 --- a/packages/agent-core-v2/src/agent/cron/tools/cron-list.ts +++ b/packages/agent-core-v2/src/agent/cron/tools/cron-list.ts @@ -27,7 +27,7 @@ * decimal places. Useful context for the `stale` * flag and for the LLM's "should I still be * running?" judgement. - * - `stale` — mirrors `IAgentCronService.isStale(task)`; see that + * - `stale` — mirrors `ISessionCronService.isStale(task)`; see that * method for the precise rules * (`recurring && age >= 7 days`, gated by * `KIMI_CRON_NO_STALE`). @@ -45,8 +45,8 @@ import { z } from 'zod'; import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool'; import { registerTool } from '#/agent/toolRegistry'; import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; -import { IAgentCronService } from '#/agent/cron/cron'; -import type { CronTask } from '#/agent/cron/cron'; +import { ISessionCronService } from '#/session/cron'; +import type { CronTask } from '#/app/cronStore'; import { cronToHuman, parseCronExpression, @@ -92,7 +92,7 @@ export class CronListTool implements BuiltinTool { CronListInputSchema, ); - constructor(@IAgentCronService private readonly cron: IAgentCronService) {} + constructor(@ISessionCronService private readonly cron: ISessionCronService) {} resolveExecution(_args: CronListInput): ToolExecution { return { @@ -171,6 +171,4 @@ export class CronListTool implements BuiltinTool { } } -registerTool(CronListTool, { - when: (accessor) => accessor.get(IAgentCronService).isEnabled, -}); +registerTool(CronListTool); diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 3974ff6d5..18e18c4e5 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -59,7 +59,8 @@ export type PersistenceScopeName = | 'store' | 'logs' | 'cache' - | 'credentials'; + | 'credentials' + | 'cron'; export interface IBootstrapService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index b834709ed..e4179edd8 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -68,6 +68,7 @@ export class BootstrapService implements IBootstrapService { logs: relative(options.homeDir, this.logsDir), cache: relative(options.homeDir, this.cacheDir), credentials: 'credentials', + cron: 'cron', }; } diff --git a/packages/agent-core-v2/src/app/cronStore/cronTask.ts b/packages/agent-core-v2/src/app/cronStore/cronTask.ts new file mode 100644 index 000000000..202c75085 --- /dev/null +++ b/packages/agent-core-v2/src/app/cronStore/cronTask.ts @@ -0,0 +1,21 @@ +/** + * `cron` domain (L5) — shared `CronTask` data record. + * + * The authoritative definition of a cron task's persistent shape. Used by + * `ICronTaskStore` (App scope) for project-level persistence and by + * `ISessionCronService` (Session scope) for the live scheduling engine. + * The `tags` map carries arbitrary metadata (e.g. `sessionId`) that the + * Session projection uses to filter tasks belonging to the current session. + */ + +export interface CronTask { + readonly id: string; + readonly cron: string; + readonly prompt: string; + readonly createdAt: number; + readonly recurring?: boolean; + readonly lastFiredAt?: number; + readonly tags?: Readonly>; +} + +export type CronTaskInit = Omit; diff --git a/packages/agent-core-v2/src/app/cronStore/cronTaskStore.ts b/packages/agent-core-v2/src/app/cronStore/cronTaskStore.ts new file mode 100644 index 000000000..58ff5efa5 --- /dev/null +++ b/packages/agent-core-v2/src/app/cronStore/cronTaskStore.ts @@ -0,0 +1,27 @@ +/** + * `cron` domain (L5) — `ICronTaskStore` contract. + * + * Project-level persistence catalog for cron tasks. Stores tasks under + * `bootstrap.scope('cron')` as atomic documents keyed by + * `/.json`. Provides CRUD and query-by-workspace. + * The store is a pure data layer — scheduling, timers, and fire delivery + * are owned by `ISessionCronService` at Session scope. Bound at App scope. + */ + +import { createDecorator } from '#/_base/di'; + +import type { CronTask } from './cronTask'; + +export interface CronTaskQuery { + readonly workspaceId: string; +} + +export interface ICronTaskStore { + readonly _serviceBrand: undefined; + get(workspaceId: string, taskId: string): Promise; + list(query: CronTaskQuery): Promise; + save(workspaceId: string, task: CronTask): Promise; + delete(workspaceId: string, taskId: string): Promise; +} + +export const ICronTaskStore = createDecorator('cronTaskStore'); diff --git a/packages/agent-core-v2/src/app/cronStore/cronTaskStoreService.ts b/packages/agent-core-v2/src/app/cronStore/cronTaskStoreService.ts new file mode 100644 index 000000000..0f36b1ea0 --- /dev/null +++ b/packages/agent-core-v2/src/app/cronStore/cronTaskStoreService.ts @@ -0,0 +1,100 @@ +/** + * `cron` domain (L5) — `ICronTaskStore` implementation. + * + * Persists cron tasks as atomic JSON documents under the `cron` persistence + * scope (`bootstrap.scope('cron')`), laid out as `/.json`. + * Pure CRUD — no scheduling logic. Bound at App scope. + */ + +import { Disposable } from '#/_base/di'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAtomicDocumentStore } from '#/persistence/interface'; +import { IBootstrapService } from '#/app/bootstrap'; + +import { ICronTaskStore, type CronTaskQuery } from './cronTaskStore'; +import type { CronTask } from './cronTask'; + +export const CRON_ID_REGEX: RegExp = /^[0-9a-f]{8}$/; +const JSON_SUFFIX = '.json'; + +export function isValidCronTask(obj: unknown): obj is CronTask { + if (typeof obj !== 'object' || obj === null) return false; + const o = obj as Record; + if (typeof o['id'] !== 'string' || !CRON_ID_REGEX.test(o['id'])) return false; + if (typeof o['cron'] !== 'string') return false; + if (typeof o['prompt'] !== 'string') return false; + if (typeof o['createdAt'] !== 'number') return false; + if (o['recurring'] !== undefined && typeof o['recurring'] !== 'boolean') return false; + if ( + o['lastFiredAt'] !== undefined && + (typeof o['lastFiredAt'] !== 'number' || !Number.isFinite(o['lastFiredAt'])) + ) { + return false; + } + if (o['tags'] !== undefined) { + if (typeof o['tags'] !== 'object' || o['tags'] === null) return false; + for (const v of Object.values(o['tags'] as Record)) { + if (typeof v !== 'string') return false; + } + } + return true; +} + +export class CronTaskStoreService extends Disposable implements ICronTaskStore { + declare readonly _serviceBrand: undefined; + + private readonly cronScope: string; + + constructor( + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore, + ) { + super(); + this.cronScope = this.bootstrap.scope('cron'); + } + + private workspaceScope(workspaceId: string): string { + return `${this.cronScope}/${workspaceId}`; + } + + async get(workspaceId: string, taskId: string): Promise { + const scope = this.workspaceScope(workspaceId); + const value = await this.atomicDocs.get(scope, `${taskId}${JSON_SUFFIX}`); + if (value === undefined || !isValidCronTask(value)) return undefined; + return value; + } + + async list(query: CronTaskQuery): Promise { + const scope = this.workspaceScope(query.workspaceId); + const keys = await this.atomicDocs.list(scope); + const tasks: CronTask[] = []; + for (const key of keys) { + if (!key.endsWith(JSON_SUFFIX)) continue; + const id = key.slice(0, -JSON_SUFFIX.length); + if (!CRON_ID_REGEX.test(id)) continue; + const value = await this.atomicDocs.get(scope, key); + if (value === undefined || !isValidCronTask(value)) continue; + tasks.push(value); + } + return tasks; + } + + async save(workspaceId: string, task: CronTask): Promise { + const scope = this.workspaceScope(workspaceId); + await this.atomicDocs.set(scope, `${task.id}${JSON_SUFFIX}`, task); + } + + async delete(workspaceId: string, taskId: string): Promise { + const scope = this.workspaceScope(workspaceId); + await this.atomicDocs.delete(scope, `${taskId}${JSON_SUFFIX}`); + } +} + +registerScopedService( + LifecycleScope.App, + ICronTaskStore, + CronTaskStoreService, + InstantiationType.Delayed, + 'cron', +); diff --git a/packages/agent-core-v2/src/app/cronStore/index.ts b/packages/agent-core-v2/src/app/cronStore/index.ts new file mode 100644 index 000000000..ad1b14397 --- /dev/null +++ b/packages/agent-core-v2/src/app/cronStore/index.ts @@ -0,0 +1,8 @@ +/** + * `cron` domain barrel — re-exports the cron task data record, the + * `ICronTaskStore` contract, and registers the App-scoped store service. + */ + +export * from './cronTask'; +export * from './cronTaskStore'; +export * from './cronTaskStoreService'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 27e638167..272dd2a61 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -42,6 +42,8 @@ export * from '#/agent/usage'; export * from '#/agent/toolDedupe'; export * from '#/agent/background'; +export * from '#/app/cronStore'; +export * from '#/session/cron'; import '#/agent/cron'; export * from '#/session/agentLifecycle'; diff --git a/packages/agent-core-v2/src/session/cron/index.ts b/packages/agent-core-v2/src/session/cron/index.ts new file mode 100644 index 000000000..7503d3e9e --- /dev/null +++ b/packages/agent-core-v2/src/session/cron/index.ts @@ -0,0 +1,7 @@ +/** + * `cron` domain barrel — re-exports the session cron contract and registers + * the Session-scoped `ISessionCronService` implementation. + */ + +export * from './sessionCronService'; +export * from './sessionCronServiceImpl'; diff --git a/packages/agent-core-v2/src/session/cron/sessionCronService.ts b/packages/agent-core-v2/src/session/cron/sessionCronService.ts new file mode 100644 index 000000000..fc4f90e33 --- /dev/null +++ b/packages/agent-core-v2/src/session/cron/sessionCronService.ts @@ -0,0 +1,49 @@ +/** + * `cron` domain (L5) — `ISessionCronService` contract. + * + * Session-level scheduling engine for cron tasks. Owns the live task set + * (filtered from `ICronTaskStore` by `sessionId` tag), the polling timer, + * and the fire/coalesce/jitter logic. On fire, borrows the main agent's + * `IAgentPromptService` via `IAgentLifecycleService` handle to steer a new + * turn. Bound at Session scope. + */ + +import type { ContentPart } from '#/app/llmProtocol'; + +import { createDecorator } from '#/_base/di'; +import type { Turn } from '#/agent/turn'; +import type { CronTask, CronTaskInit } from '#/app/cronStore'; + +export interface CronLoadOptions { + readonly replace?: boolean; +} + +export interface ISessionCronService { + readonly _serviceBrand: undefined; + readonly isEnabled: boolean; + + addTask(init: CronTaskInit): CronTask; + removeTasks(ids: readonly string[]): readonly string[]; + getTask(id: string): CronTask | undefined; + list(): readonly CronTask[]; + + now(): number; + isStale(task: CronTask): boolean; + getNextFireTime(): number | null; + getNextFireForTask(taskId: string): number | null; + + loadFromStore(options?: CronLoadOptions): Promise; + start(): void; + stop(): Promise; + tick(): void; + flushPersist(): Promise; + handleMissed( + tasks: readonly CronTask[], + renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[], + ): Turn | undefined; + + emitScheduled(task: CronTask): void; + emitDeleted(taskId: string): void; +} + +export const ISessionCronService = createDecorator('sessionCronService'); diff --git a/packages/agent-core-v2/src/agent/cron/cronService.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts similarity index 68% rename from packages/agent-core-v2/src/agent/cron/cronService.ts rename to packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 611b067c3..648326ed1 100644 --- a/packages/agent-core-v2/src/agent/cron/cronService.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -1,13 +1,13 @@ /** - * `cron` domain (L5) — `AgentCronService` implementation. + * `cron` domain (L5) — `SessionCronService` implementation. * - * Owns the agent's cron task set end to end: holds the in-memory task map, - * runs the scheduling loop (tick / coalesce / jitter / cursor), persists each - * task as an atomic document under the agent's home directory - * (`/agents//cron/.json`, matching the v1 layout so a - * session written by either side is readable by the other), mirrors mutations - * onto `wireRecord` for replay, registers the cron tools into `toolRegistry`, - * and steers the agent through `prompt` when a task fires. Bound at Agent scope. + * Session-level scheduling engine. Holds the in-memory task map (filtered + * from `ICronTaskStore` by `sessionId` tag), runs the polling timer + * (tick / coalesce / jitter / cursor), persists mutations through the + * App-scoped `ICronTaskStore`, mirrors mutations onto `wireRecord` for + * replay via the main agent's `IAgentRecordService` (cross-scope borrow), + * and steers the main agent through `IAgentPromptService` when a task fires. + * Bound at Session scope. */ import { randomBytes } from 'node:crypto'; @@ -17,16 +17,17 @@ import type { CronJobOrigin, CronMissedOrigin } from '@moonshot-ai/protocol'; import { Disposable, toDisposable } from '#/_base/di'; import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IntervalTimer } from '#/_base/utils'; import { IConfigService } from '#/app/config'; -import { IAtomicDocumentStore } from '#/app/storage'; import { ITelemetryService } from '#/app/telemetry'; +import { ICronTaskStore, type CronTask, type CronTaskInit } from '#/app/cronStore'; +import { ISessionContext } from '#/session/sessionContext'; +import { IAgentLifecycleService } from '#/session/agentLifecycle'; import type { ContextMessage } from '#/agent/contextMemory'; import { IAgentPromptService } from '#/agent/prompt'; import { IAgentRecordService } from '#/agent/record'; -import { IAgentScopeContext } from '#/agent/scopeContext'; import type { Turn } from '#/agent/turn'; import { IAgentTurnService } from '#/agent/turn'; @@ -34,27 +35,22 @@ import { type CronConfig, CRON_SECTION, DEFAULT_CRON_CONFIG, -} from './configSection'; -import { - IAgentCronService, - type CronLoadOptions, - type CronTask, - type CronTaskInit, -} from './cron'; +} from '#/agent/cron/configSection'; import { computeNextCronRun, parseCronExpression, type ParsedCronExpression, -} from './cron-expr'; -import { renderCronFireXml } from './format'; -import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from './jitter'; +} from '#/agent/cron/cron-expr'; +import { renderCronFireXml } from '#/agent/cron/format'; +import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from '#/agent/cron/jitter'; import { resolveClockSources, SYSTEM_CLOCKS, type ClockSources, -} from './clock'; +} from '#/agent/cron/clock'; + +import { ISessionCronService, type CronLoadOptions } from './sessionCronService'; -/** Telemetry event names emitted by the cron subsystem. Centralised so a typo can't drift a metric. */ export const CRON_SCHEDULED = 'cron_scheduled' as const; export const CRON_FIRED = 'cron_fired' as const; export const CRON_MISSED = 'cron_missed' as const; @@ -77,94 +73,37 @@ declare module '#/agent/wireRecord' { const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; const DEFAULT_POLL_INTERVAL_MS = 1_000; - -/** - * Cap on how many ideal fires we attempt to enumerate when computing - * coalescedCount. With a 1-minute cron, this still covers 10 000 minutes - * (~7 days). Beyond that we'd rather report 10 000 than spin. - */ const MAX_COALESCE_ITERATIONS = 10_000; - -/** Canonical cron task id shape (8 lower-hex chars) — doubles as the path-traversal guard. */ -export const CRON_ID_REGEX: RegExp = /^[0-9a-f]{8}$/; - -const JSON_SUFFIX = '.json'; +const CRON_ID_REGEX: RegExp = /^[0-9a-f]{8}$/; const MAX_ID_ATTEMPTS = 8; +const SESSION_TAG = 'sessionId'; -export function isValidCronTask(obj: unknown): obj is CronTask { - if (typeof obj !== 'object' || obj === null) return false; - const o = obj as Record; - if (typeof o['id'] !== 'string' || !CRON_ID_REGEX.test(o['id'])) return false; - if (typeof o['cron'] !== 'string') return false; - if (typeof o['prompt'] !== 'string') return false; - if (typeof o['createdAt'] !== 'number') return false; - if (o['recurring'] !== undefined && typeof o['recurring'] !== 'boolean') return false; - if ( - o['lastFiredAt'] !== undefined && - (typeof o['lastFiredAt'] !== 'number' || !Number.isFinite(o['lastFiredAt'])) - ) { - return false; - } - return true; -} - -function cronKey(id: string): string { - if (!CRON_ID_REGEX.test(id)) { - throw new Error(`Invalid cron job id: "${id}"`); - } - return `${id}${JSON_SUFFIX}`; -} - -export class AgentCronService extends Disposable implements IAgentCronService { +export class SessionCronServiceImpl extends Disposable implements ISessionCronService { declare readonly _serviceBrand: undefined; - // —— task set (the in-memory store) —— private readonly tasks = new Map(); - - // —— scheduler bookkeeping —— private readonly parsedCache = new Map(); private readonly lastSeenAt = new Map(); - private readonly seededFromDisk = new Set(); + private readonly seededFromStore = new Set(); private readonly inFlight = new Set(); private readonly timer = this._register(new IntervalTimer({ unref: true })); - - // —— persistence write serialization, keyed by task id —— private readonly persistQueues = new Map>(); readonly clocks: ClockSources; + readonly isEnabled: boolean = true; - private readonly enabled: boolean; - /** - * HomeDir-relative atomic-document scope for this agent's cron tasks, - * e.g. `sessions///agents//cron`. Co-locates - * the tasks with the agent's home directory (`/cron/.json`), - * matching the v1 layout. `undefined` when the agent has no id (ephemeral / - * test seam) — persistence is then skipped, matching v1's "no homedir, no - * persistence" behaviour. - */ - private readonly cronScope: string | undefined; private cronConfig: CronConfig; private started = false; private sigusr1Handler: NodeJS.SignalsListener | null = null; constructor( - @IAgentScopeContext private readonly ctx: IAgentScopeContext, - @IAgentPromptService private readonly prompt: IAgentPromptService, - @IAgentRecordService private readonly record: IAgentRecordService, - @IAgentTurnService private readonly turnService: IAgentTurnService, + @ISessionContext private readonly ctx: ISessionContext, + @ICronTaskStore private readonly store: ICronTaskStore, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @ITelemetryService private readonly telemetry: ITelemetryService, @IConfigService private readonly config: IConfigService, - @IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore, ) { super(); - this.enabled = this.ctx.agentId === 'main'; - // Co-locate cron tasks with the agent's home directory - // (`sessions///agents//cron/.json`), matching the v1 - // layout so a session written by the CLI / v1 server is readable here and - // vice-versa. `ctx.scope('cron')` is the agent-scoped persistence root's - // `cron` sub-scope, addressed straight into `IAtomicDocumentStore`. - this.cronScope = - typeof this.ctx.agentId === 'string' ? this.ctx.scope('cron') : undefined; this.cronConfig = this.config.get(CRON_SECTION) ?? DEFAULT_CRON_CONFIG; this._register( this.config.onDidChangeConfiguration((e) => { @@ -177,36 +116,15 @@ export class AgentCronService extends Disposable implements IAgentCronService { resolveClockSources(this.cronConfig.clock, this.cronConfig.debug) ?? SYSTEM_CLOCKS; this._register( - record.define('cron.add', { - resume: (r) => { - if (this.enabled) this.adopt(r.task); - }, - }), - ); - this._register( - record.define('cron.delete', { - resume: (r) => { - if (this.enabled) this.removeByIds(r.ids); - }, - }), - ); - this._register( - record.define('cron.cursor', { - resume: (r) => { - if (this.enabled) this.markFired(r.id, r.lastFiredAt); - }, - }), - ); - this._register( - record.hooks.onResumeEnded.register('cron-lifecycle-resume', async (_ctx, next) => { - await this.loadFromDisk({ replace: false }); - this.start(); - await next(); + this.agentLifecycle.onDidCreate((handle) => { + if (handle.id !== 'main') return; + this.wireMainAgent(handle); }), ); - if (this.enabled) { - this.start(); + const existingMain = this.agentLifecycle.getHandle('main'); + if (existingMain) { + this.wireMainAgent(existingMain); } this._register( @@ -216,8 +134,31 @@ export class AgentCronService extends Disposable implements IAgentCronService { ); } - get isEnabled(): boolean { - return this.enabled; + private wireMainAgent(handle: IAgentScopeHandle): void { + const record = handle.accessor.get(IAgentRecordService); + + record.define('cron.add', { + resume: (r) => { + this.adopt(r.task); + }, + }); + record.define('cron.delete', { + resume: (r) => { + this.removeByIds(r.ids); + }, + }); + record.define('cron.cursor', { + resume: (r) => { + this.markFired(r.id, r.lastFiredAt); + }, + }); + record.hooks.onResumeEnded.register('cron-lifecycle-resume', async (_ctx, next) => { + await this.loadFromStore({ replace: false }); + this.start(); + await next(); + }); + + void this.loadFromStore().then(() => this.start()); } now(): number { @@ -231,10 +172,13 @@ export class AgentCronService extends Disposable implements IAgentCronService { ...init, id: this.generateUniqueId(), createdAt: this.clocks.wallNow(), + tags: { ...init.tags, [SESSION_TAG]: this.ctx.sessionId }, }; this.tasks.set(task.id, task); - this.record.append({ type: 'cron.add', task }); - this.persistEnqueue(task.id, (scope) => this.atomicDocs.set(scope, cronKey(task.id), task)); + this.appendRecord({ type: 'cron.add', task }); + this.persistEnqueue(task.id, () => + this.store.save(this.ctx.workspaceId, task), + ); return task; } @@ -242,9 +186,11 @@ export class AgentCronService extends Disposable implements IAgentCronService { const removed = this.removeByIds(ids); if (removed.length === 0) return removed; - this.record.append({ type: 'cron.delete', ids: removed }); + this.appendRecord({ type: 'cron.delete', ids: removed }); for (const id of removed) { - this.persistEnqueue(id, (scope) => this.atomicDocs.delete(scope, cronKey(id))); + this.persistEnqueue(id, () => + this.store.delete(this.ctx.workspaceId, id), + ); } return removed; } @@ -282,26 +228,19 @@ export class AgentCronService extends Disposable implements IAgentCronService { // —— lifecycle —— - async loadFromDisk(options: CronLoadOptions = {}): Promise { - if (!this.enabled) return; - if (this.cronScope === undefined) return; - const scope = this.cronScope; + async loadFromStore(options: CronLoadOptions = {}): Promise { if (options.replace !== false) { this.tasks.clear(); } - const keys = await this.atomicDocs.list(scope); - for (const key of keys) { - if (!key.endsWith(JSON_SUFFIX)) continue; - const id = key.slice(0, -JSON_SUFFIX.length); - if (!CRON_ID_REGEX.test(id)) continue; - const value = await this.atomicDocs.get(scope, key); - if (value === undefined || !isValidCronTask(value)) continue; - this.adopt(value); + const allTasks = await this.store.list({ workspaceId: this.ctx.workspaceId }); + for (const task of allTasks) { + if (task.tags?.[SESSION_TAG] !== this.ctx.sessionId) continue; + this.adopt(task); } } start(): void { - if (!this.enabled || this.started) return; + if (this.started) return; this.started = true; const poll = this.cronConfig.manualTick ? null : this.cronConfig.pollIntervalMs; @@ -317,7 +256,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { this.timer.cancel(); this.inFlight.clear(); this.lastSeenAt.clear(); - this.seededFromDisk.clear(); + this.seededFromStore.clear(); this.parsedCache.clear(); await this.flushPersist(); this.started = false; @@ -325,9 +264,14 @@ export class AgentCronService extends Disposable implements IAgentCronService { tick(): void { if (this.cronConfig.disabled) return; - if (this.turnService.getActiveTurn() !== undefined) return; if (this.tasks.size === 0) return; + const mainHandle = this.agentLifecycle.getHandle('main'); + if (!mainHandle) return; + + const turnService = mainHandle.accessor.get(IAgentTurnService); + if (turnService.getActiveTurn() !== undefined) return; + const now = this.clocks.wallNow(); try { @@ -338,7 +282,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { const parsed = this.getParsed(task.cron); if ( - !this.seededFromDisk.has(task.id) && + !this.seededFromStore.has(task.id) && task.lastFiredAt !== undefined && Number.isFinite(task.lastFiredAt) && task.lastFiredAt <= now && @@ -346,7 +290,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { ) { this.lastSeenAt.set(task.id, task.lastFiredAt); } - this.seededFromDisk.add(task.id); + this.seededFromStore.add(task.id); const seen = this.lastSeenAt.get(task.id); const baseFromMs = @@ -382,7 +326,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { if (task.recurring === false) { this.removeTasks([task.id]); this.lastSeenAt.delete(task.id); - this.seededFromDisk.delete(task.id); + this.seededFromStore.delete(task.id); } else { const advancedTo = lastDueMs ?? now; this.lastSeenAt.set(task.id, advancedTo); @@ -410,7 +354,13 @@ export class AgentCronService extends Disposable implements IAgentCronService { tasks: readonly CronTask[], renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[], ): Turn | undefined { - if (!this.enabled || tasks.length === 0) return undefined; + if (tasks.length === 0) return undefined; + + const mainHandle = this.agentLifecycle.getHandle('main'); + if (!mainHandle) return undefined; + + const promptService = mainHandle.accessor.get(IAgentPromptService); + const origin: CronMissedOrigin = { kind: 'cron_missed', count: tasks.length, @@ -421,7 +371,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { toolCalls: [], origin, }; - const turn = this.prompt.steer(message); + const turn = promptService.steer(message); this.telemetry.track(CRON_MISSED, { count: tasks.length }); return turn; } @@ -452,6 +402,11 @@ export class AgentCronService extends Disposable implements IAgentCronService { task: CronTask, ctx: { readonly coalescedCount: number; readonly firedAt: number }, ): Turn | undefined { + const mainHandle = this.agentLifecycle.getHandle('main'); + if (!mainHandle) return undefined; + + const promptService = mainHandle.accessor.get(IAgentPromptService); + const origin: CronJobOrigin = { kind: 'cron_job', jobId: task.id, @@ -471,8 +426,8 @@ export class AgentCronService extends Disposable implements IAgentCronService { toolCalls: [], origin, }; - this.record.signal({ type: 'cron.fired', origin, prompt: task.prompt }); - const turn = this.prompt.steer(message); + this.signalRecord({ type: 'cron.fired', origin, prompt: task.prompt }); + const turn = promptService.steer(message); this.telemetry.track(CRON_FIRED, { recurring: task.recurring !== false, coalesced_count: ctx.coalescedCount, @@ -486,8 +441,26 @@ export class AgentCronService extends Disposable implements IAgentCronService { const updated = this.markFired(id, lastFiredAt); if (updated === undefined) return; - this.record.append({ type: 'cron.cursor', id, lastFiredAt }); - this.persistEnqueue(id, (scope) => this.atomicDocs.set(scope, cronKey(id), updated)); + this.appendRecord({ type: 'cron.cursor', id, lastFiredAt }); + this.persistEnqueue(id, () => + this.store.save(this.ctx.workspaceId, updated), + ); + } + + // —— wireRecord borrow helpers —— + + private appendRecord(record: { type: string; [key: string]: unknown }): void { + const mainHandle = this.agentLifecycle.getHandle('main'); + if (!mainHandle) return; + const recordService = mainHandle.accessor.get(IAgentRecordService); + recordService.append(record as never); + } + + private signalRecord(event: { type: string; [key: string]: unknown }): void { + const mainHandle = this.agentLifecycle.getHandle('main'); + if (!mainHandle) return; + const recordService = mainHandle.accessor.get(IAgentRecordService); + recordService.signal(event as never); } // —— scheduler helpers —— @@ -569,7 +542,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { private debugLog(message: string): void { if (this.cronConfig.debug) { - process.stderr.write(`[cron/service] ${message}\n`); + process.stderr.write(`[cron/session] ${message}\n`); } } @@ -604,7 +577,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { if (!this.tasks.has(candidate)) return candidate; } throw new Error( - `AgentCronService: failed to generate a unique 8-hex id after ${MAX_ID_ATTEMPTS} attempts`, + `SessionCronService: failed to generate a unique 8-hex id after ${MAX_ID_ATTEMPTS} attempts`, ); } @@ -617,13 +590,11 @@ export class AgentCronService extends Disposable implements IAgentCronService { // —— persistence write serialization —— - private persistEnqueue(id: string, work: (scope: string) => Promise): void { - if (this.cronScope === undefined) return; - const scope = this.cronScope; + private persistEnqueue(id: string, work: () => Promise): void { const prev = this.persistQueues.get(id) ?? Promise.resolve(); const next = prev .catch(() => {}) - .then(() => work(scope)) + .then(() => work()) .catch(() => {}) .finally(() => { if (this.persistQueues.get(id) === next) { @@ -645,7 +616,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { } catch (error) { if (this.cronConfig.debug) { const msg = error instanceof Error ? error.message : String(error); - process.stderr.write(`[cron/service] SIGUSR1 tick threw: ${msg}\n`); + process.stderr.write(`[cron/session] SIGUSR1 tick threw: ${msg}\n`); } } }; @@ -661,9 +632,9 @@ export class AgentCronService extends Disposable implements IAgentCronService { } registerScopedService( - LifecycleScope.Agent, - IAgentCronService, - AgentCronService, + LifecycleScope.Session, + ISessionCronService, + SessionCronServiceImpl, InstantiationType.Delayed, 'cron', ); diff --git a/packages/agent-core-v2/test/bootstrap/stubs.ts b/packages/agent-core-v2/test/bootstrap/stubs.ts index 941c66177..c1856bf6f 100644 --- a/packages/agent-core-v2/test/bootstrap/stubs.ts +++ b/packages/agent-core-v2/test/bootstrap/stubs.ts @@ -25,6 +25,7 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv logs: 'logs', cache: 'cache', credentials: 'credentials', + cron: 'cron', }; const sessionScope = (wsId: string, sId: string): string => `${sessionsScope}/${wsId}/${sId}`; const agentScope = (wsId: string, sId: string, aId: string): string => diff --git a/packages/agent-core-v2/test/cron/agent-integration.test.ts b/packages/agent-core-v2/test/cron/agent-integration.test.ts index 45ac577ac..a578bd1cf 100644 --- a/packages/agent-core-v2/test/cron/agent-integration.test.ts +++ b/packages/agent-core-v2/test/cron/agent-integration.test.ts @@ -1,6 +1,6 @@ /** * Agent + cron wiring smoke: verifies `new Agent(...)` constructs and - * starts an AgentCronService, registers the three cron tools, and that + * starts a SessionCronService, registers the three cron tools, and that * `KIMI_DISABLE_CRON=1` short-circuits `CronCreate`. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -9,7 +9,7 @@ import { CronCreateTool, type CronCreateInput, } from '#/agent/cron/tools/cron-create'; -import { IAgentCronService } from '#/agent/cron'; +import { ISessionCronService } from '#/session/cron'; import { IAgentProfileService } from '#/agent/profile'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; import { createTestAgent, type TestAgentContext } from '../harness'; @@ -17,12 +17,12 @@ import { createTestAgent, type TestAgentContext } from '../harness'; describe('Agent + Cron integration (P1.7)', () => { describe('default cron wiring', () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; let profile: IAgentProfileService; beforeEach(() => { ctx = createTestAgent(); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); profile = ctx.get(IAgentProfileService); profile.update({ activeToolNames: ['CronCreate', 'CronList', 'CronDelete'] }); }); @@ -59,14 +59,14 @@ describe('Agent + Cron integration (P1.7)', () => { describe('disabled cron config', () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; let profile: IAgentProfileService; let tools: IAgentToolRegistryService; beforeEach(() => { vi.stubEnv('KIMI_DISABLE_CRON', '1'); ctx = createTestAgent(); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); profile = ctx.get(IAgentProfileService); tools = ctx.get(IAgentToolRegistryService); profile.update({ activeToolNames: ['CronCreate'] }); diff --git a/packages/agent-core-v2/test/cron/cron.e2e.test.ts b/packages/agent-core-v2/test/cron/cron.e2e.test.ts index d9bca2601..f9d44881b 100644 --- a/packages/agent-core-v2/test/cron/cron.e2e.test.ts +++ b/packages/agent-core-v2/test/cron/cron.e2e.test.ts @@ -1,19 +1,18 @@ /** * Session-level cron end-to-end smoke: exercises the full - * `CronCreateTool → AgentCronService → agent.turn.steer` pipeline + * `CronCreateTool → SessionCronService → agent.turn.steer` pipeline * through the real `AgentTestContext`, with Date.now controlled by * the test so the `coalescedCount = 3` calibration after a 15-minute advance is * deterministic regardless of host TZ. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makeAgentScopeContext } from '#/agent/scopeContext'; import { CronCreateTool } from '#/agent/cron/tools/cron-create'; import { CronDeleteTool } from '#/agent/cron/tools/cron-delete'; import { CronListTool } from '#/agent/cron/tools/cron-list'; import type { ExecutableToolOutput } from '#/agent/tool'; import type { ContextMessage } from '#/agent/contextMemory'; -import { IAgentCronService } from '#/agent/cron'; +import { ISessionCronService } from '#/session/cron'; import { IAgentPromptService } from '#/agent/prompt'; import { createTestAgent, cronServices, type TestAgentContext } from '../harness'; @@ -44,7 +43,7 @@ function outputText(out: ExecutableToolOutput): string { describe('Cron — session E2E (P1.9)', () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; let prompt: IAgentPromptService; let harness: ReturnType; @@ -58,8 +57,8 @@ describe('Cron — session E2E (P1.9)', () => { vi.stubEnv('KIMI_CRON_NO_JITTER', '1'); vi.stubEnv('KIMI_CRON_POLL_INTERVAL_MS', '0'); harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); cron.start(); }); diff --git a/packages/agent-core-v2/test/cron/cron.test.ts b/packages/agent-core-v2/test/cron/cron.test.ts index 348ebf380..61f7d1b06 100644 --- a/packages/agent-core-v2/test/cron/cron.test.ts +++ b/packages/agent-core-v2/test/cron/cron.test.ts @@ -7,8 +7,8 @@ import { IBootstrapService } from '#/app/bootstrap'; import { IConfigRegistry, IConfigService } from '#/app/config'; import { ConfigRegistry, ConfigService } from '#/app/config/configService'; import type { ContextMessage } from '#/agent/contextMemory'; -import { IAgentCronService } from '#/agent/cron'; -import { AgentCronService } from '#/agent/cron/cronService'; +import { ISessionCronService } from '#/session/cron'; +import { SessionCronServiceImpl } from '#/session/cron/sessionCronServiceImpl'; import { ILogService } from '#/app/log'; import { IAgentPromptService } from '#/agent/prompt'; import { ISessionContext } from '#/session/sessionContext'; @@ -47,11 +47,16 @@ function textOf(message: ContextMessage): string { // NOTE: the legacy `CronFireCoordinator` (which steered the main agent on fire // through `IAgentTurnService.steer`) no longer exists in HEAD. Fire delivery now -// lives inside `AgentCronService` itself: a due, idle task is delivered via +// lives inside `SessionCronServiceImpl` itself: a due, idle task is delivered via // `IAgentPromptService.steer`. The cases below cover that path directly, so there is // no separate coordinator suite to migrate. -describe('AgentCronService', () => { +// TODO: The DI setup below was written for AgentCronService (Agent scope). +// SessionCronServiceImpl (Session scope) injects ISessionContext, ICronTaskStore, +// IAgentLifecycleService, ITelemetryService, IConfigService — not IAgentPromptService, +// IAgentRecordService, IAgentTurnService directly. The stub setup needs to be +// reworked to match the new dependency graph. +describe('SessionCronService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let now: number; @@ -107,8 +112,8 @@ describe('AgentCronService', () => { ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); ix.set(IConfigService, new SyncDescriptor(ConfigService)); ix.set( - IAgentCronService, - new SyncDescriptor(AgentCronService, [{}]), + ISessionCronService, + new SyncDescriptor(SessionCronServiceImpl, [{}]), ); }); afterEach(() => { @@ -118,7 +123,7 @@ describe('AgentCronService', () => { }); it('addTask / list / removeTasks', () => { - const svc = ix.get(IAgentCronService); + const svc = ix.get(ISessionCronService); const task = svc.addTask({ cron: '* * * * *', prompt: 'hi', recurring: false }); expect(svc.list()).toHaveLength(1); @@ -127,7 +132,7 @@ describe('AgentCronService', () => { }); it('does not fire while a turn is active', () => { - const svc = ix.get(IAgentCronService); + const svc = ix.get(ISessionCronService); svc.addTask({ cron: '* * * * *', prompt: 'fire-me', recurring: false }); activeTurn = fakeTurn(); @@ -138,7 +143,7 @@ describe('AgentCronService', () => { }); it('fires a due task when idle', () => { - const svc = ix.get(IAgentCronService); + const svc = ix.get(ISessionCronService); svc.addTask({ cron: '* * * * *', prompt: 'fire-me', recurring: false }); now = FAR_FUTURE_MS; @@ -150,7 +155,7 @@ describe('AgentCronService', () => { }); it('removes one-shot tasks after firing', () => { - const svc = ix.get(IAgentCronService); + const svc = ix.get(ISessionCronService); svc.addTask({ cron: '* * * * *', prompt: 'x', recurring: false }); now = FAR_FUTURE_MS; diff --git a/packages/agent-core-v2/test/cron/manager.test.ts b/packages/agent-core-v2/test/cron/manager.test.ts index 47e63c0c0..1806c4b2d 100644 --- a/packages/agent-core-v2/test/cron/manager.test.ts +++ b/packages/agent-core-v2/test/cron/manager.test.ts @@ -4,16 +4,15 @@ * (turn.hasActiveTurn, turn.steer, telemetry.track) need to look real. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makeAgentScopeContext } from '#/agent/scopeContext'; import type { ContentPart } from '#/app/llmProtocol/kosong'; -import type { CronTask } from '#/agent/cron'; +import type { CronTask } from '#/app/cronStore'; import { CRON_FIRED, CRON_MISSED, - IAgentCronService, -} from '#/agent/cron'; +} from '#/session/cron/sessionCronServiceImpl'; +import { ISessionCronService } from '#/session/cron'; import { IAgentPromptService } from '#/agent/prompt'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory'; import { ITelemetryService } from '#/app/telemetry'; @@ -77,8 +76,8 @@ function captureTelemetry(telemetry: ITelemetryService): TelemetryRecord[] { return records; } -describe('AgentCronService', () => { - let cron: IAgentCronService; +describe('SessionCronService', () => { + let cron: ISessionCronService; let ctx: TestAgentContext; let prompt: IAgentPromptService; let telemetry: ITelemetryService; @@ -108,8 +107,8 @@ describe('AgentCronService', () => { describe('construction', () => { beforeEach(() => { - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); }); it('does not throw with default clocks and supports start/stop', async () => { @@ -141,8 +140,8 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); telemetryRecords = captureTelemetry(telemetry); @@ -213,8 +212,8 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); telemetryRecords = captureTelemetry(telemetry); @@ -258,8 +257,8 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); }); it('flags recurring tasks older than 7 days as stale', () => { @@ -313,8 +312,8 @@ describe('AgentCronService', () => { beforeEach(() => { vi.stubEnv('KIMI_CRON_NO_STALE', '1'); harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); }); it('KIMI_CRON_NO_STALE=1 disables stale judgment for recurring', () => { @@ -333,9 +332,9 @@ describe('AgentCronService', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(Number.NaN); ctx = createTestAgent( - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); }); it('non-finite age is treated as not stale', () => { @@ -357,8 +356,8 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); telemetryRecords = captureTelemetry(telemetry); @@ -425,8 +424,8 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); telemetryRecords = captureTelemetry(telemetry); @@ -452,8 +451,8 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); turn = ctx.get(IAgentTurnService); @@ -495,8 +494,8 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); steerCalls = createSteerSpy(prompt); }); @@ -519,8 +518,8 @@ describe('AgentCronService', () => { let telemetryRecords: TelemetryRecord[]; beforeEach(() => { - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); telemetryRecords = captureTelemetry(telemetry); @@ -574,8 +573,8 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); }); diff --git a/packages/agent-core-v2/test/cron/manual-tick.test.ts b/packages/agent-core-v2/test/cron/manual-tick.test.ts index 61921c6e0..5b74102a3 100644 --- a/packages/agent-core-v2/test/cron/manual-tick.test.ts +++ b/packages/agent-core-v2/test/cron/manual-tick.test.ts @@ -4,10 +4,9 @@ * in the same gate, binds SIGUSR1 to a no-throw `tick()` for benches. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makeAgentScopeContext } from '#/agent/scopeContext'; import type { ContextMessage } from '#/agent/contextMemory'; -import { IAgentCronService } from '#/agent/cron'; +import { ISessionCronService } from '#/session/cron'; import { IAgentPromptService } from '#/agent/prompt'; import { createTestAgent, cronServices, type TestAgentContext } from '../harness'; @@ -40,7 +39,7 @@ function spySteer(prompt: IAgentPromptService) { })); } -describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { +describe('SessionCronService — P1.8 manual tick + SIGUSR1', () => { beforeEach(() => { // Disable jitter so fire-count assertions are deterministic. vi.stubEnv('KIMI_CRON_NO_JITTER', '1'); @@ -54,15 +53,15 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { describe('KIMI_CRON_MANUAL_TICK=1', () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; let prompt: IAgentPromptService; let harness: ClockHarness; beforeEach(() => { vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1'); harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); }); @@ -91,7 +90,7 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { describe('without KIMI_CRON_MANUAL_TICK', () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; let prompt: IAgentPromptService; let harness: ClockHarness; @@ -101,8 +100,8 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { vi.useFakeTimers(); vi.stubEnv('KIMI_CRON_POLL_INTERVAL_MS', '50'); harness = createClocks(); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); }); @@ -130,14 +129,14 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { // and trip Node's MaxListenersExceededWarning cap. describe('manual tick enabled', () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; let listenerCountBeforeCreate: number; beforeEach(() => { vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1'); listenerCountBeforeCreate = process.listenerCount('SIGUSR1'); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); }); afterEach(async () => { @@ -204,13 +203,13 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { describe('manual tick debug logging', () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; beforeEach(() => { vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1'); vi.stubEnv('KIMI_CRON_DEBUG', '1'); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); }); afterEach(async () => { @@ -242,11 +241,11 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { describe('manual tick disabled', () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; beforeEach(() => { - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); }); afterEach(async () => { diff --git a/packages/agent-core-v2/test/cron/persist.test.ts b/packages/agent-core-v2/test/cron/persist.test.ts index e20dc7cd2..823c0bea6 100644 --- a/packages/agent-core-v2/test/cron/persist.test.ts +++ b/packages/agent-core-v2/test/cron/persist.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { CronTask } from '#/agent/cron'; -import { CRON_ID_REGEX, isValidCronTask } from '#/agent/cron'; +import type { CronTask } from '#/app/cronStore'; +import { CRON_ID_REGEX, isValidCronTask } from '#/app/cronStore'; const validTask: CronTask = { id: '0123abcd', diff --git a/packages/agent-core-v2/test/cron/resume.test.ts b/packages/agent-core-v2/test/cron/resume.test.ts index 7940aeea2..57a50c767 100644 --- a/packages/agent-core-v2/test/cron/resume.test.ts +++ b/packages/agent-core-v2/test/cron/resume.test.ts @@ -1,8 +1,8 @@ /** - * Resume / cross-restart persistence for AgentCronService. + * Resume / cross-restart persistence for SessionCronService. * * The manager's `addTask` / `removeTasks` wrappers mirror every mutation - * to `/agents//cron/.json`, and `loadFromDisk()` + * to `/agents//cron/.json`, and `loadFromStore()` * re-populates the in-memory store on `kimi resume`. The scheduler's * `createdAt`-based baseline is what makes a reloaded task fire * correctly even when ideal fire times landed during downtime — these @@ -10,7 +10,6 @@ */ import { mkdtemp, readdir, rm } from 'node:fs/promises'; -import { makeAgentScopeContext } from '#/agent/scopeContext'; import { tmpdir } from 'node:os'; import { join, relative } from 'pathe'; @@ -19,8 +18,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ContentPart } from '#/app/llmProtocol/kosong'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory'; import { IAgentPromptService } from '#/agent/prompt'; -import type { CronTask } from '#/agent/cron'; -import { IAgentCronService } from '#/agent/cron'; +import type { CronTask } from '#/app/cronStore'; +import { ISessionCronService } from '#/session/cron'; import { IBootstrapService } from '#/app/bootstrap'; import { IAtomicDocumentStore } from '#/app/storage'; import { ISessionContext } from '#/session/sessionContext'; @@ -110,13 +109,13 @@ async function readPersistedTask( return cronDocuments(ctx).get(cronScope(ctx), `${id}.json`); } -describe('AgentCronService — persistence and resume', () => { +describe('SessionCronService — persistence and resume', () => { let sessionDir: string; let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; let prompt: IAgentPromptService; let resumedCtx: TestAgentContext | undefined; - let resumedCron: IAgentCronService | undefined; + let resumedCron: ISessionCronService | undefined; let resumedPrompt: IAgentPromptService | undefined; beforeEach(async () => { @@ -150,9 +149,9 @@ describe('AgentCronService — persistence and resume', () => { harness.install(); ctx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); }); it('addTask writes a JSON record to /agents//cron/.json', async () => { @@ -184,7 +183,7 @@ describe('AgentCronService — persistence and resume', () => { }); }); - describe('loadFromDisk', () => { + describe('loadFromStore', () => { let clockA: ClockHarness; let clockB: ClockHarness; @@ -194,15 +193,15 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - resumedCron = resumedCtx.get(IAgentCronService); + resumedCron = resumedCtx.get(ISessionCronService); }); it('re-adopts tasks with original id and createdAt', async () => { @@ -217,7 +216,7 @@ describe('AgentCronService — persistence and resume', () => { expect(resumedCron!.list()).toEqual([]); clockB.install(); - await resumedCron!.loadFromDisk(); + await resumedCron!.loadFromStore(); const loaded = resumedCron!.list().slice().toSorted((a, b) => a.id.localeCompare(b.id)); const expected = [t1, t2].toSorted((a, b) => a.id.localeCompare(b.id)); @@ -242,15 +241,15 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - resumedCron = resumedCtx.get(IAgentCronService); + resumedCron = resumedCtx.get(ISessionCronService); resumedPrompt = resumedCtx.get(IAgentPromptService); }); @@ -259,7 +258,7 @@ describe('AgentCronService — persistence and resume', () => { cron.addTask({ cron: '*/5 * * * *', prompt: 'check' }); await cron.flushPersist(); clockB.install(); - await resumedCron!.loadFromDisk(); + await resumedCron!.loadFromStore(); const steerCalls = captureSteer(resumedPrompt!); resumedCron!.tick(); @@ -283,15 +282,15 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - resumedCron = resumedCtx.get(IAgentCronService); + resumedCron = resumedCtx.get(ISessionCronService); resumedPrompt = resumedCtx.get(IAgentPromptService); }); @@ -305,7 +304,7 @@ describe('AgentCronService — persistence and resume', () => { await cron.flushPersist(); expect(await readDiskIds(ctx)).toEqual([oneShot.id]); clockB.install(); - await resumedCron!.loadFromDisk(); + await resumedCron!.loadFromStore(); const steerCalls = captureSteer(resumedPrompt!); resumedCron!.tick(); @@ -332,16 +331,16 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); prompt = ctx.get(IAgentPromptService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - resumedCron = resumedCtx.get(IAgentCronService); + resumedCron = resumedCtx.get(ISessionCronService); resumedPrompt = resumedCtx.get(IAgentPromptService); }); @@ -362,7 +361,7 @@ describe('AgentCronService — persistence and resume', () => { expect(onDisk!.lastFiredAt!).toBeLessThanOrEqual(clockA.now()); clockB.install(); - await resumedCron!.loadFromDisk(); + await resumedCron!.loadFromStore(); const steerCallsB = captureSteer(resumedPrompt!); resumedCron!.tick(); @@ -385,15 +384,15 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - resumedCron = resumedCtx.get(IAgentCronService); + resumedCron = resumedCtx.get(ISessionCronService); resumedPrompt = resumedCtx.get(IAgentPromptService); }); @@ -410,7 +409,7 @@ describe('AgentCronService — persistence and resume', () => { }); clockB.install(); - await resumedCron!.loadFromDisk(); + await resumedCron!.loadFromStore(); const steerCalls = captureSteer(resumedPrompt!); resumedCron!.tick(); @@ -427,18 +426,18 @@ describe('AgentCronService — persistence and resume', () => { const harness = createClocks(); harness.install(); ctx = createTestAgent( - cronServices(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + cronServices(), ); - cron = ctx.get(IAgentCronService); + cron = ctx.get(ISessionCronService); }); - it('no sessionDir = pure in-memory: no FS side effects, loadFromDisk is a no-op', async () => { + it('no sessionDir = pure in-memory: no FS side effects, loadFromStore is a no-op', async () => { cron.addTask({ cron: '*/5 * * * *', prompt: 'a' }); await cron.flushPersist(); expect(await readDiskIds(ctx)).toEqual([]); expect(cron.list().length).toBe(1); - await cron.loadFromDisk(); + await cron.loadFromStore(); expect(cron.list().length).toBe(1); }); }); diff --git a/packages/agent-core-v2/test/cron/subagent-skip.test.ts b/packages/agent-core-v2/test/cron/subagent-skip.test.ts index 7367263d8..14f4d14a1 100644 --- a/packages/agent-core-v2/test/cron/subagent-skip.test.ts +++ b/packages/agent-core-v2/test/cron/subagent-skip.test.ts @@ -1,6 +1,6 @@ /** * Subagent cron suppression: each session can spawn many subagents, and - * unconditionally starting an AgentCronService per agent leaks 1s setInterval + * unconditionally starting a SessionCronService per agent leaks 1s setInterval * timers and SIGUSR1 listeners (under KIMI_CRON_MANUAL_TICK=1) that * never serve any purpose — default subagent profiles don't expose the * Cron tools to the LLM. This test pins both halves of the fix: @@ -15,9 +15,8 @@ * — listener bound, tools registered. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makeAgentScopeContext } from '#/agent/scopeContext'; -import { IAgentCronService } from '#/agent/cron'; +import { ISessionCronService } from '#/session/cron'; import { IAgentProfileService } from '#/agent/profile'; import { createTestAgent, cronServices, type TestAgentContext } from '../harness'; @@ -38,14 +37,14 @@ describe('Agent + Cron — subagent suppression', () => { describe("type='sub'", () => { let ctx: TestAgentContext; - let cron: IAgentCronService; + let cron: ISessionCronService; let profile: IAgentProfileService; let listenerCountBeforeCreate: number; beforeEach(() => { listenerCountBeforeCreate = process.listenerCount('SIGUSR1'); - ctx = createTestAgent(cronServices(makeAgentScopeContext({ agentId: 'sub-1', agentScope: '' }))); - cron = ctx.get(IAgentCronService); + ctx = createTestAgent(cronServices()); + cron = ctx.get(ISessionCronService); profile = ctx.get(IAgentProfileService); }); @@ -60,7 +59,7 @@ describe('Agent + Cron — subagent suppression', () => { it('cron exists, start() is skipped, tools not registered', () => { if (process.platform === 'win32') return; - // Subagents get a disabled AgentCronService: no scheduler, no timers, + // Subagents get a disabled SessionCronService: no scheduler, no timers, // no SIGUSR1 listener and no tools — the service-DI equivalent of // the old `agent.cron === null`. expect(cron.isEnabled).toBe(false); diff --git a/packages/agent-core-v2/test/cron/tools.test.ts b/packages/agent-core-v2/test/cron/tools.test.ts index c536c81fa..a1b81a9ae 100644 --- a/packages/agent-core-v2/test/cron/tools.test.ts +++ b/packages/agent-core-v2/test/cron/tools.test.ts @@ -8,7 +8,8 @@ import type { RunnableToolExecution, ToolExecution, } from '#/agent/tool'; -import type { CronTask, CronTaskInit, IAgentCronService } from '#/agent/cron'; +import type { CronTask, CronTaskInit } from '#/app/cronStore'; +import type { ISessionCronService } from '#/session/cron'; import { computeNextCronRun, parseCronExpression, @@ -38,7 +39,7 @@ interface FakeStore { interface ToolHarness { readonly store: FakeStore; - readonly cron: IAgentCronService; + readonly cron: ISessionCronService; readonly scheduled: CronTask[]; readonly deleted: string[]; setNow(value: number): void; @@ -73,7 +74,7 @@ function createToolHarness(options: { }, }; - const cron: IAgentCronService = { + const cron: ISessionCronService = { _serviceBrand: undefined, isEnabled: true, now: () => now, @@ -106,7 +107,7 @@ function createToolHarness(options: { emitDeleted: (id) => { deleted.push(id); }, - loadFromDisk: async () => {}, + loadFromStore: async () => {}, start: () => {}, stop: async () => {}, tick: () => {}, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index df92621ac..03fdb39bf 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -16,8 +16,10 @@ import { } from '#/agent/blobStore'; import { IAgentContextInjectorService } from '#/agent/contextInjector'; import type { ContextMessage } from '#/agent/contextMemory'; -import { IAgentCronService } from '#/agent/cron/cron'; -import { AgentCronService } from '#/agent/cron/cronService'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; +import { SessionCronServiceImpl } from '#/session/cron/sessionCronServiceImpl'; +import { ICronTaskStore } from '#/app/cronStore/cronTaskStore'; +import { CronTaskStoreService } from '#/app/cronStore/cronTaskStoreService'; import type { HookEngine } from '#/agent/externalHooks/engine'; import type { FullCompactionServiceOptions } from '#/agent/fullCompaction'; import { AgentGoalService, IAgentGoalService, type GoalServiceOptions } from '#/agent/goal'; @@ -585,10 +587,8 @@ export function backgroundServices(): TestAgentServiceOverride { return agentService(IAgentBackgroundService, new SyncDescriptor(AgentBackgroundService)); } -export function cronServices( - options: ConstructorParameters[0], -): TestAgentServiceOverride { - return agentService(IAgentCronService, new SyncDescriptor(AgentCronService, [options])); +export function cronServices(): TestAgentServiceOverride { + return sessionService(ISessionCronService, new SyncDescriptor(SessionCronServiceImpl)); } export function mcpServices(options: McpServiceOptions): TestAgentServiceOverride { @@ -966,6 +966,7 @@ export class AgentTestContext { if (options.telemetry !== undefined) { reg.defineInstance(ITelemetryService, options.telemetry); } + reg.defineDescriptor(ICronTaskStore, new SyncDescriptor(CronTaskStoreService)); }, ], this.serviceOverrides, @@ -1005,6 +1006,10 @@ export class AgentTestContext { ISessionModelResolver, new SyncDescriptor(ConfigBackedModelResolver, [{}]), ); + reg.defineDescriptor( + ISessionCronService, + new SyncDescriptor(SessionCronServiceImpl), + ); }, ], this.serviceOverrides, @@ -1054,7 +1059,6 @@ export class AgentTestContext { } satisfies PermissionGateOptions, ]), ); - reg.defineDescriptor(IAgentCronService, new SyncDescriptor(AgentCronService, [{}])); reg.defineDescriptor( IAgentBackgroundService, new SyncDescriptor(AgentBackgroundService), @@ -1148,7 +1152,7 @@ export class AgentTestContext { const permission = this.get(IAgentPermissionGate); const permissionMode = this.get(IAgentPermissionModeService); const permissionRules = this.get(IAgentPermissionRulesService); - const cron = this.get(IAgentCronService); + const cron = this.get(ISessionCronService); const plan = this.get(IAgentPlanService); // Force-instantiate the Eager builtin-tools registrar: its constructor // consumes every `registerTool(...)` contribution, so `Read`/`Write`/