@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/README.md +18 -6
- package/docs/README.ru.md +21 -5
- package/docs/README.zh.md +18 -6
- package/docs/design/DESIGN.md +6 -4
- package/docs/plans/0.2.6-economy-block.md +61 -0
- package/lib/api.js +399 -0
- package/lib/client.js +154 -4
- package/lib/failure-inspector.js +107 -0
- package/lib/http-utils.js +24 -0
- package/lib/index.js +121 -388
- package/lib/llm-ask.js +141 -0
- package/lib/recipes.js +238 -0
- package/lib/runner.js +210 -171
- package/lib/scheduler.js +226 -80
- package/lib/silent-rule.js +100 -0
- package/lib/store.js +11 -0
- package/lib/task-patch.js +81 -0
- package/lib/task-transfer.js +4 -0
- package/lib/templates.js +4 -0
- package/package.json +1 -1
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
|
310
|
-
|
|
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.
|
|
329
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
+
}
|
package/lib/task-transfer.js
CHANGED
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
|
|