refactor(agent-core-v2): move cron from Agent scope to Session scope with App-scoped store

- split cron task persistence into App-scoped ICronTaskStore
  (packages/agent-core-v2/src/app/cronStore)
- rename AgentCronService to SessionCronService and bind it at Session scope
- tag tasks with sessionId and filter store queries by workspace/session
- borrow the main agent's prompt/record/turn services via IAgentLifecycleService
- update CronCreate/CronList/CronDelete tools to use ISessionCronService
- adjust bootstrap, layer checker, barrel exports, and all cron tests
This commit is contained in:
haozhe.yang 2026-07-03 11:32:40 +08:00
parent 0f2e97245b
commit e7747c6a2f
27 changed files with 509 additions and 400 deletions

View file

@ -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.

View file

@ -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<CronTask, 'id' | 'createdAt'>;
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<void>;
start(): void;
stop(): Promise<void>;
tick(): void;
flushPersist(): Promise<void>;
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<IAgentCronService>('agentCronService');

View file

@ -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';

View file

@ -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
* `<sessionDir>/agents/<agentId>/cron/<id>.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<CronCreateInput> {
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<CronCreateInput> {
}
registerTool(CronCreateTool, {
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
staticArgs: (accessor) => [
accessor.get(IConfigService).get<CronConfig>(CRON_SECTION)?.disabled
?? DEFAULT_CRON_CONFIG.disabled,

View file

@ -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<CronDeleteInput> {
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<CronDeleteInput> {
}
}
registerTool(CronDeleteTool, {
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
});
registerTool(CronDeleteTool);

View file

@ -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<CronListInput> {
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<CronListInput> {
}
}
registerTool(CronListTool, {
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
});
registerTool(CronListTool);

View file

@ -59,7 +59,8 @@ export type PersistenceScopeName =
| 'store'
| 'logs'
| 'cache'
| 'credentials';
| 'credentials'
| 'cron';
export interface IBootstrapService {
readonly _serviceBrand: undefined;

View file

@ -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',
};
}

View file

@ -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<Record<string, string>>;
}
export type CronTaskInit = Omit<CronTask, 'id' | 'createdAt'>;

View file

@ -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
* `<workspaceId>/<taskId>.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<CronTask | undefined>;
list(query: CronTaskQuery): Promise<readonly CronTask[]>;
save(workspaceId: string, task: CronTask): Promise<void>;
delete(workspaceId: string, taskId: string): Promise<void>;
}
export const ICronTaskStore = createDecorator<ICronTaskStore>('cronTaskStore');

View file

@ -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 `<workspaceId>/<id>.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<string, unknown>;
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<string, unknown>)) {
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<CronTask | undefined> {
const scope = this.workspaceScope(workspaceId);
const value = await this.atomicDocs.get<CronTask>(scope, `${taskId}${JSON_SUFFIX}`);
if (value === undefined || !isValidCronTask(value)) return undefined;
return value;
}
async list(query: CronTaskQuery): Promise<readonly CronTask[]> {
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<CronTask>(scope, key);
if (value === undefined || !isValidCronTask(value)) continue;
tasks.push(value);
}
return tasks;
}
async save(workspaceId: string, task: CronTask): Promise<void> {
const scope = this.workspaceScope(workspaceId);
await this.atomicDocs.set(scope, `${task.id}${JSON_SUFFIX}`, task);
}
async delete(workspaceId: string, taskId: string): Promise<void> {
const scope = this.workspaceScope(workspaceId);
await this.atomicDocs.delete(scope, `${taskId}${JSON_SUFFIX}`);
}
}
registerScopedService(
LifecycleScope.App,
ICronTaskStore,
CronTaskStoreService,
InstantiationType.Delayed,
'cron',
);

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<void>;
start(): void;
stop(): Promise<void>;
tick(): void;
flushPersist(): Promise<void>;
handleMissed(
tasks: readonly CronTask[],
renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[],
): Turn | undefined;
emitScheduled(task: CronTask): void;
emitDeleted(taskId: string): void;
}
export const ISessionCronService = createDecorator<ISessionCronService>('sessionCronService');

View file

@ -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
* (`<sessionDir>/agents/<agentId>/cron/<id>.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<string, unknown>;
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<string, CronTask>();
// —— scheduler bookkeeping ——
private readonly parsedCache = new Map<string, ParsedCronExpression>();
private readonly lastSeenAt = new Map<string, number>();
private readonly seededFromDisk = new Set<string>();
private readonly seededFromStore = new Set<string>();
private readonly inFlight = new Set<string>();
private readonly timer = this._register(new IntervalTimer({ unref: true }));
// —— persistence write serialization, keyed by task id ——
private readonly persistQueues = new Map<string, Promise<void>>();
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/<workspaceId>/<sessionId>/agents/<agentId>/cron`. Co-locates
* the tasks with the agent's home directory (`<agentHomedir>/cron/<id>.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/<wsId>/<sId>/agents/<agentId>/cron/<id>.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<CronConfig>(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<void> {
if (!this.enabled) return;
if (this.cronScope === undefined) return;
const scope = this.cronScope;
async loadFromStore(options: CronLoadOptions = {}): Promise<void> {
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<CronTask>(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>): void {
if (this.cronScope === undefined) return;
const scope = this.cronScope;
private persistEnqueue(id: string, work: () => Promise<void>): 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',
);

View file

@ -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 =>

View file

@ -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'] });

View file

@ -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<typeof createClocks>;
@ -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();
});

View file

@ -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;

View file

@ -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);
});

View file

@ -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 () => {

View file

@ -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',

View file

@ -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 `<sessionDir>/agents/<agentId>/cron/<id>.json`, and `loadFromDisk()`
* to `<sessionDir>/agents/<agentId>/cron/<id>.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<CronTask>(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 <sessionDir>/agents/<agentId>/cron/<id>.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);
});
});

View file

@ -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);

View file

@ -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: () => {},

View file

@ -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<typeof AgentCronService>[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`/