Skip to content

Commit 6091f4e

Browse files
committed
fix(agent-core-v2): stop cron ticks after shutdown
1 parent d201c74 commit 6091f4e

4 files changed

Lines changed: 79 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Prevent cron ticks from continuing after an agent shuts down.

packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,12 @@ function configOf(runtime: AgentRuntimeContext<CronModelState>): IConfigService
6868
return runtime.get(IConfigService);
6969
}
7070

71+
function readCronConfig(config: IConfigService): CronConfig {
72+
return config.get<CronConfig>(CRON_SECTION) ?? DEFAULT_CRON_CONFIG;
73+
}
74+
7175
function cronConfigOf(runtime: AgentRuntimeContext<CronModelState>): CronConfig {
72-
return configOf(runtime).get<CronConfig>(CRON_SECTION) ?? DEFAULT_CRON_CONFIG;
76+
return readCronConfig(configOf(runtime));
7377
}
7478

7579
function clocksOf(runtime: AgentRuntimeContext<CronModelState>): ClockSources {
@@ -265,9 +269,12 @@ async function processDue(
265269
async function tickCron(
266270
runtime: AgentRuntimeContext<CronModelState>,
267271
state: CronEffectState,
272+
config: IConfigService,
273+
isDisposed: () => boolean,
268274
): Promise<void> {
269-
await configOf(runtime).ready;
270-
if (cronConfigOf(runtime).disabled || runtime.getState().size === 0) return;
275+
await config.ready;
276+
if (isDisposed()) return;
277+
if (readCronConfig(config).disabled || runtime.getState().size === 0) return;
271278
if (runtime.get(IAgentLoopService).status().state === 'running') return;
272279
const now = state.clocks.wallNow();
273280
await Promise.all([...runtime.getState().values()].map((task) => processDue(runtime, state, task, now)));
@@ -286,6 +293,7 @@ const cronEffects = fromCallback(({
286293
sendBack: (event: CronActorEvent) => void;
287294
}) => {
288295
if (input.runtime.agent.agentId !== MAIN_AGENT_ID) return;
296+
const config = configOf(input.runtime);
289297
const timer = new IntervalTimer({ unref: true });
290298
const state: CronEffectState = {
291299
clocks: SYSTEM_CLOCKS,
@@ -297,18 +305,22 @@ const cronEffects = fromCallback(({
297305
let disposed = false;
298306
let signalHandler: NodeJS.SignalsListener | undefined;
299307
receive((event) => {
300-
void tickCron(input.runtime, state).then(event.resolve, event.reject);
308+
if (disposed) {
309+
event.resolve?.();
310+
return;
311+
}
312+
void tickCron(input.runtime, state, config, () => disposed).then(event.resolve, event.reject);
301313
});
302-
input.restore.waitUntil(configOf(input.runtime).ready.then(() => {
314+
input.restore.waitUntil(config.ready.then(() => {
303315
if (disposed) return;
304-
const config = cronConfigOf(input.runtime);
305-
state.clocks = resolveClockSources(config.clock, config.debug) ?? SYSTEM_CLOCKS;
306-
const poll = config.manualTick ? null : config.pollIntervalMs;
316+
const current = readCronConfig(config);
317+
state.clocks = resolveClockSources(current.clock, current.debug) ?? SYSTEM_CLOCKS;
318+
const poll = current.manualTick ? null : current.pollIntervalMs;
307319
const interval = poll === undefined ? DEFAULT_POLL_INTERVAL_MS : poll;
308320
if (interval !== null && interval !== 0) {
309321
timer.cancelAndSet(() => { sendBack({ type: 'cron.tick' }); }, interval);
310322
}
311-
if (process.platform !== 'win32' && config.manualTick) {
323+
if (process.platform !== 'win32' && current.manualTick) {
312324
signalHandler = () => { sendBack({ type: 'cron.tick' }); };
313325
process.on('SIGUSR1', signalHandler);
314326
}

packages/agent-core-v2/test/features/cron/sessionCron.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { describe, expect, it } from 'vitest';
22

3+
import { DisposableStore } from '#/_base/di/lifecycle';
4+
import { createServices } from '#/_base/di/test';
5+
import type { AgentContext } from '#/agent/agentContext/agentContext';
6+
import { AgentRuntimeSet } from '#/agent/runtime/agentRuntimeSet';
37
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
4-
import { AgentCron } from '#/features/cron/cronAgentRuntime';
8+
import { IConfigService } from '#/app/config/config';
9+
import { DEFAULT_CRON_CONFIG } from '#/features/cron/configSection';
10+
import { AgentCron, cronAgentRuntimeProvider } from '#/features/cron/cronAgentRuntime';
511
import { CronCursor } from '#/features/cron/cronOps';
612

713
import {
@@ -21,6 +27,51 @@ async function bootCronContext(options: TestAgentOptions = {}): Promise<TestAgen
2127
}
2228

2329
describe('session cron wire persistence', () => {
30+
it('settles an in-flight tick without reading services after close', async () => {
31+
let releaseReady!: () => void;
32+
const ready = new Promise<void>((resolve) => { releaseReady = resolve; });
33+
let markTickStarted!: () => void;
34+
const tickStarted = new Promise<void>((resolve) => { markTickStarted = resolve; });
35+
let readyReads = 0;
36+
let closed = false;
37+
const disposables = new DisposableStore();
38+
const services = createServices(disposables, {
39+
additionalServices: (reg) => {
40+
reg.definePartialInstance(IConfigService, {
41+
get ready() {
42+
readyReads += 1;
43+
if (readyReads === 2) markTickStarted();
44+
return ready;
45+
},
46+
get: <T>() => {
47+
if (closed) throw new Error('config read after close');
48+
return DEFAULT_CRON_CONFIG as T;
49+
},
50+
});
51+
},
52+
});
53+
const agent = { agentId: 'main', generation: 1, space: {} } as AgentContext;
54+
const runtimes = new AgentRuntimeSet(agent, services);
55+
runtimes.apply({
56+
definition: AgentCron,
57+
provider: cronAgentRuntimeProvider,
58+
generation: 1,
59+
active: true,
60+
});
61+
runtimes.attachDurable({ attach: () => ({ dispose: () => {} }) });
62+
63+
const restoring = runtimes.restore();
64+
const ticking = runtimes.resolve(AgentCron).tick();
65+
await tickStarted;
66+
await runtimes.close();
67+
closed = true;
68+
disposables.dispose();
69+
releaseReady();
70+
71+
await expect(ticking).resolves.toBeUndefined();
72+
await expect(restoring).resolves.toBeUndefined();
73+
});
74+
2475
it('writes cron ops as durable wire records and rebuilds the task table on replay', async () => {
2576
const persistence = new InMemoryWireRecordPersistence();
2677
const first = await bootCronContext({ persistence });

packages/agent-core-v2/test/features/tower/store.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ describe('merge gate', () => {
754754

755755
const after = await store.merge(third!.branch);
756756
expect(after.conflictsWith.map((c) => c.branch)).not.toContain(second!.branch);
757-
});
757+
}, 15_000);
758758
});
759759

760760
describe('updateMission', () => {

0 commit comments

Comments
 (0)