mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 14:16:22 +00:00
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:
parent
0f2e97245b
commit
e7747c6a2f
27 changed files with 509 additions and 400 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ export type PersistenceScopeName =
|
|||
| 'store'
|
||||
| 'logs'
|
||||
| 'cache'
|
||||
| 'credentials';
|
||||
| 'credentials'
|
||||
| 'cron';
|
||||
|
||||
export interface IBootstrapService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
21
packages/agent-core-v2/src/app/cronStore/cronTask.ts
Normal file
21
packages/agent-core-v2/src/app/cronStore/cronTask.ts
Normal 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'>;
|
||||
27
packages/agent-core-v2/src/app/cronStore/cronTaskStore.ts
Normal file
27
packages/agent-core-v2/src/app/cronStore/cronTaskStore.ts
Normal 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');
|
||||
100
packages/agent-core-v2/src/app/cronStore/cronTaskStoreService.ts
Normal file
100
packages/agent-core-v2/src/app/cronStore/cronTaskStoreService.ts
Normal 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',
|
||||
);
|
||||
8
packages/agent-core-v2/src/app/cronStore/index.ts
Normal file
8
packages/agent-core-v2/src/app/cronStore/index.ts
Normal 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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
7
packages/agent-core-v2/src/session/cron/index.ts
Normal file
7
packages/agent-core-v2/src/session/cron/index.ts
Normal 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';
|
||||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
|
|
@ -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`/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue