You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/10/09 12:45:30 UTC

(maka) branch main updated: fix(runtime-host): retry scheduled notifications independently of due times (#5975)

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new 93147734db fix(runtime-host): retry scheduled notifications independently of due times (#5975)
93147734db is described below

commit 93147734db54bd8b350caa0f6827266bc206140b
Author: yum <ya...@chihum.dev>
AuthorDate: Fri Oct 9 20:45:25 2026 +0800

    fix(runtime-host): retry scheduled notifications independently of due times (#5975)
    
    Generated-by: Codex
    fix(runtime-host): stop delivering waiting notifications after expiry
    
    A notification fire waiting for a Desktop provider kept its claim after
    its task expired. The scheduler then polled every five seconds for as
    long as no provider appeared, and a provider that connected later, or a
    Host recovering the claim, still delivered the reminder after expiresAt.
    Expiry already closes due-time and Trigger Now admission, so a native
    fire that has not crossed delivery admission now settles as blocked once
    its task expires, which also ends the poll. A once task completes with
    that blocked run, as an incognito-blocked fire does; a recurring task
    ends expired.
    
    Trigger Now refreshes the retry schedule after persisting a waiting
    claim. A Store read failure there replaced the provider-unavailable
    outcome with a generic persistence failure. The refresh failure now
    drains the Host, whose recovery retries the durable claim, and the
    caller still receives the provider-unavailable outcome.
    
    Tests cover expiry of once and interval notifications, recovery after
    expiry, the Trigger Now refresh failure, and the next slot of a
    recurring notification delivered late.
    test(runtime-host): pin expiry edits replacing a waiting notification
    
    The expiry check for a waiting native fire reads the task snapshot held
    by its claim. That snapshot cannot hold a stale expiresAt: the Store
    rejects every task edit while a claim exists, and an update first
    cancels a fire that is still waiting for a provider. The edited task is
    then claimed again with the new expiry.
    
    Cover shortened, added, and extended expiry during a provider wait. A
    shortened or added expiry blocks the next claim at the new deadline
    without a late delivery. An extended expiry delivers once a provider
    connects after the original deadline. With both edit guards removed,
    all three cases fail because the old claim keeps its stale snapshot.
    test(runtime-host): cover recurring trigger and bot notification waits
    
    Trigger Now on an interval notification without a provider now has
    coverage. The caller gets the provider-unavailable outcome. The claim
    retries every five seconds while its wait spans a regular slot, which
    adds no second claim. After one delivery the task resumes at the next
    regular slot without catching up.
    
    A due bot notification waits for a provider like a local one and is
    then delivered once through notify_bot with its body, platform, and
    chat. A stored bot task whose platform has no bot delivery, which
    create rejects, is blocked at its due time instead of waiting, and a
    provider that connects later delivers nothing.
---
 .../scheduled-task-coordinator-retry.test.ts       | 595 +++++++++++++++++++++
 .../src/server/scheduled-task-coordinator.ts       |  24 +-
 2 files changed, 616 insertions(+), 3 deletions(-)

diff --git a/packages/runtime-host/src/__tests__/scheduled-task-coordinator-retry.test.ts b/packages/runtime-host/src/__tests__/scheduled-task-coordinator-retry.test.ts
new file mode 100644
index 0000000000..63cae8bccb
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/scheduled-task-coordinator-retry.test.ts
@@ -0,0 +1,595 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import type { CreateScheduledTaskInput } from '@maka/core/scheduled-task';
+import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store';
+import { openInteractiveScheduledTaskStoreForWrite } from '@maka/storage/scheduled-task-store';
+import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
+import type { ScheduledTaskMutateInput } from '../protocol/index.js';
+import {
+  HostScheduledTaskCoordinator,
+  type HostScheduledTaskCoordinatorInput,
+} from '../server/scheduled-task-coordinator.js';
+
+const PROVIDER_UNAVAILABLE = {
+  ok: false,
+  error: {
+    code: 'operation_conflict',
+    message: 'ScheduledTask native delivery is waiting for a Desktop provider',
+  },
+} as const;
+const EXPIRED_BEFORE_DELIVERY = '定时任务已过期,通知没有送达。';
+const CORRUPT_TASK_ID = 'corrupt-scheduled-task-row';
+
+async function schedulerFixture() {
+  const base = await mkdtemp(join(tmpdir(), 'maka-scheduled-task-retry-'));
+  const root = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' });
+  const owner = await tryAcquireInteractiveRootOwner(root);
+  assert.ok(owner);
+  const store = await openInteractiveScheduledTaskStoreForWrite(owner.lease);
+  let now = 1_000;
+  let providerAvailable = false;
+  let afterProviderCheck: (() => void) | undefined;
+  let drainAllowed = false;
+  let drainRequests = 0;
+  let timer: { callback: () => void; delayMs: number } | undefined;
+  const deliveries: Record<string, unknown>[] = [];
+  const deliveryMethods: string[] = [];
+  const writeDatabase = (sql: string, taskId: string) => {
+    const lease = acquireOperationalStateDatabase(root.canonicalPath);
+    try {
+      lease.transaction('write', () => lease.database.prepare(sql).run(taskId));
+    } finally {
+      lease.close();
+    }
+  };
+  const input: HostScheduledTaskCoordinatorInput = {
+    store,
+    sessions: null as never,
+    runtime: null as never,
+    root: null as never,
+    runtimePolicy: {
+      runtimePolicy: {
+        getSnapshot: async () => ({ policy: { privacy: { incognitoActive: false } } }),
+      },
+    } as never,
+    nativeEffects: {
+      hasWorkspaceService: () => {
+        const hook = afterProviderCheck;
+        afterProviderCheck = undefined;
+        hook?.();
+        return providerAvailable;
+      },
+      callWorkspaceService: async (request) => {
+        deliveries.push(request.input);
+        deliveryMethods.push(request.method);
+        return {};
+      },
+    },
+    createSession: async () => assert.fail('Notification must not create a Session'),
+    changes: { publish: () => {} },
+    acquireResidency: () => ({ release: () => {} }),
+    requestDrain: () => {
+      drainRequests += 1;
+      if (!drainAllowed) assert.fail('Scheduler must not drain');
+      coordinator.beginDrain();
+    },
+    now: () => now,
+    setTimeout: (callback, delayMs) => {
+      timer = { callback, delayMs };
+      return timer;
+    },
+    clearTimeout: (handle) => {
+      if (handle === timer) timer = undefined;
+    },
+  };
+  let coordinator = new HostScheduledTaskCoordinator(input);
+  await coordinator.prepareRecovery();
+  const mutate = (command: ScheduledTaskMutateInput) =>
+    coordinator.handlers['scheduled-task.mutate'](command, {} as never);
+  return {
+    store,
+    deliveries,
+    deliveryMethods,
+    mutate,
+    setNow: (at: number) => {
+      now = at;
+    },
+    connectProvider: () => {
+      providerAvailable = true;
+    },
+    allowDrain: () => {
+      drainAllowed = true;
+    },
+    drainRequests: () => drainRequests,
+    /** The waiting claim still persists, but catalog reads fail after the next provider check. */
+    corruptCatalogAfterProviderCheck: () => {
+      afterProviderCheck = () =>
+        writeDatabase(
+          `INSERT INTO workflow_scheduled_tasks(task_id, created_at, updated_at, record_json)
+           VALUES (?, 0, 0, '{}')`,
+          CORRUPT_TASK_ID,
+        );
+    },
+    repairCatalog: () =>
+      writeDatabase('DELETE FROM workflow_scheduled_tasks WHERE task_id = ?', CORRUPT_TASK_ID),
+    /** Create rejects platforms without bot delivery, so only a stored row can hold one. */
+    storeBotPlatform: (taskId: string, platform: 'feishu' | 'wecom') =>
+      writeDatabase(
+        `UPDATE workflow_scheduled_tasks
+         SET record_json = json_set(record_json, '$.effect.platform', '${platform}')
+         WHERE task_id = ?`,
+        taskId,
+      ),
+    timerDelay: () => timer?.delayMs,
+    async create(patch: Partial<CreateScheduledTaskInput> = {}) {
+      const outcome = await mutate({
+        kind: 'create',
+        input: {
+          title: 'Reminder',
+          intentBody: 'Review the report',
+          schedule: { kind: 'once', runAt: 11_000 },
+          effect: { kind: 'notify', channel: 'local' },
+          ...patch,
+        },
+      });
+      assert.equal(outcome.ok, true);
+      if (!outcome.ok || outcome.result.kind !== 'task') throw new Error('Task creation failed');
+      return outcome.result.task;
+    },
+    async start() {
+      coordinator.start();
+      // The public catalog query waits behind the scheduler's current lane.
+      await coordinator.list();
+    },
+    async tick(at: number) {
+      now = at;
+      assert.ok(timer, 'Expected a scheduled timer');
+      const callback = timer.callback;
+      timer = undefined;
+      callback();
+      await coordinator.list();
+    },
+    async restart() {
+      await coordinator.close();
+      coordinator = new HostScheduledTaskCoordinator(input);
+      await coordinator.prepareRecovery();
+      await coordinator.recover();
+      coordinator.start();
+      await coordinator.list();
+    },
+    async close() {
+      await coordinator.close();
+      store.close();
+      await owner.close();
+      await rm(base, { recursive: true, force: true });
+    },
+  };
+}
+
+test('due notifications wait five seconds for a provider and deliver once after reconnect', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const task = await fixture.create();
+    await fixture.start();
+    assert.equal(fixture.timerDelay(), 10_000);
+    await fixture.tick(11_000);
+    assert.equal((await fixture.store.listPendingFires())[0]?.nativeState, 'waiting_for_provider');
+    assert.equal(fixture.timerDelay(), 5_000);
+    await fixture.tick(16_000);
+    assert.equal(fixture.timerDelay(), 5_000);
+    fixture.connectProvider();
+    await fixture.tick(21_000);
+    assert.deepEqual(fixture.deliveries, [{ taskId: task.id, title: 'Reminder' }]);
+    const completed = await fixture.store.get(task.id);
+    assert.equal(completed?.status, 'completed');
+    assert.equal(completed?.fireCount, 1);
+    assert.equal(completed?.runs[0]?.outcome, 'ok');
+    assert.equal(fixture.timerDelay(), undefined);
+    await fixture.restart();
+    assert.equal(fixture.deliveries.length, 1);
+  } finally {
+    await fixture.close();
+  }
+});
+
+for (const triggerAt of [1_000, 3_601_000]) {
+  test(`manual notification at ${triggerAt} schedules provider retry independently of its due time`, async () => {
+    const fixture = await schedulerFixture();
+    try {
+      const task = await fixture.create({ schedule: { kind: 'once', runAt: 3_601_000 } });
+      await fixture.start();
+      assert.equal(fixture.timerDelay(), 3_600_000);
+      fixture.setNow(triggerAt);
+      const outcome = await fixture.mutate({ kind: 'trigger_now', taskId: task.id });
+      assert.deepEqual(outcome, PROVIDER_UNAVAILABLE);
+      assert.equal(
+        (await fixture.store.listPendingFires())[0]?.nativeState,
+        'waiting_for_provider',
+      );
+      assert.equal(fixture.timerDelay(), 5_000);
+      fixture.connectProvider();
+      await fixture.tick(triggerAt + 5_000);
+      assert.deepEqual(fixture.deliveries, [{ taskId: task.id, title: 'Reminder' }]);
+      assert.equal((await fixture.store.get(task.id))?.fireCount, 1);
+      assert.equal(fixture.timerDelay(), undefined);
+    } finally {
+      await fixture.close();
+    }
+  });
+}
+
+test('manual trigger of a recurring notification waits, then resumes at its next regular slot', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const task = await fixture.create({
+      schedule: { kind: 'interval', everySeconds: 60, startAt: 61_000 },
+    });
+    await fixture.start();
+    assert.equal(fixture.timerDelay(), 60_000);
+    fixture.setNow(21_000);
+    assert.deepEqual(
+      await fixture.mutate({ kind: 'trigger_now', taskId: task.id }),
+      PROVIDER_UNAVAILABLE,
+    );
+    // The wait spans the regular 61_000 slot, which neither adds a claim nor
+    // shortens the provider retry.
+    for (let at = 26_000; at <= 66_000; at += 5_000) {
+      assert.equal(fixture.timerDelay(), 5_000);
+      await fixture.tick(at);
+      assert.deepEqual(
+        (await fixture.store.listPendingFires()).map((claim) => [
+          claim.scheduledFor,
+          claim.nativeState,
+        ]),
+        [[21_000, 'waiting_for_provider']],
+      );
+    }
+    fixture.connectProvider();
+    await fixture.tick(71_000);
+    assert.deepEqual(fixture.deliveries, [{ taskId: task.id, title: 'Reminder' }]);
+    const delivered = await fixture.store.get(task.id);
+    assert.equal(delivered?.status, 'active');
+    assert.equal(delivered?.fireCount, 1);
+    assert.equal(delivered?.runs[0]?.outcome, 'ok');
+    assert.equal(delivered?.lastFireAt, 71_000);
+    assert.equal(delivered?.nextFireAt, 121_000);
+    assert.deepEqual(await fixture.store.listPendingFires(), []);
+    assert.equal(fixture.timerDelay(), 50_000);
+  } finally {
+    await fixture.close();
+  }
+});
+
+test('due bot notifications wait for a provider and deliver once through the bot method', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const task = await fixture.create({
+      effect: { kind: 'notify', channel: 'bot', platform: 'telegram', chatId: 'chat-1' },
+    });
+    await fixture.start();
+    await fixture.tick(11_000);
+    assert.equal((await fixture.store.listPendingFires())[0]?.nativeState, 'waiting_for_provider');
+    assert.equal(fixture.timerDelay(), 5_000);
+    await fixture.tick(16_000);
+    assert.equal(fixture.timerDelay(), 5_000);
+    fixture.connectProvider();
+    await fixture.tick(21_000);
+    assert.deepEqual(fixture.deliveryMethods, ['notify_bot']);
+    assert.deepEqual(fixture.deliveries, [
+      {
+        taskId: task.id,
+        title: 'Reminder',
+        body: 'Review the report',
+        platform: 'telegram',
+        chatId: 'chat-1',
+      },
+    ]);
+    const completed = await fixture.store.get(task.id);
+    assert.equal(completed?.status, 'completed');
+    assert.equal(completed?.runs[0]?.outcome, 'ok');
+    assert.equal(completed?.runs[0]?.message, '已投递到 Telegram。');
+    assert.equal(fixture.timerDelay(), undefined);
+    await fixture.restart();
+    assert.equal(fixture.deliveries.length, 1);
+  } finally {
+    await fixture.close();
+  }
+});
+
+test('a stored bot notification for a non-delivery platform is blocked instead of waiting', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const task = await fixture.create({
+      effect: { kind: 'notify', channel: 'bot', platform: 'telegram', chatId: 'chat-1' },
+    });
+    fixture.storeBotPlatform(task.id, 'feishu');
+    await fixture.start();
+    await fixture.tick(11_000);
+    const blocked = await fixture.store.get(task.id);
+    assert.equal(blocked?.status, 'completed');
+    assert.deepEqual(
+      blocked?.runs.map(({ outcome, message }) => ({ outcome, message })),
+      [{ outcome: 'blocked', message: '飞书 当前不是可投递目标。' }],
+    );
+    assert.deepEqual(await fixture.store.listPendingFires(), []);
+    assert.equal(fixture.timerDelay(), undefined);
+    fixture.connectProvider();
+    await fixture.restart();
+    assert.deepEqual(fixture.deliveries, []);
+  } finally {
+    await fixture.close();
+  }
+});
+
+test('recovery retries a persisted waiting fire before its original future due time', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const task = await fixture.create({ schedule: { kind: 'once', runAt: 3_601_000 } });
+    await fixture.start();
+    await fixture.mutate({ kind: 'trigger_now', taskId: task.id });
+    await fixture.restart();
+    assert.equal(fixture.timerDelay(), 5_000);
+    fixture.connectProvider();
+    await fixture.tick(6_000);
+    assert.deepEqual(fixture.deliveries, [{ taskId: task.id, title: 'Reminder' }]);
+    assert.equal((await fixture.store.get(task.id))?.runs[0]?.outcome, 'ok');
+    await fixture.restart();
+    assert.equal(fixture.deliveries.length, 1);
+  } finally {
+    await fixture.close();
+  }
+});
+
+for (const kind of ['pause', 'delete'] as const) {
+  test(`${kind} cancels a waiting fire without cancelling another notification`, async () => {
+    const fixture = await schedulerFixture();
+    try {
+      const waiting = await fixture.create();
+      const later = await fixture.create({
+        title: 'Later reminder',
+        schedule: { kind: 'once', runAt: 31_000 },
+      });
+      await fixture.start();
+      await fixture.tick(11_000);
+      const outcome = await fixture.mutate({ kind, taskId: waiting.id });
+      assert.equal(outcome.ok, true);
+      assert.equal(fixture.timerDelay(), 20_000);
+      assert.equal((await fixture.store.listPendingFires()).length, 0);
+      fixture.connectProvider();
+      await fixture.tick(31_000);
+      assert.deepEqual(fixture.deliveries, [{ taskId: later.id, title: 'Later reminder' }]);
+      assert.equal((await fixture.store.get(later.id))?.status, 'completed');
+      assert.equal(
+        (await fixture.store.get(waiting.id))?.status,
+        kind === 'pause' ? 'paused' : undefined,
+      );
+    } finally {
+      await fixture.close();
+    }
+  });
+}
+
+test('a waiting fire does not delay another task with an earlier deadline', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const first = await fixture.create();
+    const second = await fixture.create({
+      title: 'Second reminder',
+      schedule: { kind: 'once', runAt: 13_000 },
+    });
+    await fixture.start();
+    await fixture.tick(11_000);
+    assert.equal(fixture.timerDelay(), 2_000);
+    await fixture.tick(13_000);
+    assert.deepEqual(
+      (await fixture.store.listPendingFires()).map((claim) => claim.task.id).sort(),
+      [first.id, second.id].sort(),
+    );
+    assert.equal(fixture.timerDelay(), 5_000);
+    fixture.connectProvider();
+    await fixture.tick(18_000);
+    assert.deepEqual(
+      fixture.deliveries.map((delivery) => delivery.taskId).sort(),
+      [first.id, second.id].sort(),
+    );
+    assert.equal((await fixture.store.get(first.id))?.fireCount, 1);
+    assert.equal((await fixture.store.get(second.id))?.fireCount, 1);
+  } finally {
+    await fixture.close();
+  }
+});
+
+test('trigger now reports the waiting provider even when its retry schedule cannot be read', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const task = await fixture.create({ schedule: { kind: 'once', runAt: 3_601_000 } });
+    await fixture.start();
+    fixture.allowDrain();
+    fixture.corruptCatalogAfterProviderCheck();
+    const outcome = await fixture.mutate({ kind: 'trigger_now', taskId: task.id });
+    assert.deepEqual(outcome, PROVIDER_UNAVAILABLE);
+    // The Store failure drains this Host. Recovery in the next one retries the
+    // durable claim, so the reminder is neither lost nor delivered twice.
+    assert.equal(fixture.drainRequests(), 1);
+    fixture.repairCatalog();
+    assert.equal((await fixture.store.listPendingFires())[0]?.nativeState, 'waiting_for_provider');
+    await fixture.restart();
+    assert.equal(fixture.timerDelay(), 5_000);
+    fixture.connectProvider();
+    await fixture.tick(6_000);
+    assert.deepEqual(fixture.deliveries, [{ taskId: task.id, title: 'Reminder' }]);
+    assert.equal((await fixture.store.get(task.id))?.fireCount, 1);
+    assert.equal(fixture.drainRequests(), 1);
+  } finally {
+    await fixture.close();
+  }
+});
+
+for (const schedule of [
+  { kind: 'once', runAt: 11_000 },
+  { kind: 'interval', everySeconds: 10, startAt: 11_000 },
+] as const) {
+  test(`${schedule.kind} notification still waiting at expiry is blocked, not delivered late`, async () => {
+    const fixture = await schedulerFixture();
+    try {
+      const task = await fixture.create({ schedule, expiresAt: 12_000 });
+      await fixture.start();
+      await fixture.tick(11_000);
+      assert.equal(fixture.timerDelay(), 1_000);
+      await fixture.tick(12_000);
+      const blocked = await fixture.store.get(task.id);
+      // Settling consumes the fire: a one-shot task completes, a recurring one
+      // stays expired. Either way the poll stops and nothing is delivered.
+      assert.equal(blocked?.status, schedule.kind === 'once' ? 'completed' : 'expired');
+      assert.equal(blocked?.nextFireAt, null);
+      assert.equal(blocked?.fireCount, 1);
+      assert.deepEqual(
+        blocked?.runs.map(({ at, outcome, message }) => ({ at, outcome, message })),
+        [{ at: 12_000, outcome: 'blocked', message: EXPIRED_BEFORE_DELIVERY }],
+      );
+      assert.deepEqual(await fixture.store.listPendingFires(), []);
+      assert.equal(fixture.timerDelay(), undefined);
+      fixture.connectProvider();
+      await fixture.restart();
+      assert.deepEqual(fixture.deliveries, []);
+      assert.equal(fixture.timerDelay(), undefined);
+    } finally {
+      await fixture.close();
+    }
+  });
+}
+
+test('recovery blocks a waiting notification whose task expired while the Host was stopped', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const task = await fixture.create({ expiresAt: 12_000 });
+    await fixture.start();
+    await fixture.tick(11_000);
+    assert.equal((await fixture.store.listPendingFires())[0]?.nativeState, 'waiting_for_provider');
+    fixture.setNow(60_000);
+    fixture.connectProvider();
+    await fixture.restart();
+    assert.deepEqual(fixture.deliveries, []);
+    assert.deepEqual(
+      (await fixture.store.get(task.id))?.runs.map(({ at, outcome }) => ({ at, outcome })),
+      [{ at: 60_000, outcome: 'blocked' }],
+    );
+    assert.deepEqual(await fixture.store.listPendingFires(), []);
+    assert.equal(fixture.timerDelay(), undefined);
+  } finally {
+    await fixture.close();
+  }
+});
+
+for (const edit of [
+  { change: 'shortened', from: 60_000, to: 25_000 },
+  { change: 'added', from: null, to: 25_000 },
+  { change: 'extended', from: 14_000, to: 60_000 },
+] as const) {
+  test(`expiry ${edit.change} during a provider wait replaces the waiting fire and its snapshot`, async () => {
+    const fixture = await schedulerFixture();
+    try {
+      const task = await fixture.create({
+        schedule: { kind: 'interval', everySeconds: 10, startAt: 11_000 },
+        expiresAt: edit.from,
+      });
+      await fixture.start();
+      await fixture.tick(11_000);
+      assert.equal((await fixture.store.listPendingFires())[0]?.task.expiresAt, edit.from);
+      fixture.setNow(13_000);
+      const outcome = await fixture.mutate({
+        kind: 'update',
+        taskId: task.id,
+        patch: { expiresAt: edit.to },
+      });
+      assert.equal(outcome.ok, true);
+      // The Store rejects task edits while a claim exists, so the edit first
+      // cancels the waiting fire. No claim is left holding the old expiry.
+      assert.deepEqual(await fixture.store.listPendingFires(), []);
+      assert.equal((await fixture.store.get(task.id))?.nextFireAt, 21_000);
+      await fixture.tick(21_000);
+      assert.equal((await fixture.store.listPendingFires())[0]?.task.expiresAt, edit.to);
+      if (edit.change === 'extended') {
+        fixture.connectProvider();
+        await fixture.tick(26_000);
+        assert.deepEqual(fixture.deliveries, [{ taskId: task.id, title: 'Reminder' }]);
+        const delivered = await fixture.store.get(task.id);
+        assert.equal(delivered?.status, 'active');
+        assert.equal(delivered?.runs[0]?.outcome, 'ok');
+        assert.equal(delivered?.nextFireAt, 31_000);
+      } else {
+        assert.equal(fixture.timerDelay(), 4_000);
+        await fixture.tick(25_000);
+        const blocked = await fixture.store.get(task.id);
+        assert.equal(blocked?.status, 'expired');
+        assert.deepEqual(
+          blocked?.runs.map(({ at, outcome }) => ({ at, outcome })),
+          [{ at: 25_000, outcome: 'blocked' }],
+        );
+        assert.deepEqual(await fixture.store.listPendingFires(), []);
+        fixture.connectProvider();
+        await fixture.restart();
+        assert.deepEqual(fixture.deliveries, []);
+      }
+    } finally {
+      await fixture.close();
+    }
+  });
+}
+
+test('a recurring notification delivered late resumes at its next slot without catching up', async () => {
+  const fixture = await schedulerFixture();
+  try {
+    const task = await fixture.create({
+      schedule: { kind: 'interval', everySeconds: 10, startAt: 11_000 },
+    });
+    await fixture.start();
+    await fixture.tick(11_000);
+    await fixture.tick(16_000);
+    // The 21_000 slot passes while the 11_000 fire still waits; it adds no claim.
+    await fixture.tick(21_000);
+    const waiting = await fixture.store.listPendingFires();
+    assert.deepEqual(
+      waiting.map((claim) => [claim.scheduledFor, claim.nativeState]),
+      [[11_000, 'waiting_for_provider']],
+    );
+    assert.equal(fixture.timerDelay(), 5_000);
+    fixture.connectProvider();
+    await fixture.tick(26_000);
+    assert.equal(fixture.deliveries.length, 1);
+    const delivered = await fixture.store.get(task.id);
+    assert.equal(delivered?.status, 'active');
+    assert.equal(delivered?.fireCount, 1);
+    assert.equal(delivered?.lastFireAt, 26_000);
+    assert.equal(delivered?.nextFireAt, 31_000);
+    assert.deepEqual(await fixture.store.listPendingFires(), []);
+    await fixture.tick(31_000);
+    assert.equal(fixture.deliveries.length, 2);
+    assert.equal((await fixture.store.get(task.id))?.nextFireAt, 41_000);
+    assert.equal(fixture.timerDelay(), 10_000);
+  } finally {
+    await fixture.close();
+  }
+});
diff --git a/packages/runtime-host/src/server/scheduled-task-coordinator.ts b/packages/runtime-host/src/server/scheduled-task-coordinator.ts
index e8e1718f62..e79225f9d3 100644
--- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts
+++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts
@@ -467,7 +467,13 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority
             const claim = await this.#store.claimNow(input.taskId, this.#now());
             await this.#refreshResidency();
             const task = await this.#fulfill(claim, false);
