@goodandready/dsh-cron 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/scheduler.js CHANGED
@@ -1,4 +1,18 @@
1
1
  import { Cron } from 'croner';
2
+ import { applySilentRule } from './silent-rule.js';
3
+ import { inspectFailure } from './failure-inspector.js';
4
+
5
+ /** Task types that talk to a model and can therefore use a fallback model. */
6
+ export const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
7
+
8
+ /** Sum two usage records, so a fallback attempt is not lost in accounting. */
9
+ export function addUsage(a, b) {
10
+ return {
11
+ inputTokens: (a?.inputTokens || 0) + (b?.inputTokens || 0),
12
+ outputTokens: (a?.outputTokens || 0) + (b?.outputTokens || 0),
13
+ cacheReadTokens: (a?.cacheReadTokens || 0) + (b?.cacheReadTokens || 0),
14
+ };
15
+ }
2
16
 
3
17
  function pad(v) {
4
18
  return String(v).padStart(2, '0');
@@ -297,44 +311,28 @@ export class TaskScheduler {
297
311
  }
298
312
  }
299
313
 
300
- async runTask(taskId) {
301
- const task = this.store.get(taskId);
302
- if (!task) return;
303
-
314
+ /**
315
+ * Pre-flight guards for a run: the global concurrency throttle (#52) and the
316
+ * per-task overlap policy. Returns the run token to continue with, or null
317
+ * when this tick must not execute (the reason is already recorded).
318
+ */
319
+ beginRun(task, taskId) {
304
320
  // Global concurrency throttle (#52): a run that would exceed the limit is
305
321
  // skipped and recorded; already-running tasks are never throttled against
306
322
  // themselves.
307
323
  if (!this.running.has(taskId) && this.maxConcurrent > 0 && this.running.size >= this.maxConcurrent) {
308
324
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${this.maxConcurrent})`);
309
- this.store.recordRun(taskId, {
310
- at: Date.now(),
311
- status: 'skipped',
312
- durationMs: 0,
313
- output: `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`,
314
- error: null,
315
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
316
- costUsd: 0,
317
- });
318
- return;
325
+ this.recordSkipped(taskId, `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`);
326
+ return null;
319
327
  }
320
328
 
321
329
  const overlapPolicy = task.overlapPolicy || 'skip';
322
-
323
- // Check overlap with currently running task
324
330
  const active = this.running.get(taskId);
325
331
  if (active) {
326
332
  if (overlapPolicy === 'skip') {
327
333
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, skipping run (overlapPolicy: skip)`);
328
- this.store.recordRun(taskId, {
329
- at: Date.now(),
330
- status: 'skipped',
331
- durationMs: 0,
332
- output: 'Skipped: the previous run is still in progress (overlapPolicy: skip)',
333
- error: null,
334
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
335
- costUsd: 0,
336
- });
337
- return;
334
+ this.recordSkipped(taskId, 'Skipped: the previous run is still in progress (overlapPolicy: skip)');
335
+ return null;
338
336
  }
339
337
  if (overlapPolicy === 'replace') {
340
338
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
@@ -343,73 +341,221 @@ export class TaskScheduler {
343
341
  if (overlapPolicy === 'queue') {
344
342
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
345
343
  active.queueCount = (active.queueCount || 0) + 1;
346
- return;
344
+ return null;
347
345
  }
348
346
  }
349
347
 
350
348
  const controller = new AbortController();
351
- const currentRun = {
352
- controller,
353
- startedAt: Date.now(),
354
- queueCount: 0,
355
- };
349
+ const currentRun = { controller, startedAt: Date.now(), queueCount: 0 };
356
350
  this.running.set(taskId, currentRun);
351
+ return currentRun;
352
+ }
357
353
 
358
- const start = Date.now();
359
- let status = 'success';
360
- let output = '';
361
- let error = null;
362
- let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
363
- let costUsd = 0;
364
- let sessionId = null;
354
+ /** Record a run that never started, with the reason in the history entry. */
355
+ recordSkipped(taskId, reason) {
356
+ this.store.recordRun(taskId, {
357
+ at: Date.now(),
358
+ status: 'skipped',
359
+ durationMs: 0,
360
+ output: reason,
361
+ error: null,
362
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
363
+ costUsd: 0,
364
+ });
365
+ }
365
366
 
367
+ /** Run the task body once and normalise whatever the executor returned. */
368
+ async executeOnce(task, signal) {
369
+ const result = {
370
+ status: 'success',
371
+ output: '',
372
+ error: null,
373
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
374
+ costUsd: 0,
375
+ sessionId: null,
376
+ model: task.model || '',
377
+ fallback: false,
378
+ };
379
+ if (typeof this.executeFn !== 'function') return result;
366
380
  try {
367
- if (typeof this.executeFn === 'function') {
368
- const res = await this.executeFn(task, { signal: controller.signal });
369
- if (typeof res === 'object' && res !== null) {
370
- output = res.output || '';
371
- usage = res.usage || usage;
372
- costUsd = res.costUsd || 0;
373
- sessionId = res.sessionId || null;
374
- } else {
375
- output = String(res || '');
376
- }
377
- }
378
- } catch (err) {
379
- error = err.message || String(err);
380
- if (error.toLowerCase().includes('timed out') || error.toLowerCase().includes('timeout')) {
381
- status = 'timeout';
381
+ const res = await this.executeFn(task, { signal });
382
+ if (typeof res === 'object' && res !== null) {
383
+ result.output = res.output || '';
384
+ result.usage = res.usage || result.usage;
385
+ result.costUsd = res.costUsd || 0;
386
+ result.sessionId = res.sessionId || null;
387
+ if (res.model) result.model = res.model;
382
388
  } else {
383
- status = 'error';
384
- }
385
- } finally {
386
- const durationMs = Date.now() - start;
387
- const runInfo = {
388
- at: start,
389
- status,
390
- durationMs,
391
- output,
392
- error,
393
- usage,
394
- costUsd,
395
- sessionId,
396
- };
397
- try {
398
- this.store.recordRun(taskId, runInfo);
399
- } catch (recordErr) {
400
- console.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
389
+ result.output = String(res || '');
401
390
  }
391
+ } catch (err) {
392
+ result.error = err.message || String(err);
393
+ const timedOut = result.error.toLowerCase().includes('timed out') || result.error.toLowerCase().includes('timeout');
394
+ result.status = timedOut ? 'timeout' : 'error';
395
+ }
396
+ return result;
397
+ }
398
+
399
+ /**
400
+ * Run the task, retrying once on the configured fallback model when the
401
+ * primary attempt failed (#45).
402
+ *
403
+ * The point is cost: a task can default to the cheap model and still finish
404
+ * on the strong one when the cheap model cannot do the job. Exactly one
405
+ * fallback attempt is made — the ordinary retry backoff still applies
406
+ * afterwards — and usage and cost of BOTH attempts are summed, because both
407
+ * were really spent.
408
+ */
409
+ async executeWithFallback(task, signal) {
410
+ const primary = await this.executeOnce(task, signal);
411
+ if (!this.shouldUseFallback(task, primary)) return primary;
412
+
413
+ const fallbackTask = {
414
+ ...task,
415
+ provider: task.fallbackProvider || task.provider,
416
+ model: task.fallbackModel,
417
+ };
418
+ console.log(`[dsh-cron] task "${task.title}" failed on ${task.model || 'the default model'}, retrying once on ${task.fallbackModel}`);
419
+ const secondary = await this.executeOnce(fallbackTask, signal);
420
+ return {
421
+ ...secondary,
422
+ fallback: true,
423
+ usage: addUsage(primary.usage, secondary.usage),
424
+ costUsd: Number((primary.costUsd + secondary.costUsd).toFixed(6)),
425
+ primaryModel: task.model || '',
426
+ };
427
+ }
428
+
429
+ /** Only agent-mediated types use a model, and only a failed run falls back. */
430
+ shouldUseFallback(task, outcome) {
431
+ if (!task || !task.fallbackModel) return false;
432
+ if (!AGENT_TASK_TYPES.includes(task.type || 'llm')) return false;
433
+ return outcome.status === 'error' || outcome.status === 'timeout';
434
+ }
402
435
 
403
- // Clean up running map
404
- const finishedRun = this.running.get(taskId);
405
- if (finishedRun === currentRun) {
406
- this.running.delete(taskId);
407
- }
408
436
 
409
- const queuedCount = finishedRun?.queueCount || 0;
437
+ async runTask(taskId) {
438
+ const task = this.store.get(taskId);
439
+ if (!task) return;
440
+
441
+ const currentRun = this.beginRun(task, taskId);
442
+ if (currentRun === null) return;
443
+
444
+ const start = Date.now();
445
+ const outcome = await this.executeWithFallback(task, currentRun.controller.signal);
446
+ await this.completeRun(task, taskId, currentRun, outcome, start);
447
+ }
448
+
449
+ /**
450
+ * Record the run, release the run slot and hand the result to delivery and
451
+ * bookkeeping. Kept separate from runTask so the run flow reads in steps.
452
+ */
453
+ async completeRun(task, taskId, currentRun, outcome, start) {
454
+ const runInfo = {
455
+ at: start,
456
+ status: outcome.status,
457
+ durationMs: Date.now() - start,
458
+ output: outcome.output,
459
+ error: outcome.error,
460
+ usage: outcome.usage,
461
+ costUsd: outcome.costUsd,
462
+ sessionId: outcome.sessionId,
463
+ // #45: which model actually produced this result, and whether the run
464
+ // only finished thanks to the configured fallback.
465
+ model: outcome.model || task.model || '',
466
+ fallback: Boolean(outcome.fallback),
467
+ };
468
+
469
+ // #43/#44 decide before the run is recorded, so the single history entry
470
+ // already carries the diagnosis and the silence verdict.
471
+ const silent = await this.applyModelAssists(task, runInfo);
472
+
473
+ try {
474
+ this.store.recordRun(taskId, runInfo);
475
+ } catch (recordErr) {
476
+ console.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
477
+ }
478
+
479
+ // Release the slot before delivery so a long notification never blocks the
480
+ // next tick of this task.
481
+ const finishedRun = this.running.get(taskId);
482
+ if (finishedRun === currentRun) this.running.delete(taskId);
483
+ const queuedCount = finishedRun?.queueCount || 0;
484
+
485
+ if (!silent.skipped) await this.deliverNotifications(task, runInfo);
486
+ this.finishRun(task, taskId, outcome.status, queuedCount);
487
+ }
488
+
489
+ /**
490
+ * Run the model-assisted steps for a finished run: diagnose a failure (#43)
491
+ * and decide whether a successful run should stay silent (#44). Both are
492
+ * fail-open and write their outcome into `runInfo`.
493
+ */
494
+ async applyModelAssists(task, runInfo) {
495
+ const inspection = await this.inspectFailureRun(task, runInfo);
496
+ if (inspection.inspected) {
497
+ runInfo.diagnosis = inspection.diagnosis;
498
+ runInfo.suggestion = inspection.suggestion;
499
+ runInfo.confidence = inspection.confidence;
500
+ console.log(`[dsh-cron] task "${task.title}" failure diagnosed (${inspection.confidence}): ${inspection.diagnosis}`);
501
+ } else if (inspection.error && task.inspectOnFailure) {
502
+ console.warn(`[dsh-cron] failure inspection for "${task.title}" not applied: ${inspection.error}`);
503
+ }
504
+
505
+ const silent = await this.applySilentRuleToRun(task, runInfo);
506
+ if (silent.skipped) {
507
+ runInfo.silentSkip = true;
508
+ runInfo.silentReason = silent.reason;
509
+ console.log(`[dsh-cron] task "${task.title}" stayed silent by rule: ${silent.reason || 'no reason given'}`);
510
+ } else if (silent.verdictError) {
511
+ console.warn(`[dsh-cron] silent rule for "${task.title}" not applied: ${silent.verdictError}`);
512
+ }
513
+ return silent;
514
+ }
515
+
516
+ /** Ask the model to diagnose a failed run (#43). Never throws. */
517
+ async inspectFailureRun(task, runInfo) {
518
+ if (typeof this.askModel !== 'function') return { inspected: false, error: 'no model is available' };
519
+ const settings = this.readSettings();
520
+ try {
521
+ return await inspectFailure({
522
+ task,
523
+ runInfo,
524
+ ask: this.askModel,
525
+ preferredModel: String(settings.inspectorModel || ''),
526
+ timeoutMs: this.inspectTimeoutMs || 25000,
527
+ });
528
+ } catch (err) {
529
+ return { inspected: false, error: err.message };
530
+ }
531
+ }
532
+
533
+ /** Settings snapshot, or an empty object when the store has none. */
534
+ readSettings() {
535
+ try {
536
+ return typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
537
+ } catch {
538
+ return {};
539
+ }
540
+ }
410
541
 
411
- await this.deliverNotifications(task, runInfo);
412
- this.finishRun(task, taskId, status, queuedCount);
542
+ /**
543
+ * Ask the silent rule whether this run should be delivered (#44).
544
+ * Never throws: any problem means "deliver".
545
+ */
546
+ async applySilentRuleToRun(task, runInfo) {
547
+ if (typeof this.askModel !== 'function') return { skipped: false, reason: '' };
548
+ const preferredModel = String(this.readSettings().silentRuleModel || '');
549
+ try {
550
+ return await applySilentRule({
551
+ task,
552
+ runInfo,
553
+ ask: this.askModel,
554
+ preferredModel,
555
+ timeoutMs: this.silentRuleTimeoutMs || 20000,
556
+ });
557
+ } catch (err) {
558
+ return { skipped: false, reason: '', verdictError: err.message };
413
559
  }
414
560
  }
415
561
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The "silent rule" (#44): a plain-language condition that decides whether a
3
+ * successful run is worth alerting about.
4
+ *
5
+ * A script that runs every 30 minutes usually produces nothing interesting, and
6
+ * `onlyOnFailure` cannot tell "the disk is at 12%" from "the disk is at 96%".
7
+ * The rule is written by the user in words; a cheap model call turns it into a
8
+ * yes/no verdict with a reason.
9
+ *
10
+ * Fail-open by design: no rule, no model, a broken call or an unusable answer all
11
+ * mean "deliver the report". Silence is only ever produced by an explicit
12
+ * verdict.
13
+ */
14
+
15
+ import { askModel, parseJsonAnswer, resolveAskTarget } from './llm-ask.js';
16
+
17
+ /** Task types whose runs produce output a rule can judge. */
18
+ export const SILENT_RULE_TASK_TYPES = ['script', 'node', 'python', 'http'];
19
+
20
+ export function supportsSilentRule(task) {
21
+ return SILENT_RULE_TASK_TYPES.includes(String(task && task.type ? task.type : 'script'));
22
+ }
23
+
24
+ const SYSTEM_PROMPT = [
25
+ 'You decide whether a monitoring job should notify its owner.',
26
+ 'Answer with a single JSON object and nothing else:',
27
+ '{"notify": true|false, "reason": "short explanation"}',
28
+ 'Be conservative: when the output shows a problem, a warning, an error or anything uncertain, notify.',
29
+ 'Only stay silent when the output clearly matches the condition for silence.',
30
+ ].join(' ');
31
+
32
+ export function buildSilentRulePrompt(task, runInfo) {
33
+ const output = String((runInfo && runInfo.output) || '').slice(0, 4000);
34
+ return [
35
+ `Task: ${task.title || 'scheduled task'} (type: ${task.type || 'script'})`,
36
+ `Schedule: ${task.scheduleText || task.schedule || ''}`,
37
+ `Exit status: ${(runInfo && runInfo.status) || 'success'}`,
38
+ '',
39
+ 'Condition for staying silent:',
40
+ String(task.silentRule || '').trim(),
41
+ '',
42
+ 'Output of the run:',
43
+ '```',
44
+ output || '(no output)',
45
+ '```',
46
+ ].join('\n');
47
+ }
48
+
49
+ /** Turn a model answer into a verdict; anything unusable means "notify". */
50
+ export function readVerdict(text) {
51
+ const parsed = parseJsonAnswer(text);
52
+ if (!parsed || typeof parsed.notify !== 'boolean') {
53
+ return { ok: false, error: 'the model did not answer with a notify verdict' };
54
+ }
55
+ return { ok: true, notify: parsed.notify, reason: String(parsed.reason || '').slice(0, 500) };
56
+ }
57
+
58
+ /**
59
+ * Decide whether a finished run should be delivered.
60
+ * Returns `{ skipped, reason, verdictError? }`; `skipped` is true only after an
61
+ * explicit "stay silent" verdict.
62
+ */
63
+ export async function applySilentRule({ task, runInfo, ask, preferredModel, timeoutMs }) {
64
+ if (!task || !task.silentRule || String(task.silentRule).trim() === '') {
65
+ return { skipped: false, reason: '' };
66
+ }
67
+ if ((runInfo && runInfo.status) !== 'success') {
68
+ return { skipped: false, reason: '', note: 'the rule only judges successful runs' };
69
+ }
70
+ if (typeof ask !== 'function') {
71
+ return { skipped: false, reason: '', verdictError: 'no model is available for the silent rule' };
72
+ }
73
+ try {
74
+ const result = await ask({
75
+ task,
76
+ prompt: buildSilentRulePrompt(task, runInfo),
77
+ system: SYSTEM_PROMPT,
78
+ preferredModel,
79
+ maxTokens: 200,
80
+ timeoutMs,
81
+ });
82
+ if (!result || !result.ok) {
83
+ return { skipped: false, reason: '', verdictError: (result && result.error) || 'the model call failed' };
84
+ }
85
+ const verdict = readVerdict(result.text);
86
+ if (!verdict.ok) return { skipped: false, reason: '', verdictError: verdict.error };
87
+ return { skipped: verdict.notify === false, reason: verdict.reason };
88
+ } catch (err) {
89
+ // The rule never decides by failing: a thrown model call means "deliver".
90
+ return { skipped: false, reason: '', verdictError: (err && err.message) || String(err) };
91
+ }
92
+ }
93
+
94
+ /** Ask helper bound to a context, used by the scheduler. */
95
+ export function makeAsk(ctx) {
96
+ return async ({ prompt, system, preferredModel, maxTokens, timeoutMs, task }) => {
97
+ const target = resolveAskTarget(ctx, task || {}, preferredModel);
98
+ return askModel(ctx, { ...target, prompt, system, maxTokens, timeoutMs });
99
+ };
100
+ }
package/lib/store.js CHANGED
@@ -283,6 +283,17 @@ export class TaskStore {
283
283
  usage,
284
284
  costUsd,
285
285
  sessionId: runInfo.sessionId || null,
286
+ // #45: which model produced the run, and whether it only finished thanks
287
+ // to the configured fallback.
288
+ model: runInfo.model || '',
289
+ fallback: Boolean(runInfo.fallback),
290
+ // #44: this run was suppressed by its silent rule, with the reason.
291
+ silentSkip: Boolean(runInfo.silentSkip),
292
+ silentReason: runInfo.silentSkip ? String(runInfo.silentReason || '').slice(0, 500) : '',
293
+ // #43: the failure diagnosis and the prompt change it proposes.
294
+ diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
295
+ suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
296
+ confidence: runInfo.confidence || '',
286
297
  });
287
298
  if (runs.length > 50) runs.length = 50;
288
299
  this.history.set(id, runs);
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Partial task updates, shared by the HTTP PATCH route and the cron_update_task
3
+ * tool (#49).
4
+ *
5
+ * One implementation keeps the whitelist, the type validation, the schedule
6
+ * re-parse and the re-scheduling identical on both surfaces; the HTTP route adds
7
+ * its own cross-origin and confirmation checks on top, while an agent calling the
8
+ * tool acts on the user's explicit instruction.
9
+ */
10
+
11
+ import { parseScheduleExpression } from './scheduler.js';
12
+ import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
13
+ import { PATCHABLE_TASK_FIELDS, validateTaskType } from './task-transfer.js';
14
+ import { pickPatchableFields } from './http-utils.js';
15
+
16
+ /** True when a patch changes a task into a type that executes code. */
17
+ export function isCodeTypeSwitch(current, nextType) {
18
+ if (!current || nextType === undefined) return false;
19
+ const target = normalizeTaskType(nextType);
20
+ return CODE_EXECUTING_TYPES.includes(target) && !CODE_EXECUTING_TYPES.includes(current.type);
21
+ }
22
+
23
+ /**
24
+ * Normalise a patch: whitelist the fields, resolve the type, re-parse the
25
+ * schedule so cron patterns and one-shot markers stay consistent.
26
+ * Returns `{ ok: false, error }` when the merged task would be invalid.
27
+ */
28
+ export function buildTaskPatch(current, body) {
29
+ const patch = pickPatchableFields(body || {}, PATCHABLE_TASK_FIELDS);
30
+ if (patch.type !== undefined) {
31
+ patch.type = normalizeTaskType(patch.type);
32
+ const typeError = validateTaskType(patch.type, { ...current, ...patch });
33
+ if (typeError) return { ok: false, error: typeError };
34
+ }
35
+ if (patch.schedule !== undefined) {
36
+ // An empty schedule would leave an armed task that can never fire.
37
+ if (!String(patch.schedule).trim()) return { ok: false, error: 'Schedule cannot be empty' };
38
+ const parsed = parseScheduleExpression(patch.schedule);
39
+ patch.schedule = parsed.cronPattern || patch.schedule;
40
+ patch.scheduleText = (body && body.scheduleText) || parsed.humanText;
41
+ patch.oneShot = Boolean(parsed.isOneShot || (body && body.oneShot));
42
+ }
43
+ return { ok: true, patch };
44
+ }
45
+
46
+ /**
47
+ * Apply a patch to a stored task and re-arm or pause it.
48
+ *
49
+ * Switching a task into a code-executing type is the one change that turns a
50
+ * harmless schedule into code execution, so it needs explicit confirmation on
51
+ * EVERY surface: the HTTP route passes the result of its confirmation header,
52
+ * and the tool exposes its own switch. A prose request in a tool description is
53
+ * not a gate, so the refusal lives here instead.
54
+ *
55
+ * Returns `{ ok: true, task }`, `{ ok: false, error }`, or
56
+ * `{ ok: false, needsConfirmation: true, error }` for that switch.
57
+ */
58
+ export function applyTaskPatch({ store, scheduler, id, body, allowCodeSwitch = false }) {
59
+ const current = store.get(id);
60
+ if (!current) return { ok: false, notFound: true, error: 'Task not found' };
61
+ if (isCodeTypeSwitch(current, body && body.type) && allowCodeSwitch !== true) {
62
+ return {
63
+ ok: false,
64
+ needsConfirmation: true,
65
+ error: `Switching this task to ${normalizeTaskType(body.type)} makes it execute code; confirm that explicitly and retry`,
66
+ };
67
+ }
68
+ const built = buildTaskPatch(current, body);
69
+ if (!built.ok) return built;
70
+ const task = store.set({ ...current, ...built.patch, id });
71
+ if (task.status === 'active') scheduler.scheduleTask(task);
72
+ else scheduler.pauseTask(id);
73
+ return { ok: true, task, patch: built.patch };
74
+ }
75
+
76
+ /** Human-readable summary of what changed, for tool output and logs. */
77
+ export function describeTaskPatch(task, patch) {
78
+ const keys = Object.keys(patch || {}).filter((k) => k !== 'scheduleText');
79
+ if (!keys.length) return 'no changes';
80
+ return keys.map((key) => `${key}=${JSON.stringify(patch[key])}`).join(', ');
81
+ }
@@ -21,6 +21,10 @@ export const PATCHABLE_TASK_FIELDS = [
21
21
  'delivery',
22
22
  'provider',
23
23
  'model',
24
+ 'fallbackProvider',
25
+ 'fallbackModel',
26
+ 'silentRule',
27
+ 'inspectOnFailure',
24
28
  'timezone',
25
29
  'misfirePolicy',
26
30
  'maxRetries',
package/lib/templates.js CHANGED
@@ -17,6 +17,8 @@ export const TEMPLATE_VARIABLES = [
17
17
  'time',
18
18
  'tokens',
19
19
  'cost',
20
+ 'model',
21
+ 'diagnosis',
20
22
  ];
21
23
 
22
24
  export const DEFAULT_PLAIN_TEMPLATE = '⏰ {title}\nStatus: {status}\nSchedule: {schedule}\nDuration: {duration}\n{output}';
@@ -60,6 +62,8 @@ export function buildTemplateVars(task, runInfo = {}) {
60
62
  time: new Date(runInfo.at || Date.now()).toISOString(),
61
63
  tokens: String(tokens),
62
64
  cost: `$${Number(runInfo.costUsd || 0).toFixed(4)}`,
65
+ model: runInfo.model || '',
66
+ diagnosis: truncateText(runInfo.diagnosis || ''),
63
67
  };
64
68
  }
65
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",