mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
bea5d51c3d
commit
7f488e6c6c
5 changed files with 69 additions and 8 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue