fix(loop): let the first wakeup arm before the scheduler starts

Critical (wenshao, #5197): LoopWakeup hard-rejected when
`scheduler.running` was false, but on the first self-paced /loop in a
session with no cron jobs the scheduler hasn't started yet
(#startCronSchedulerIfNeeded bails on !hasPendingWork). The post-prompt
hook starts the tick *after* the turn, once a wakeup exists — so the
guard rejected the very call that makes the loop possible, breaking the
primary use case.

The `running` check was a proxy for "cron is alive", added to reject
re-arms after the token-limit breaker. Replace it with an explicit,
permanent `disabled` state so the two cases are distinguishable:

- CronScheduler gains `disabled` + `disable()` (sets the flag, stops).
- LoopWakeup rejects only when `scheduler.disabled`, not when merely
  stopped — a stopped-but-restartable scheduler still accepts wakeups.
- The token-limit breaker calls `disable()` instead of `stop()`, so its
  rejection (the original intent) is preserved.

Also attribute cron-prompt errors by source: `[loop error]` vs
`[cron error]` (item.source was already in scope).

Tests: reject-when-disabled, schedule-when-stopped (the regression),
and a disable() unit test.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
qqqys 2026-06-17 14:26:30 +08:00
parent bea5d51c3d
commit 7f488e6c6c
5 changed files with 69 additions and 8 deletions

View file

@ -2184,7 +2184,9 @@ export class Session implements SessionContext {
debugLogger.error('Error processing cron prompt:', error);
const msg =
error instanceof Error ? error.message : String(error);
await this.messageEmitter.emitAgentMessage(`[cron error] ${msg}`);
await this.messageEmitter.emitAgentMessage(
`[${item.source} error] ${msg}`,
);
} finally {
if (this.cronAbortController === ac) {
this.cronAbortController = null;
@ -2212,7 +2214,10 @@ export class Session implements SessionContext {
this.cronDisabledByTokenLimit = true;
this.cronQueue = [];
if (!this.config.isCronEnabled()) return;
this.config.getCronScheduler().stop();
// disable() (not stop()): the breaker is permanent for the session, so
// LoopWakeup must reject re-arms that would never fire, not just halt the
// tick (which a later pending wakeup would otherwise silently restart).
this.config.getCronScheduler().disable();
void this.#emitAgentDiagnosticMessageSafely(
'Cron jobs and loop wakeups disabled for the rest of this session due to token limit. Restart the session to re-enable.',
'Failed to emit cron-disabled diagnostic',

View file

@ -483,6 +483,19 @@ describe('CronScheduler', () => {
).not.toThrow();
});
it('disable() marks the scheduler disabled and stops the tick', () => {
scheduler.start(() => {});
expect(scheduler.disabled).toBe(false);
expect(scheduler.running).toBe(true);
scheduler.disable();
// Disabled is a distinct, permanent state — a plain stop() leaves it
// restartable, but disable() bars re-arming for the session.
expect(scheduler.disabled).toBe(true);
expect(scheduler.running).toBe(false);
});
it('keeps second precision (does not round to the minute)', () => {
// 90s would round up to 2 min under the old cron path; the timer is exact.
const w = scheduler.scheduleWakeup(90, 'p');

View file

@ -194,6 +194,10 @@ export class CronScheduler {
// an empty map would restart the clock every fire and let a continuous
// loop escape the cap. Reset only by stop()/destroy() (a new session).
private wakeupChainStartedAt: number | null = null;
// Set once disable() runs (the session's token-limit breaker). Permanent
// for this scheduler's lifetime — distinct from a stopped-but-restartable
// timer, so LoopWakeup can reject wakeups that would never fire.
private _disabled = false;
private timer: ReturnType<typeof setInterval> | null = null;
private onFire: ((job: CronJob) => void) | null = null;
@ -950,6 +954,26 @@ export class CronScheduler {
return this.timer !== null;
}
/**
* True once disable() has run. Distinct from `!running`: a fresh scheduler
* is stopped but not disabled, and starts on first pending work. Used by
* LoopWakeup to reject wakeups that would never fire (vs. ones that will
* fire once the post-prompt hook starts the tick).
*/
get disabled(): boolean {
return this._disabled;
}
/**
* Permanently disables the scheduler for this session: stops the tick and
* marks it disabled so LoopWakeup rejects new wakeups. Only the token-limit
* breaker calls this; cleared only by a new session (a fresh instance).
*/
disable(): void {
this._disabled = true;
this.stop();
}
/**
* Manual tick checks all jobs against the current time and fires those
* that are due. Exported for testing.

View file

@ -102,8 +102,8 @@ describe('LoopWakeupTool', () => {
expect(scheduler.size).toBe(0);
});
it('rejects scheduling when the scheduler is stopped', async () => {
scheduler.stop();
it('rejects scheduling when the scheduler is disabled', async () => {
scheduler.disable();
const invocation = tool.build({
delaySeconds: 300,
prompt: 'continue loop',
@ -112,11 +112,29 @@ describe('LoopWakeupTool', () => {
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.message).toBe(
'Loop wakeups cannot be scheduled because the scheduler is stopped.',
'Loop wakeups are disabled for the rest of this session ' +
'(token limit reached). Restart the session to re-enable.',
);
expect(scheduler.sessionSize).toBe(0);
});
it('schedules even when the scheduler is stopped but not disabled', async () => {
// The first self-paced /loop in a session with no cron jobs arms a
// wakeup before the scheduler has started — the post-prompt hook starts
// the tick afterwards. A merely-stopped scheduler must not reject.
scheduler.stop();
const invocation = tool.build({
delaySeconds: 300,
prompt: 'continue loop',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Scheduled loop wakeup');
expect(scheduler.sessionSize).toBe(1);
});
it('tells the model to re-arm to keep the loop alive', async () => {
const invocation = tool.build({
delaySeconds: 300,
@ -224,7 +242,7 @@ describe('LoopWakeupTool', () => {
it('surfaces a scheduler failure as a structured tool error', async () => {
const failingConfig = {
getCronScheduler: () => ({
running: true,
disabled: false,
scheduleWakeup: () => {
throw new Error('scheduler boom', {
cause: new Error('clock unavailable'),

View file

@ -72,9 +72,10 @@ class LoopWakeupInvocation extends BaseToolInvocation<
try {
const scheduler = this.config.getCronScheduler();
if (!scheduler.running) {
if (scheduler.disabled) {
const message =
'Loop wakeups cannot be scheduled because the scheduler is stopped.';
'Loop wakeups are disabled for the rest of this session ' +
'(token limit reached). Restart the session to re-enable.';
return {
llmContent: message,
returnDisplay: message,