refactor(agent-core-v2): persist cron tasks as durable wire records (#3093)

* refactor(agent-core-v2): persist cron tasks as durable wire records

- write CronAdd/CronDelete/CronCursor as durable wire records and rebuild the cron task table from dispatcher replay
- migrate legacy per-workspace cron JSON files into the wire on first resume, then drop the file-based persistence service, its registrations, and the bootstrap cron scope
- derive the session cron view from the agent replayable cron state and remove the redundant session-level copy
- let session forks inherit cron tasks through the copied wire instead of duplicating task files

* fix(agent-core-v2): keep legacy cron tasks on cold forks and flush before cleanup

- inherit legacy cron task files into a full fork's wire so cold sessions forked before their first post-upgrade resume do not silently lose scheduled tasks
- flush the migrated wire records before deleting legacy files so a crash cannot lose both copies

* refactor(agent-core-v2): drop the legacy cron file migration

- stop reading legacy per-workspace cron JSON files entirely; pre-upgrade tasks simply stop applying instead of being migrated into the wire
- remove the legacy read path, the fork-time legacy inheritance, and the now-unused session context/document store injections
This commit is contained in:
Haozhe 2026-08-19 21:26:57 +08:00 committed by GitHub
parent c843d3a7f9
commit eac9ea88e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 306 additions and 277 deletions

View file

@ -27,7 +27,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 98 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 17 keys · Agent: 98 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
@ -42,7 +42,6 @@
// cron.parsedCache src/session/cron/sessionCronServiceImpl.ts
// cron.seededFromStore src/session/cron/sessionCronServiceImpl.ts
// cron.started src/session/cron/sessionCronServiceImpl.ts
// cron.tasks src/session/cron/sessionCronServiceImpl.ts
// interaction.nextId src/session/interaction/interactionService.ts
// interaction.pending src/session/interaction/interactionService.ts
// interaction.recentlyResolved src/session/interaction/interactionService.ts
@ -443,15 +442,6 @@ export interface SessionStateSnapshot {
}>;
'cron.seededFromStore': Set<string>;
'cron.started': boolean;
'cron.tasks': Map<string, /* CronTask — packages/agent-core-v2/src/app/cron/cronTask.ts */ {
readonly id: string;
readonly cron: string;
readonly prompt: string;
readonly createdAt: number;
readonly recurring?: boolean;
readonly lastFiredAt?: number;
readonly tags?: Readonly<Record<string, string>>;
}>;
// src/session/interaction/interactionService.ts
'interaction.nextId': number;
'interaction.pending': Map<string, /* Pending — packages/agent-core-v2/src/session/interaction/interactionService.ts */ {
@ -1591,7 +1581,7 @@ export interface AgentStateSnapshot {
// replayable · durable — folds: TowerModeEnter, TowerModeExit
'tower': boolean;
// src/session/cron/cronOps.ts
// replayable · transient — folds: CronAdd, CronDelete, CronCursor
// replayable · durable — folds: CronAdd, CronDelete, CronCursor
'cron': /* CronModelState — packages/agent-core-v2/src/session/cron/cronOps.ts */ Map<string, /* CronTask — packages/agent-core-v2/src/app/cron/cronTask.ts */ {
readonly id: string;
readonly cron: string;

View file

@ -24,13 +24,16 @@
// cross-reducers), blobs (the folding states whose blob codec offloads inline
// media to blob storage), owner (the source file declaring the class).
// Index (49 record types)
// Index (52 record types)
// config.update profile src/agent/profile/profileOps.ts
// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts
// context.append_message contextMemory, goalForkNotice, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts
// context.apply_compaction contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts
// context.clear contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts
// context.undo contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts
// cron.add cron src/session/cron/cronOps.ts
// cron.cursor cron src/session/cron/cronOps.ts
// cron.delete cron src/session/cron/cronOps.ts
// forked goal, goalForkNotice src/agent/goal/goalOps.ts
// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts
// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts
@ -167,6 +170,42 @@ interface ContextUndoPayload {
count: number;
}
/**
* states: cron
* owner: src/session/cron/cronOps.ts
*/
interface CronAddPayload {
_name: 'cron.add';
task: {
id: string;
cron: string;
prompt: string;
createdAt: number;
recurring?: boolean;
lastFiredAt?: number;
tags?: Record<string, string>;
};
}
/**
* states: cron
* owner: src/session/cron/cronOps.ts
*/
interface CronCursorPayload {
_name: 'cron.cursor';
id: string;
lastFiredAt: number;
}
/**
* states: cron
* owner: src/session/cron/cronOps.ts
*/
interface CronDeletePayload {
_name: 'cron.delete';
ids: string[];
}
/**
* states: goal, goalForkNotice
* owner: src/agent/goal/goalOps.ts
@ -715,6 +754,9 @@ interface WirePayloadMap {
"context.apply_compaction": ContextApplyCompactionPayload;
"context.clear": ContextClearPayload;
"context.undo": ContextUndoPayload;
"cron.add": CronAddPayload;
"cron.cursor": CronCursorPayload;
"cron.delete": CronDeletePayload;
"forked": ForkedPayload;
"full_compaction.begin": FullCompactionBeginPayload;
"full_compaction.cancel": FullCompactionCancelPayload;

View file

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

View file

@ -56,7 +56,6 @@ export class BootstrapService implements IBootstrapService {
logs: relative(options.homeDir, this.logsDir),
cache: relative(options.homeDir, this.cacheDir),
credentials: 'credentials',
cron: 'cron',
};
}

View file

@ -9,5 +9,3 @@ export interface CronTask {
}
export type CronTaskInit = Omit<CronTask, 'id' | 'createdAt'>;
export const CRON_SESSION_TAG = 'sessionId';

View file

@ -1,18 +0,0 @@
import { createDecorator } from '#/_base/di/instantiation';
import type { CronTask } from './cronTask';
export interface CronTaskQuery {
readonly workspaceId: string;
}
export interface ICronTaskPersistence {
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 ICronTaskPersistence = createDecorator<ICronTaskPersistence>('cronTaskPersistence');

View file

@ -1,92 +0,0 @@
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { ICronTaskPersistence, type CronTaskQuery } from './cronTaskPersistence';
import type { CronTask } from './cronTask';
export const CRON_ID_REGEX: RegExp = /^(?:[0-9a-f]{8}|[0-9A-HJKMNP-TV-Z]{26})$/i;
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 CronTaskPersistenceService extends Disposable implements ICronTaskPersistence {
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,
ICronTaskPersistence,
CronTaskPersistenceService,
ScopeActivation.OnScopeCreated,
'cron',
);

View file

@ -413,8 +413,6 @@ export * from '#/agent/task/taskOps';
export * from '#/agent/task/taskService';
import '#/app/cron/configSection';
export * from '#/app/cron/cronTask';
export * from '#/app/cron/cronTaskPersistence';
export * from '#/app/cron/cronTaskPersistenceService';
export * from '#/app/cron/cron-expr';
export * from '#/app/cron/format';
export * from '#/app/cron/jitter';

View file

@ -8,12 +8,28 @@ import { defineState } from '#/state/state';
export type CronModelState = Map<string, CronTask>;
const cronTaskSchema = z.object({
id: z.string(),
cron: z.string(),
prompt: z.string(),
createdAt: z.number(),
recurring: z.boolean().optional(),
lastFiredAt: z.number().optional(),
tags: z.record(z.string(), z.string()).optional(),
});
const cronAddSchema = z.object({ task: cronTaskSchema });
const cronDeleteSchema = z.object({ ids: z.array(z.string()) });
const cronCursorSchema = z.object({ id: z.string(), lastFiredAt: z.number() });
export interface CronAddPayload {
readonly task: CronTask;
}
export class CronAdd extends Event2<CronAddPayload> {
static override readonly type = 'cron.add';
static override readonly durable = true;
static override readonly schema = cronAddSchema;
}
export interface CronAdd extends CronAddPayload {}
@ -23,6 +39,8 @@ export interface CronDeletePayload {
export class CronDelete extends Event2<CronDeletePayload> {
static override readonly type = 'cron.delete';
static override readonly durable = true;
static override readonly schema = cronDeleteSchema;
}
export interface CronDelete extends CronDeletePayload {}
@ -33,6 +51,8 @@ export interface CronCursorPayload {
export class CronCursor extends Event2<CronCursorPayload> {
static override readonly type = 'cron.cursor';
static override readonly durable = true;
static override readonly schema = cronCursorSchema;
}
export interface CronCursor extends CronCursorPayload {}
@ -49,7 +69,6 @@ export interface CronFired extends CronFiredPayload {}
export const cronKey = defineState('cron', (): CronModelState => new Map()).replayable({
schema: z.custom<CronModelState>(),
durable: false,
})
.on(CronAdd, (s, e) => {
s.set(e.task.id, e.task);

View file

@ -5,10 +5,6 @@ import type { Turn } from '#/agent/loop/loop';
import type { CronTask, CronTaskInit } from '#/app/cron/cronTask';
import type { ParsedCronExpression } from '#/app/cron/cron-expr';
export interface CronLoadOptions {
readonly replace?: boolean;
}
export interface ISessionCronService {
readonly _serviceBrand: undefined;
@ -27,11 +23,9 @@ export interface ISessionCronService {
parsed: ParsedCronExpression,
idealMs: number,
): number | null;
loadFromStore(options?: CronLoadOptions): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
tick(): Promise<void>;
flushPersist(): Promise<void>;
handleMissed(
tasks: readonly CronTask[],
renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[],

View file

@ -15,11 +15,9 @@ import { ITelemetryService } from '#/app/telemetry/telemetry';
import { type ClockSources, resolveClockSources, SYSTEM_CLOCKS } from '#/app/cron/clock';
import { type CronConfig, CRON_SECTION } from '#/app/cron/configSection';
import { computeNextCronRun, parseCronExpression, type ParsedCronExpression } from '#/app/cron/cron-expr';
import { CRON_SESSION_TAG, type CronTask, type CronTaskInit } from '#/app/cron/cronTask';
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
import { type CronTask, type CronTaskInit } from '#/app/cron/cronTask';
import { renderCronFireXml } from '#/app/cron/format';
import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from '#/app/cron/jitter';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionStateService } from '#/session/state/sessionState';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import type { ContextMessage } from '#/agent/contextMemory/types';
@ -35,14 +33,13 @@ import { ICronListTool } from '#/agent/tools/cron/cron-list/cron-list';
import { ICronDeleteTool } from '#/agent/tools/cron/cron-delete/cron-delete';
import { CronAdd, CronDelete, CronCursor, CronFired, cronKey } from './cronOps';
import { ISessionCronService, type CronLoadOptions } from './sessionCronService';
import { ISessionCronService } from './sessionCronService';
export const CRON_SCHEDULED = 'cron_scheduled' as const;
export const CRON_FIRED = 'cron_fired' as const;
export const CRON_MISSED = 'cron_missed' as const;
export const CRON_DELETED = 'cron_deleted' as const;
export const cronTasksKey = defineState<Map<string, CronTask>>('cron.tasks', () => new Map());
export const cronParsedCacheKey = defineState<Map<string, ParsedCronExpression>>(
'cron.parsedCache',
() => new Map(),
@ -62,7 +59,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
declare readonly _serviceBrand: undefined;
private readonly timer = this._register(new IntervalTimer({ unref: true }));
private readonly persistQueues = new Map<string, Promise<void>>();
private clocks: ClockSources = SYSTEM_CLOCKS;
readonly isEnabled: boolean = true;
@ -71,15 +67,12 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
constructor(
@ISessionStateService private readonly states: ISessionStateService,
@ISessionContext private readonly ctx: ISessionContext,
@ICronTaskPersistence private readonly store: ICronTaskPersistence,
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IConfigService private readonly config: IConfigService,
) {
super();
this.states.contributeState(cronTasksKey);
this.states.contributeState(cronParsedCacheKey);
this.states.contributeState(cronLastSeenAtKey);
this.states.contributeState(cronSeededFromStoreKey);
@ -109,8 +102,10 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
);
}
private get tasks(): Map<string, CronTask> {
return this.states.get(cronTasksKey);
private get tasks(): ReadonlyMap<string, CronTask> {
const main = this.agentLifecycle.get('main');
if (main === undefined) return new Map();
return main.accessor.get(IAgentStateService).get(cronKey);
}
private get parsedCache(): Map<string, ParsedCronExpression> {
@ -139,16 +134,10 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
private bindMainAgent(handle: IAgentScopeHandle): void {
const dispatcher = handle.accessor.get(IEventDispatcher);
const agentState = handle.accessor.get(IAgentStateService);
this._register(
dispatcher.hooks.onDidRestore.register('cron', async (_ctx, next) => {
await this.config.ready;
this.resolveClocks();
this.tasks.clear();
for (const [id, task] of agentState.get(cronKey)) {
this.tasks.set(id, task as CronTask);
}
await this.loadFromStore({ replace: false });
await this.start();
await next();
}),
@ -191,26 +180,15 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
...init,
id: this.generateUniqueId(),
createdAt: this.clocks.wallNow(),
tags: { ...init.tags, [CRON_SESSION_TAG]: this.ctx.sessionId },
};
this.tasks.set(task.id, task);
this.dispatchCron(new CronAdd({ task }));
this.persistEnqueue(task.id, () =>
this.store.save(this.ctx.workspaceId, task),
);
return task;
}
removeTasks(ids: readonly string[]): readonly string[] {
const removed = this.removeByIds(ids);
const removed = ids.filter((id) => this.tasks.has(id));
if (removed.length === 0) return removed;
this.dispatchCron(new CronDelete({ ids: removed }));
for (const id of removed) {
this.persistEnqueue(id, () =>
this.store.delete(this.ctx.workspaceId, id),
);
}
return removed;
}
@ -243,29 +221,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
return this.nextFireFor(task);
}
async loadFromStore(options: CronLoadOptions = {}): Promise<void> {
if (options.replace !== false) {
this.tasks.clear();
}
const allTasks = await this.store.list({ workspaceId: this.ctx.workspaceId });
for (const task of allTasks) {
const owner = task.tags?.[CRON_SESSION_TAG];
if (owner !== undefined && owner !== this.ctx.sessionId) continue;
if (owner === undefined) {
const claimed: CronTask = {
...task,
tags: { ...task.tags, [CRON_SESSION_TAG]: this.ctx.sessionId },
};
this.adopt(claimed);
this.persistEnqueue(claimed.id, () =>
this.store.save(this.ctx.workspaceId, claimed),
);
continue;
}
this.adopt(task);
}
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
@ -287,7 +242,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
this.lastSeenAt.clear();
this.seededFromStore.clear();
this.parsedCache.clear();
await this.flushPersist();
this.started = false;
}
@ -380,11 +334,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
}
}
async flushPersist(): Promise<void> {
const inFlight = Array.from(this.persistQueues.values());
await Promise.allSettled(inFlight);
}
handleMissed(
tasks: readonly CronTask[],
renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[],
@ -500,13 +449,8 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
}
private advanceCursor(id: string, lastFiredAt: number): void {
const updated = this.markFired(id, lastFiredAt);
if (updated === undefined) return;
if (!this.tasks.has(id)) return;
this.dispatchCron(new CronCursor({ id, lastFiredAt }));
this.persistEnqueue(id, () =>
this.store.save(this.ctx.workspaceId, updated),
);
}
private dispatchCron(event: CronAdd | CronDelete | CronCursor): void {
@ -614,28 +558,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
}
}
private adopt(task: CronTask): void {
this.tasks.set(task.id, task);
}
private markFired(id: string, lastFiredAt: number): CronTask | undefined {
const existing = this.tasks.get(id);
if (existing === undefined) return undefined;
const updated: CronTask = { ...existing, lastFiredAt };
this.tasks.set(id, updated);
return updated;
}
private removeByIds(ids: readonly string[]): readonly string[] {
const removed: string[] = [];
for (const id of ids) {
if (this.tasks.delete(id)) {
removed.push(id);
}
}
return removed;
}
private generateUniqueId(): string {
for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) {
const candidate = ulid();
@ -654,20 +576,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
return Number.isFinite(age) && age >= STALE_THRESHOLD_MS;
}
private persistEnqueue(id: string, work: () => Promise<void>): void {
const prev = this.persistQueues.get(id) ?? Promise.resolve();
const next = prev
.catch(() => {})
.then(() => work())
.catch(() => {})
.finally(() => {
if (this.persistQueues.get(id) === next) {
this.persistQueues.delete(id);
}
});
this.persistQueues.set(id, next);
}
private bindSigusr1(): void {
if (process.platform === 'win32') return;
if (!this.getCronConfig().manualTick) return;

View file

@ -1,7 +1,6 @@
import { randomUUID } from 'node:crypto';
import { join } from 'pathe';
import { ulid } from 'ulid';
import type { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
@ -15,8 +14,6 @@ import { DEFAULT_PLAN_MODE_SECTION } from '#/features/plan/configSection';
import { IAgentPlanService } from '#/features/plan/plan';
import { LifecycleScope } from '#/app/scopes';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask';
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
import { IConfigService } from '#/app/config/config';
import { IEventService } from '#/app/event/event';
import {
@ -145,7 +142,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
@IAppendLogStore private readonly appendLogStore: IAppendLogStore,
@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore,
@IHostFileSystem private readonly hostFs: IHostFileSystem,
@ICronTaskPersistence private readonly cronStore: ICronTaskPersistence,
@IEventService private readonly event: IEventService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IWorkspaceAgentProfileLoader
@ -559,10 +555,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata),
});
if (turnSlice === undefined) {
await this.duplicateCronTasks(sourceId, targetId);
}
await this.appendSessionIndexEntry(targetId, this.workspaceContext.cwd);
this._onDidForkSession.fire({
sourceSessionId: sourceId,
@ -719,19 +711,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}
}
private async duplicateCronTasks(sourceId: string, targetId: string): Promise<void> {
const tasks = await this.cronStore.list({ workspaceId: this.workspaceId });
for (const task of tasks) {
if (task.tags?.[CRON_SESSION_TAG] !== sourceId) continue;
const clone: CronTask = {
...task,
id: ulid(),
tags: { ...task.tags, [CRON_SESSION_TAG]: targetId },
};
await this.cronStore.save(this.workspaceId, clone);
}
}
private async readMetaFromDisk(sessionId: string): Promise<SessionMeta | undefined> {
return this.docs.get<SessionMeta>(sessionScopeOf(this.handlerScope, sessionId), 'state.json');
}

View file

@ -7,7 +7,6 @@ import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAge
import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
import { IEventService } from '#/app/event/event';
import { IFlagService } from '#/app/flag/flag';
import { IGitService } from '#/app/git/git';
@ -54,7 +53,6 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager {
@IHostEnvironment private readonly environment: IHostEnvironment,
@IAppStateService private readonly appState: IAppStateService,
@IConfigService private readonly config: IConfigService,
@ICronTaskPersistence private readonly cronStore: ICronTaskPersistence,
@IEventService private readonly event: IEventService,
@IFlagService private readonly flags: IFlagService,
@ref(IGitService) private readonly git: LiveRef<IGitService>,
@ -209,7 +207,6 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager {
this.appendLogStore,
this.docs,
input.fs,
this.cronStore,
this.event,
this.telemetry,
input.workspaceAgentProfiles,

View file

@ -25,7 +25,6 @@ export function stubBootstrap(
logs: 'logs',
cache: 'cache',
credentials: 'credentials',
cron: 'cron',
};
return {
_serviceBrand: undefined,

View file

@ -24,8 +24,6 @@ import type { ContextMessage } from '#/agent/contextMemory/types';
import { ISessionCronService } from '#/session/cron/sessionCronService';
import { SessionCronServiceImpl } from '#/session/cron/sessionCronServiceImpl';
import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity';
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
import { CronTaskPersistenceService } from '#/app/cron/cronTaskPersistenceService';
import { IAgentGoalService } from '#/agent/goal/goal';
import { AgentGoalService } from '#/agent/goal/goalService';
import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle';
@ -1176,7 +1174,6 @@ export class AgentTestContext {
ready: Promise.resolve(),
} satisfies IHostEnvironment,
);
reg.defineDescriptor(ICronTaskPersistence, new SyncDescriptor(CronTaskPersistenceService));
},
],
this.serviceOverrides,

View file

@ -94,6 +94,9 @@ const V2_RECORD_TYPES: ReadonlySet<string> = new Set([
'token_counting.measured',
'token_counting.truncated',
'token_counting.rebased',
'cron.add',
'cron.delete',
'cron.cursor',
]);
describe('v1 wire vocabulary', () => {

View file

@ -47,7 +47,6 @@ import '#/state/eventDispatcherService';
import { IAgentTaskService } from '#/agent/task/task';
import { ISessionCronService } from '#/session/cron/sessionCronService';
import { SessionCronServiceImpl } from '#/session/cron/sessionCronServiceImpl';
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
import { CRON_SECTION } from '#/app/cron/configSection';
import { ISessionInteractionService } from '#/session/interaction/interaction';
import { SessionInteractionService } from '#/session/interaction/interactionService';
@ -716,13 +715,6 @@ describe('AgentLifecycleService', () => {
},
{ type: 'interaction.request', id: 'i1', kind: 'question', request: { q: 1 }, time: 3 },
]).store);
ix.stub(ICronTaskPersistence, {
_serviceBrand: undefined,
get: async () => undefined,
list: async () => [],
save: async () => {},
delete: async () => {},
} as ICronTaskPersistence);
ix.stub(IConfigService, {
ready: Promise.resolve(),
get: ((section: unknown) =>

View file

@ -127,11 +127,9 @@ function createToolHarness(options: {
deleted.push(id);
deletedAgentIds.push(agentId);
},
loadFromStore: async () => {},
start: () => Promise.resolve(),
stop: async () => {},
tick: () => Promise.resolve(),
flushPersist: async () => {},
handleMissed: () => undefined,
};

View file

@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import { Emitter, Event } from '#/_base/event';
import type { ServiceIdentifier } from '#/_base/di/instantiation';
import { LifecycleScope } from '#/app/scopes';
import { type IAgentScopeHandle } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { CronCursor } from '#/session/cron/cronOps';
import { ISessionCronService } from '#/session/cron/sessionCronService';
import {
createTestAgent,
InMemoryWireRecordPersistence,
sessionService,
type TestAgentContext,
type TestAgentOptions,
} from '../../harness';
interface CronHarness {
readonly ctx: TestAgentContext;
readonly onDidCreate: Emitter<IAgentScopeHandle>;
}
async function bootCronContext(options: TestAgentOptions = {}): Promise<CronHarness> {
const onDidCreate = new Emitter<IAgentScopeHandle>();
let mainHandle: IAgentScopeHandle | undefined;
const lifecycleStub: IAgentLifecycleService = {
_serviceBrand: undefined,
onDidCreate: onDidCreate.event,
onDidDispose: Event.None as Event<string>,
create: () => Promise.reject(new Error('not supported in this test')),
fork: () => Promise.reject(new Error('not supported in this test')),
get: (agentId) => (agentId === 'main' ? mainHandle : undefined),
list: () => (mainHandle === undefined ? [] : [mainHandle]),
broadcastPermissionMode: () => {},
remove: () => Promise.resolve(),
};
const ctx = createTestAgent(options, sessionService(IAgentLifecycleService, lifecycleStub));
ctx.kimiConfig = {
...ctx.kimiConfig,
cron: { debug: false, noJitter: true, noStale: false, disabled: false, manualTick: true },
};
const accessor = {
get: <T,>(id: ServiceIdentifier<T>): T => ctx.get(id),
};
mainHandle = { id: 'main', kind: LifecycleScope.Agent, accessor, dispose: () => {} };
onDidCreate.fire(mainHandle);
return { ctx, onDidCreate };
}
describe('session cron wire persistence', () => {
it('writes cron ops as durable wire records and rebuilds the task table on replay', async () => {
const persistence = new InMemoryWireRecordPersistence();
const first = await bootCronContext({ persistence });
try {
await first.ctx.restorePersisted();
const cron = first.ctx.get(ISessionCronService);
const task = cron.addTask({ cron: '0 9 * * *', prompt: 'wire me', recurring: true });
await first.ctx.dispatcher.dispatch(new CronCursor({ id: task.id, lastFiredAt: 1234 }));
await first.ctx.dispatcher.flush();
const types = persistence.records.map((record) => record.type);
expect(types).toContain('cron.add');
expect(types).toContain('cron.cursor');
} finally {
await first.ctx.dispose();
first.onDidCreate.dispose();
}
const second = await bootCronContext({
persistence: new InMemoryWireRecordPersistence(persistence.records),
});
try {
await second.ctx.restorePersisted();
const resumed = second.ctx.get(ISessionCronService);
const rebuilt = resumed.list();
expect(rebuilt).toHaveLength(1);
expect(rebuilt[0]).toMatchObject({
cron: '0 9 * * *',
prompt: 'wire me',
recurring: true,
lastFiredAt: 1234,
});
} finally {
await second.ctx.dispose();
second.onDidCreate.dispose();
}
});
it('drops deleted tasks on replay', async () => {
const persistence = new InMemoryWireRecordPersistence();
const first = await bootCronContext({ persistence });
try {
await first.ctx.restorePersisted();
const cron = first.ctx.get(ISessionCronService);
const kept = cron.addTask({ cron: '0 9 * * *', prompt: 'keep', recurring: true });
const dropped = cron.addTask({ cron: '0 10 * * *', prompt: 'drop', recurring: true });
cron.removeTasks([dropped.id]);
await first.ctx.dispatcher.flush();
const types = persistence.records.map((record) => record.type);
expect(types).toContain('cron.delete');
expect(kept.id).not.toBe(dropped.id);
} finally {
await first.ctx.dispose();
first.onDidCreate.dispose();
}
const second = await bootCronContext({
persistence: new InMemoryWireRecordPersistence(persistence.records),
});
try {
await second.ctx.restorePersisted();
const resumed = second.ctx.get(ISessionCronService);
expect(resumed.list().map((task) => task.prompt)).toEqual(['keep']);
} finally {
await second.ctx.dispose();
second.onDidCreate.dispose();
}
});
});

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { sliceMainRecordsAtTurn } from '#/workspace/sessionLifecycle/internal/forkTurnSlice';
import type { WireRecord } from '#/wire/record';
function userTurnRecord(text: string, time: number): WireRecord {
return {
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text }],
origin: { kind: 'user' },
},
time,
};
}
describe('sliceMainRecordsAtTurn', () => {
it('keeps cron records that fall inside a truncated fork slice', () => {
const records: WireRecord[] = [
{ type: 'metadata', protocol_version: '1.5', created_at: 1 },
{
type: 'cron.add',
task: { id: 'aa11bb22', cron: '0 9 * * *', prompt: 'legacy', createdAt: 2 },
time: 2,
},
userTurnRecord('hello', 3),
{ type: 'cron.cursor', id: 'aa11bb22', lastFiredAt: 4, time: 4 },
userTurnRecord('second turn', 5),
{ type: 'cron.add', task: { id: 'bb22cc33', cron: '0 10 * * *', prompt: 'late', createdAt: 6 }, time: 6 },
];
const slice = sliceMainRecordsAtTurn(records, 'ses_source', 0);
const types = slice.records.map((record) => record.type);
expect(types).toContain('cron.add');
expect(types).toContain('cron.cursor');
expect(
slice.records.filter((record) => record.type === 'cron.add'),
).toHaveLength(1);
expect(types).toContain('metadata');
expect(types).toContain('context.append_message');
});
});

View file

@ -139,10 +139,10 @@ function manager(
{ scope: () => 'sessions' },
workspaces,
{ ready },
...Array.from({ length: 22 }, () => undefined),
...Array.from({ length: 21 }, () => undefined),
new TestRuntimeUnitHostFactory(),
];
args[20] = { entries: () => [] };
args[19] = { entries: () => [] };
const value = Reflect.construct(WorkspaceInstanceManager, args) as WorkspaceInstanceManager;
const providers = (value as unknown as { providers: Map<string, RuntimeProviderFactory> }).providers;
providers.clear();

View file

@ -18,6 +18,8 @@ import {
IAgentLifecycleService,
IEventBus,
IEventService,
ISessionCronService,
ISessionManager,
MAIN_AGENT_ID,
closeSessionById,
getLiveSessionById,
@ -913,6 +915,62 @@ describe('server-v2 /api/v1/sessions', () => {
expect(children.body.data.items.some((s) => s.id === forked.body.data.id)).toBe(false);
});
it('fork inherits cron tasks through the copied wire', async () => {
const cwd = home as string;
const parent = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const parentId = parent.body.data.id;
const session = getLiveSessionById((server as RunningServer).core.accessor, parentId);
expect(session).toBeDefined();
await session!.accessor.get(IAgentLifecycleService).create({ agentId: MAIN_AGENT_ID });
const cron = session!.accessor.get(ISessionCronService);
const task = cron.addTask({ cron: '0 9 * * *', prompt: 'fork me', recurring: true });
const forked = await postJson<SessionWire>(`/api/v1/sessions/${parentId}:fork`, {});
expect(forked.body.code).toBe(0);
const forkedSession = getLiveSessionById(
(server as RunningServer).core.accessor,
forked.body.data.id,
);
expect(forkedSession).toBeDefined();
const forkedCron = forkedSession!.accessor.get(ISessionCronService);
expect(forkedCron.list().map((t) => ({ id: t.id, prompt: t.prompt }))).toEqual([
{ id: task.id, prompt: 'fork me' },
]);
});
it('keeps cron tasks across a server restart through the wire', async () => {
const cwd = home as string;
const parent = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const parentId = parent.body.data.id;
const session = getLiveSessionById((server as RunningServer).core.accessor, parentId);
expect(session).toBeDefined();
await session!.accessor.get(IAgentLifecycleService).create({ agentId: MAIN_AGENT_ID });
const task = session!.accessor
.get(ISessionCronService)
.addTask({ cron: '0 9 * * *', prompt: 'restart me', recurring: true });
await (server as RunningServer).close();
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
debugEndpoints: true,
});
base = `http://127.0.0.1:${server.port}`;
const resumed = await (server as RunningServer).core.accessor
.get(ISessionManager)
.resume(parentId);
expect(resumed).toBeDefined();
const cron = resumed!.accessor.get(ISessionCronService);
expect(cron.list().map((t) => ({ id: t.id, prompt: t.prompt }))).toEqual([
{ id: task.id, prompt: 'restart me' },
]);
});
it('returns 40401 when listing children of a missing parent', async () => {
const { body } = await getJson<null>('/api/v1/sessions/sess_missing_parent/children');
expect(body.code).toBe(40401);