Skip to content

Commit 6c00fcf

Browse files
mindfnclaude
andcommitted
feat(scheduler): add success notification with next fire time (#415)
Notify users after successful task execution: recurring tasks report next fire time, once tasks confirm completion and auto-retirement. Closes the last lifecycle feedback gap identified by review. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cb09a53 commit 6c00fcf

3 files changed

Lines changed: 122 additions & 3 deletions

File tree

packages/api/src/infrastructure/scheduler/TaskRunnerV2.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { getNextCronMs } from './cron-utils.js';
22
import type { DynamicTaskDef, DynamicTaskStore } from './DynamicTaskStore.js';
33
import { executeTaskPipeline } from './execute-pipeline.js';
44
import type { RunLedger } from './RunLedger.js';
5-
import { notifyTaskFailed } from './schedule-notify.js';
5+
import { notifyTaskFailed, notifyTaskSucceeded } from './schedule-notify.js';
66
import type { TaskTemplate } from './templates/types.js';
77
import type {
88
ActorRole,
@@ -401,11 +401,12 @@ export class TaskRunnerV2 {
401401
fetchContent: this.fetchContent,
402402
invokeTrigger: this.invokeTrigger,
403403
onItemOutcome: (taskId, _subjectKey, outcome, errorSummary) => {
404-
if (outcome !== 'RUN_FAILED') return;
405404
const dynDefId = this.dynamicTaskIds.get(taskId);
406405
if (!dynDefId || !this.dynamicTaskStore) return;
407406
const def = this.dynamicTaskStore.getById(dynDefId);
408-
if (def) notifyTaskFailed(this.deliver, def, errorSummary);
407+
if (!def) return;
408+
if (outcome === 'RUN_FAILED') notifyTaskFailed(this.deliver, def, errorSummary);
409+
if (outcome === 'RUN_DELIVERED') notifyTaskSucceeded(this.deliver, def);
409410
},
410411
});
411412
}

packages/api/src/infrastructure/scheduler/schedule-notify.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ export function notifyTaskDeleted(deliver: DeliverFn | undefined, def: DynamicTa
5959
fire(deliver, def, `🗑️ 定时任务「${label(def)}」已删除`);
6060
}
6161