-            if (!task) throw new ScheduledTaskNativeUnavailableError();
+            if (!task) {
+              // The waiting claim is already durable. A Store failure here
+              // drains the Host and recovery retries the claim, so the caller
+              // still gets the provider outcome rather than a generic failure.
+              await this.#refreshSchedule().catch((error: unknown) => this.#fatal(error));
+              throw new ScheduledTaskNativeUnavailableError();
+            }
             return task;
           }),
         );
@@ -610,6 +616,11 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority
           'The previous native notification stopped before delivery was confirmed.',
         );
       }
+      // Expiry closes delivery just as it closes due and Trigger Now admission.
+      // A fire still waiting for a provider is settled rather than sent late.
+      if (task.expiresAt !== null && this.#now() >= task.expiresAt) {
+        return this.#settle(claim, 'blocked', '定时任务已过期,通知没有送达。', 'blocked');
+      }
       if (task.effect.channel === 'bot' && !isBotDeliveryProvider(task.effect.platform)) {
         return this.#settle(
           claim,
@@ -865,10 +876,17 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority
     await this.#refreshResidency();
     if (!this.#started || this.#draining || this.#handoffHeld) return;
     const [tasks, claims] = await Promise.all([this.#store.list(), this.#store.listPendingFires()]);
+    const claimedTaskIds = new Set(claims.map((claim) => claim.task.id));
     const next = tasks
-      .filter((task) => task.status === 'active' && task.nextFireAt !== null)
+      .filter((task) => task.status === 'active')
       .reduce<number | null>((earliest, task) => {
-        const deadline = Math.min(task.nextFireAt!, task.expiresAt ?? task.nextFireAt!);
+        // A claimed fire waits on delivery, not on its original due time. Its
+        // expiry still wakes the scheduler, which settles a fire that is still
+        // waiting for a provider as blocked.
+        const nextFireAt = claimedTaskIds.has(task.id) ? null : task.nextFireAt;
+        const deadline =
+          nextFireAt === null ? task.expiresAt : Math.min(nextFireAt, task.expiresAt ?? nextFireAt);
+        if (deadline === null) return earliest;
         return earliest === null || deadline < earliest ? deadline : earliest;
       }, null);
     const waitingForProvider = claims.some((claim) => claim.nativeState === 'waiting_for_provider');


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org