62+
export function notifyTaskSucceeded(deliver: DeliverFn | undefined, def: DynamicTaskDef): void {
63+
if (def.trigger.type === 'once') {
64+
fire(deliver, def, `✅ 定时任务「${label(def)}」已执行完成,任务已自动结束`);
65+
} else {
66+
const nextFire = computeNextFireTime(def.trigger);
67+
const timeStr = nextFire ? formatTime(nextFire) : '未知';
68+
fire(deliver, def, `✅ 定时任务「${label(def)}」本次执行完成,下次执行时间:${timeStr}`);
69+
}
70+
}
71+
6272
export function notifyTaskFailed(
6373
deliver: DeliverFn | undefined,
6474
def: DynamicTaskDef,

packages/api/test/scheduler/schedule-notify.test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,112 @@ describe('TaskRunnerV2 — execution failure notification (#415)', () => {
198198
assert.ok(failMsg.content.includes('kaboom'));
199199
runner.stop();
200200
});
201+
202+
it('RUN_DELIVERED triggers notifyTaskSucceeded via onItemOutcome', async () => {
203+
const Database = (await import('better-sqlite3')).default;
204+
const db = new Database(':memory:');
205+
const { applyMigrations } = await import('../../dist/domains/memory/schema.js');
206+
const { RunLedger } = await import('../../dist/infrastructure/scheduler/RunLedger.js');
207+
const { DynamicTaskStore } = await import('../../dist/infrastructure/scheduler/DynamicTaskStore.js');
208+
const { TaskRunnerV2 } = await import('../../dist/infrastructure/scheduler/TaskRunnerV2.js');
209+
applyMigrations(db);
210+
const ledger = new RunLedger(db);
211+
const dynamicTaskStore = new DynamicTaskStore(db);
212+
const deliverCalls = [];
213+
const mockDeliver = async (opts) => {
214+
deliverCalls.push(opts);
215+
return 'msg-1';
216+
};
217+
const noop = () => {};
218+
const runner = new TaskRunnerV2({
219+
logger: { info: noop, error: noop },
220+
ledger,
221+
dynamicTaskStore,
222+
deliver: mockDeliver,
223+
});
224+
225+
dynamicTaskStore.insert({
226+
id: 'dyn-ok-1',
227+
templateId: 'reminder',
228+
trigger: { type: 'interval', ms: 999999 },
229+
params: { message: 'test', triggerUserId: 'user-42' },
230+
display: { label: '成功任务', category: 'system' },
231+
deliveryThreadId: 'thread-ok',
232+
enabled: true,
233+
createdBy: 'opus',
234+
createdAt: new Date().toISOString(),
235+
});
236+
237+
runner.registerDynamic(
238+
{
239+
id: 'dyn-ok-1',
240+
profile: 'awareness',
241+
trigger: { type: 'interval', ms: 999999 },
242+
admission: {
243+
gate: async () => ({ run: true, workItems: [{ signal: 'go', subjectKey: 'k' }] }),
244+
},
245+
run: {
246+
overlap: 'skip',
247+
timeoutMs: 5000,
248+
execute: async () => ({ delivered: true }),
249+
},
250+
state: { runLedger: 'sqlite' },
251+
outcome: { whenNoSignal: 'drop' },
252+
enabled: () => true,
253+
},
254+
'dyn-ok-1',
255+
);
256+
257+
await runner.triggerNow('dyn-ok-1');
258+
await new Promise((r) => setTimeout(r, 50));
259+
260+
const successMsg = deliverCalls.find((c) => c.content.includes('执行完成'));
261+
assert.ok(successMsg, 'should contain success notification');
262+
assert.equal(successMsg.threadId, 'thread-ok');
263+
assert.ok(successMsg.content.includes('下次执行时间'), 'recurring task should include next fire time');
264+
runner.stop();
265+
});
266+
});
267+
268+
describe('schedule-notify: notifyTaskSucceeded', () => {
269+
const makeDef2 = (overrides = {}) => ({
270+
id: 'dyn-test-1',
271+
templateId: 'reminder',
272+
trigger: { type: 'once', fireAt: Date.now() + 60_000 },
273+
params: { message: 'test', triggerUserId: 'user-42' },
274+
display: { label: '测试提醒', category: 'system' },
275+
deliveryThreadId: 'thread-xyz',
276+
enabled: true,
277+
createdBy: 'opus',
278+
createdAt: new Date().toISOString(),
279+
...overrides,
280+
});
281+
282+
it('recurring task includes next fire time', async () => {
283+
const { notifyTaskSucceeded } = await import('../../dist/infrastructure/scheduler/schedule-notify.js');
284+
const calls = [];
285+
const mockDeliver = async (opts) => {
286+
calls.push(opts);
287+
return 'msg-1';
288+
};
289+
notifyTaskSucceeded(mockDeliver, makeDef2({ trigger: { type: 'interval', ms: 60000 } }));
290+
await new Promise((r) => setTimeout(r, 20));
291+
assert.equal(calls.length, 1);
292+
assert.ok(calls[0].content.includes('本次执行完成'));
293+
assert.ok(calls[0].content.includes('下次执行时间'));
294+
});
295+
296+
it('once task says task has ended', async () => {
297+
const { notifyTaskSucceeded } = await import('../../dist/infrastructure/scheduler/schedule-notify.js');
298+
const calls = [];
299+
const mockDeliver = async (opts) => {
300+
calls.push(opts);
301+
return 'msg-1';
302+
};
303+
notifyTaskSucceeded(mockDeliver, makeDef2({ trigger: { type: 'once', fireAt: Date.now() } }));
304+
await new Promise((r) => setTimeout(r, 20));
305+
assert.equal(calls.length, 1);
306+
assert.ok(calls[0].content.includes('已执行完成'));
307+
assert.ok(calls[0].content.includes('自动结束'));
308+
});
201309
});

0 commit comments

Comments
 (0)