@goodandready/dsh-cron 0.2.40 → 0.2.42

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 CHANGED
@@ -550,7 +550,7 @@ dsh-cron:
550
550
  Notes:
551
551
 
552
552
  * Run history is capped at **50 entries per task** (fixed); each entry keeps up to 4000 characters of output.
553
- * Tasks run in the **server's local timezone**; cron expressions are evaluated by `croner` on the host clock.
553
+ * Tasks run in their configured **timezone** (`task.timezone`, falling back to `settings.defaultTimezone` or server local timezone); cron expressions are evaluated by `croner` against the resolved timezone.
554
554
  * Tasks persist in the DSH data directory (`cron/tasks.json`) and survive restarts; missed one-shots are detected on startup.
555
555
 
556
556
  ---
@@ -21,22 +21,26 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
21
21
  const botToken = settings.botToken || defaults.botToken;
22
22
  const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
23
23
 
24
- // Secret token verification (Issue #208)
24
+ // Secret token verification (Issue #208) - fail-closed: secret MUST be configured
25
25
  const webhookSecret = settings.telegramWebhookSecret || settings.webhookSecret || settings.secretToken || process.env.TELEGRAM_WEBHOOK_SECRET;
26
- if (webhookSecret) {
27
- const receivedSecret = req.headers['x-telegram-bot-api-secret-token'];
28
- if (!receivedSecret || typeof receivedSecret !== 'string') {
29
- logger.warn('[dsh-cron] unauthorized telegram webhook: missing secret token header');
30
- sendJson(res, 401, { ok: false, error: 'Unauthorized: missing secret token' });
31
- return;
32
- }
33
- const secretBuf = Buffer.from(String(webhookSecret));
34
- const receivedBuf = Buffer.from(String(receivedSecret));
35
- if (secretBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(secretBuf, receivedBuf)) {
36
- logger.warn('[dsh-cron] unauthorized telegram webhook: invalid secret token');
37
- sendJson(res, 401, { ok: false, error: 'Unauthorized: invalid secret token' });
38
- return;
39
- }
26
+ if (!webhookSecret) {
27
+ logger.warn('[dsh-cron] unauthorized telegram webhook: webhook secret is not configured (fail-closed)');
28
+ sendJson(res, 401, { ok: false, error: 'Unauthorized: webhook secret is not configured' });
29
+ return;
30
+ }
31
+ const headers = req.headers || {};
32
+ const receivedSecret = headers['x-telegram-bot-api-secret-token'] || headers['X-Telegram-Bot-Api-Secret-Token'];
33
+ if (!receivedSecret || typeof receivedSecret !== 'string') {
34
+ logger.warn('[dsh-cron] unauthorized telegram webhook: missing secret token header');
35
+ sendJson(res, 401, { ok: false, error: 'Unauthorized: missing secret token' });
36
+ return;
37
+ }
38
+ const secretBuf = Buffer.from(String(webhookSecret));
39
+ const receivedBuf = Buffer.from(String(receivedSecret));
40
+ if (secretBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(secretBuf, receivedBuf)) {
41
+ logger.warn('[dsh-cron] unauthorized telegram webhook: invalid secret token');
42
+ sendJson(res, 401, { ok: false, error: 'Unauthorized: invalid secret token' });
43
+ return;
40
44
  }
41
45
 
42
46
  // Deduplicate updates by update_id (Issue #208)
package/lib/api.js CHANGED
@@ -256,8 +256,8 @@ function buildTaskRecord(store, body, taskType, parsed) {
256
256
  model: body.model || undefined,
257
257
  fallbackProvider: body.fallbackProvider || undefined,
258
258
  fallbackModel: body.fallbackModel || undefined,
259
- notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
260
- onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
259
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : undefined),
260
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : undefined),
261
261
  timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
262
262
  overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
263
263
  kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
package/lib/channels.js CHANGED
@@ -18,11 +18,17 @@ export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'b
18
18
  export const isFailureStatus = (status) => status === 'error' || status === 'timeout' || status === 'missed';
19
19
  export const isFailed = (status) => isFailureStatus(status);
20
20
 
21
- /** The message text for a channel: custom template, else built-in defaults. */
22
- function messageTextFor(channelId, task, runInfo, settings = {}) {
21
+ /** The message text for a channel: task template -> channel template -> global template -> built-in defaults (#233). */
22
+ export function messageTextFor(channelId, task, runInfo, settings = {}) {
23
23
  const templates = (settings && settings.channelTemplates) || {};
24
- const template = templates[channelId] || settings.template || '';
25
- return resolveTemplateText({ template, task, runInfo });
24
+ const custom = (task && task.template) || templates[channelId] || (settings && settings.template) || '';
25
+ if (custom) {
26
+ return resolveTemplateText({ template: custom, task, runInfo });
27
+ }
28
+ if (channelId === 'telegram') {
29
+ return formatTaskTelegramMessage(task, runInfo);
30
+ }
31
+ return resolveTemplateText({ template: '', task, runInfo });
26
32
  }
27
33
 
28
34
  /**
@@ -56,17 +62,27 @@ export function unknownChannels(task) {
56
62
  return unknownChannelIds(task && task.channels);
57
63
  }
58
64
 
59
- /** Failure/only-on-failure filtering, per channel. */
65
+ /** Failure/only-on-failure filtering, per channel (#219). Explicit task channels win over legacy flags. */
60
66
  export function shouldSendToChannel(channelId, task, runInfo, settings = {}) {
61
- if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
62
- if (channelId === 'telegram') {
63
- if (task && task.notifyTelegram === false) return false;
64
- const taskForTelegram = Object.assign({}, task, { notifyTelegram: true });
65
- return shouldNotifyTask(taskForTelegram, runInfo, settings);
67
+ const isExplicit = Array.isArray(task && task.channels) && task.channels.includes(channelId);
68
+ if (!isExplicit) {
69
+ if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
70
+ if (channelId === 'telegram') {
71
+ if (task && task.notifyTelegram === false) return false;
72
+ const taskForTelegram = Object.assign({}, task, { notifyTelegram: true });
73
+ return shouldNotifyTask(taskForTelegram, runInfo, settings);
74
+ }
75
+ }
76
+
77
+ // Explicit selection (or default event filtering for all channels):
78
+ if (channelId === 'kanban') {
79
+ if (task && task.kanbanMode === 'on_failure') return isFailed(runInfo.status);
80
+ if (task && task.kanbanMode === 'always') return true;
66
81
  }
67
- const onlyOnFail = task.onlyOnFailure !== undefined
82
+
83
+ const onlyOnFail = (task && task.onlyOnFailure !== undefined)
68
84
  ? Boolean(task.onlyOnFailure)
69
- : Boolean(settings.onlyOnFailure);
85
+ : Boolean(settings && settings.onlyOnFailure);
70
86
  if (onlyOnFail && !isFailed(runInfo.status)) return false;
71
87
  return true;
72
88
  }
@@ -133,8 +149,34 @@ export function buildPushplusRequest({ settings = {}, text, task, token }) {
133
149
  };
134
150
  }
135
151
 
136
- export function buildGiteaIssuePayload({ task, runInfo }) {
152
+ export function buildGiteaIssuePayload({ task, runInfo, settings = {} }) {
137
153
  const failed = isFailed(runInfo.status);
154
+ const defaultNames = failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'];
155
+
156
+ // Resolve labels to integer IDs per Gitea Swagger (array<int64>) (#224)
157
+ const mapping = settings.giteaLabelIds || {};
158
+ let candidateIds = [];
159
+
160
+ if (Array.isArray(task && task.giteaLabelIds)) {
161
+ candidateIds = task.giteaLabelIds;
162
+ } else if (Array.isArray(settings.giteaLabels)) {
163
+ candidateIds = settings.giteaLabels;
164
+ } else if (Array.isArray(settings.giteaLabelIds)) {
165
+ candidateIds = settings.giteaLabelIds;
166
+ } else if (Array.isArray(task && task.labels)) {
167
+ candidateIds = task.labels.map((item) => {
168
+ const num = Number(item);
169
+ if (Number.isInteger(num) && num > 0) return num;
170
+ return typeof mapping === 'object' && mapping !== null ? mapping[item] : undefined;
171
+ });
172
+ } else if (typeof mapping === 'object' && mapping !== null && Object.keys(mapping).length > 0) {
173
+ candidateIds = defaultNames.map((name) => mapping[name]);
174
+ }
175
+
176
+ const labelIds = candidateIds
177
+ .map((id) => Number(id))
178
+ .filter((id) => Number.isInteger(id) && id > 0);
179
+
138
180
  return {
139
181
  title: `${failed ? '[Cron failure]' : '[Cron run]'} ${truncateText(task.title || 'Task', 200)}`,
140
182
  body: [
@@ -145,7 +187,7 @@ export function buildGiteaIssuePayload({ task, runInfo }) {
145
187
  runInfo.error ? `\n**Error:**\n\`\`\`\n${truncateText(runInfo.error, 1000)}\n\`\`\`` : '',
146
188
  runInfo.output ? `\n**Output:**\n\`\`\`\n${truncateText(runInfo.output, 1000)}\n\`\`\`` : '',
147
189
  ].filter(Boolean).join('\n\n'),
148
- labels: failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
190
+ labels: labelIds,
149
191
  };
150
192
  }
151
193
 
@@ -248,9 +290,7 @@ const CHANNEL_HANDLERS = {
248
290
  const botToken = secrets.botToken || '';
249
291
  const chatId = String(settings.chatId || '').trim();
250
292
  if (!botToken || !chatId) throw new Error('telegram: botToken or chatId is not configured');
251
- const text = settings.template
252
- ? messageTextFor('telegram', task, runInfo, settings)
253
- : formatTaskTelegramMessage(task, runInfo);
293
+ const text = messageTextFor('telegram', task, runInfo, settings);
254
294
  try {
255
295
  const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
256
296
  await sendTelegramMessage({ botToken, chatId, text, replyMarkup, fetchFn: http, signal });
@@ -321,7 +361,7 @@ const CHANNEL_HANDLERS = {
321
361
  if (!base || !repo) throw new Error('gitea: giteaBaseUrl and giteaRepo are required');
322
362
  const token = settings.giteaTokenRef ? await resolveSecret(settings.giteaTokenRef) : '';
323
363
  if (!token) throw new Error(`gitea: credential "${settings.giteaTokenRef || ''}" did not resolve`);
324
- const payload = buildGiteaIssuePayload({ task, runInfo });
364
+ const payload = buildGiteaIssuePayload({ task, runInfo, settings });
325
365
  const issue = await postJson(
326
366
  http,
327
367
  `${base}/api/v1/repos/${repo}/issues`,
package/lib/client.js CHANGED
@@ -1499,11 +1499,32 @@ window.__ModuleLoader__.load({
1499
1499
 
1500
1500
  const actionInProgressRef = React.useRef(new Set());
1501
1501
 
1502
+ const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1503
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1504
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
1505
+ try {
1506
+ const res = await fetch(url, {
1507
+ ...options,
1508
+ signal: controller ? controller.signal : options.signal,
1509
+ });
1510
+ return res;
1511
+ } catch (err) {
1512
+ if (err && err.name === 'AbortError') {
1513
+ const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
1514
+ timeoutErr.isTimeout = true;
1515
+ throw timeoutErr;
1516
+ }
1517
+ throw err;
1518
+ } finally {
1519
+ if (timer) clearTimeout(timer);
1520
+ }
1521
+ };
1522
+
1502
1523
  const handleToggleTask = async (id) => {
1503
1524
  if (actionInProgressRef.current.has(id)) return;
1504
1525
  actionInProgressRef.current.add(id);
1505
1526
  try {
1506
- const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
1527
+ const res = await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
1507
1528
  const data = await res.json().catch(() => ({}));
1508
1529
  if (!res.ok || (data && data.ok === false)) {
1509
1530
  const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
@@ -1526,7 +1547,7 @@ window.__ModuleLoader__.load({
1526
1547
  const prevStatus = prevTask ? prevTask.lastStatus : null;
1527
1548
  setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
1528
1549
  try {
1529
- const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' });
1550
+ const res = await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
1530
1551
  const data = await res.json().catch(() => ({}));
1531
1552
  if (!res.ok || (data && data.ok === false)) {
1532
1553
  setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { bestEffort } from './best-effort.js';
2
3
  import { setLogger, logger } from './logger.js';
3
4
  import { registerPluginUpdater } from './updater.js';
4
5
  import { credentialRefStatus } from './secrets.js';
@@ -182,16 +183,67 @@ export function apply(ctx, rawConfig) {
182
183
 
183
184
  scheduler.start();
184
185
 
186
+ // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
187
+ // external monitor can alert when this scheduler stops responding.
185
188
  // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
186
189
  // external monitor can alert when this scheduler stops responding.
187
190
  let heartbeatTimer = null;
188
- if (config && config.heartbeatUrl && Number(config.heartbeatIntervalSec) > 0) {
189
- const intervalMs = Number(config.heartbeatIntervalSec) * 1000;
190
- heartbeatTimer = setInterval(() => {
191
- fetch(config.heartbeatUrl, { method: 'GET' }).catch((err) => {
192
- logger.warn('[dsh-cron] heartbeat ping failed:', err.message);
193
- });
194
- }, intervalMs);
191
+ function updateHeartbeat(url, intervalSec) {
192
+ if (heartbeatTimer) {
193
+ clearInterval(heartbeatTimer);
194
+ heartbeatTimer = null;
195
+ }
196
+ const cleanUrl = String(url || '').trim();
197
+ const cleanSec = Number(intervalSec) || 0;
198
+ if (cleanUrl && cleanSec > 0) {
199
+ heartbeatTimer = setInterval(() => {
200
+ fetch(cleanUrl, { method: 'GET' }).catch((err) => {
201
+ logger.warn('[dsh-cron] heartbeat ping failed:', err.message);
202
+ });
203
+ }, cleanSec * 1000);
204
+ if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
205
+ }
206
+ }
207
+ updateHeartbeat(config && config.heartbeatUrl, config && config.heartbeatIntervalSec);
208
+
209
+ // Synchronize volatile Config settings to runtime scheduler and store (#235)
210
+ let activeVolatileConfig = {};
211
+ function syncVolatileConfig(updatedConfig) {
212
+ try {
213
+ const cfg = updatedConfig || ctx.config || config || {};
214
+ const unwrapped = unwrapConfig(cfg);
215
+ activeVolatileConfig = { ...activeVolatileConfig, ...unwrapped };
216
+ if (typeof unwrapped.maxConcurrent === 'number') {
217
+ scheduler.maxConcurrent = unwrapped.maxConcurrent;
218
+ }
219
+ if (typeof unwrapped.defaultTimezone === 'string') {
220
+ scheduler.defaultTimezone = unwrapped.defaultTimezone;
221
+ }
222
+ if (typeof unwrapped.llmActionsEnabled === 'boolean') {
223
+ scheduler.llmActionsEnabled = unwrapped.llmActionsEnabled;
224
+ }
225
+ if (unwrapped.heartbeatUrl !== undefined || unwrapped.heartbeatIntervalSec !== undefined) {
226
+ updateHeartbeat(unwrapped.heartbeatUrl, unwrapped.heartbeatIntervalSec);
227
+ }
228
+ const current = store.getSettings();
229
+ const patch = {};
230
+ for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
231
+ if (unwrapped[k] !== undefined && unwrapped[k] !== current[k]) {
232
+ patch[k] = unwrapped[k];
233
+ }
234
+ }
235
+ if (Object.keys(patch).length > 0) {
236
+ store.saveSettings(patch);
237
+ }
238
+ } catch (err) {
239
+ logger.warn('[dsh-cron] error syncing volatile config:', err.message);
240
+ }
241
+ }
242
+
243
+ if (typeof ctx.on === 'function') {
244
+ ctx.on('loader/volatile-update', (updatedConfig) => {
245
+ syncVolatileConfig(updatedConfig);
246
+ });
195
247
  }
196
248
 
197
249
  ctx.effect(() => {
@@ -228,7 +280,17 @@ export function apply(ctx, rawConfig) {
228
280
  handler: async (req, res) => {
229
281
  try {
230
282
  if (req.method === 'GET') {
231
- sendJson(res, 200, { ok: true, settings: { ...store.getClientSettings(), version: pkgVersion } });
283
+ const unwrapped = {
284
+ ...unwrapConfig(ctx.config || config || {}),
285
+ ...activeVolatileConfig,
286
+ };
287
+ const clientSettings = store.getClientSettings();
288
+ for (const k of ['maxConcurrent', 'defaultTimezone', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled']) {
289
+ if (unwrapped[k] !== undefined) {
290
+ clientSettings[k] = unwrapped[k];
291
+ }
292
+ }
293
+ sendJson(res, 200, { ok: true, settings: { ...clientSettings, version: pkgVersion } });
232
294
  return;
233
295
  }
234
296
  if (req.method === 'POST') {
@@ -236,12 +298,29 @@ export function apply(ctx, rawConfig) {
236
298
  const { body, error } = await readBody(req, res);
237
299
  if (error) return;
238
300
  const payload = sanitizeSettingsPayload(body);
239
- store.saveSettings(payload);
240
301
  const scopeErrors = await applySettingsToScope(settingsService, payload);
302
+ if (scopeErrors && scopeErrors.length > 0) {
303
+ sendJson(res, 500, {
304
+ ok: false,
305
+ error: `Settings scope update failed: ${scopeErrors.join('; ')}`,
306
+ scopeErrors,
307
+ });
308
+ return;
309
+ }
310
+ store.saveSettings(payload);
311
+ if (payload.maxConcurrent !== undefined && Number.isFinite(Number(payload.maxConcurrent))) {
312
+ scheduler.maxConcurrent = Number(payload.maxConcurrent);
313
+ }
314
+ if (payload.defaultTimezone !== undefined) {
315
+ scheduler.defaultTimezone = String(payload.defaultTimezone || '').trim();
316
+ }
317
+ if (payload.heartbeatUrl !== undefined || payload.heartbeatIntervalSec !== undefined) {
318
+ const current = store.getSettings();
319
+ updateHeartbeat(current.heartbeatUrl, current.heartbeatIntervalSec);
320
+ }
241
321
  sendJson(res, 200, {
242
322
  ok: true,
243
323
  settings: { ...store.getClientSettings(), version: pkgVersion },
244
- scopeErrors: (scopeErrors && scopeErrors.length > 0) ? scopeErrors : undefined
245
324
  });
246
325
  return;
247
326
  }
@@ -112,11 +112,29 @@ export async function runViaRemoteWorkspace(task, signal, ctx) {
112
112
 
113
113
  let profile = null;
114
114
  profile = bestEffort('get-remoteSsh-profile', () => {
115
+ const remoteProfilesSvc = ctx.get('remoteProfiles');
116
+ if (remoteProfilesSvc && typeof remoteProfilesSvc.list === 'function') {
117
+ const list = remoteProfilesSvc.list();
118
+ if (Array.isArray(list)) {
119
+ const found = list.find((p) => p && p.id === task.sshProfileId);
120
+ if (found) return found;
121
+ }
122
+ }
115
123
  const settings = ctx.get('settings');
116
- if (settings && typeof settings.get === 'function') {
117
- const snap = settings.get('dsh-remote-workspace');
118
- const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
119
- return profiles.find((p) => p && p.id === task.sshProfileId) || null;
124
+ if (settings) {
125
+ if (typeof settings.describe === 'function') {
126
+ const desc = settings.describe();
127
+ const entry = Array.isArray(desc) ? desc.find((r) => r && r.ns === 'dsh-remote-workspace') : null;
128
+ const snap = entry ? entry.value : null;
129
+ const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
130
+ const found = profiles.find((p) => p && p.id === task.sshProfileId);
131
+ if (found) return found;
132
+ }
133
+ if (typeof settings.get === 'function') {
134
+ const snap = settings.get('dsh-remote-workspace');
135
+ const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
136
+ return profiles.find((p) => p && p.id === task.sshProfileId) || null;
137
+ }
120
138
  }
121
139
  return null;
122
140
  });
package/lib/runner.js CHANGED
@@ -423,22 +423,41 @@ export class SessionRunner {
423
423
  turnEvents = handle.agent.log.slice(startSeq);
424
424
  }
425
425
 
426
- // 3. Terminal turn failure check (#227)
426
+ // Extract usage FIRST so failed turns and budget checks have accurate token and cost accounting (#228)
427
+ const usage = this._extractUsage(handle, turnEvents);
428
+ const costUsd = estimateTokenCost(prep.model, usage);
429
+
430
+ // 3. Terminal turn failure and settlement check (#227)
431
+ let hasTerminalEvent = false;
427
432
  for (const ev of turnEvents) {
428
433
  if (!ev) continue;
429
434
  if (ev.type === 'turn/end' || ev.type === 'turn/error' || ev.type === 'turn/failure') {
435
+ hasTerminalEvent = true;
430
436
  const reason = ev.data?.reason || ev.reason;
431
437
  const reasonKind = (reason && typeof reason === 'object') ? reason.kind : reason;
432
- if (['error', 'interrupted', 'failed', 'aborted'].includes(reasonKind) || ev.data?.error || ev.error) {
433
- const errMsg = ev.data?.error?.message || ev.error?.message || reason?.message || (typeof reason === 'string' ? reason : 'Agent turn ended with error');
438
+ if (['error', 'interrupted', 'failed', 'aborted', 'blocked'].includes(reasonKind) || ev.data?.error || ev.error) {
439
+ const errMsg = ev.data?.error?.message || ev.error?.message || reason?.message || (typeof reason === 'string' ? reason : `Agent turn ended with status: ${reasonKind || 'error'}`);
434
440
  const err = new Error(errMsg);
435
441
  err.reason = reason;
442
+ err.reasonKind = reasonKind;
436
443
  err.turnFailed = true;
444
+ err.usage = usage;
445
+ err.costUsd = costUsd;
446
+ err.sessionId = handle.agent.session?.id || null;
437
447
  throw err;
438
448
  }
439
449
  }
440
450
  }
441
451
 
452
+ if (turnEvents.length > 0 && !hasTerminalEvent) {
453
+ const err = new Error('Agent turn ended without terminal completion event');
454
+ err.turnFailed = true;
455
+ err.usage = usage;
456
+ err.costUsd = costUsd;
457
+ err.sessionId = handle.agent.session?.id || null;
458
+ throw err;
459
+ }
460
+
442
461
  // 4. Extract assistant message text from turn events (#227)
443
462
  const messageTexts = [];
444
463
  const chunkTexts = [];
@@ -476,11 +495,10 @@ export class SessionRunner {
476
495
  outputText = `[dsh-cron] Agent finished turn for task "${task.title}" (${sessionNote})`;
477
496
  }
478
497
 
479
- const usage = this._extractUsage(handle, turnEvents);
480
498
  return {
481
499
  output: outputText,
482
500
  usage,
483
- costUsd: estimateTokenCost(prep.model, usage),
501
+ costUsd,
484
502
  sessionId: handle.agent.session?.id || null,
485
503
  };
486
504
  } finally {
@@ -541,27 +559,35 @@ export class SessionRunner {
541
559
  }
542
560
  }
543
561
 
544
- // assistant/attempt usage (e.g. paid failed attempts or retry attempts)
545
- if (ev.type === 'assistant/attempt' && ev.data?.usage) {
546
- const u = ev.data.usage;
547
- if (ev.data.failed || ev.data.error || ev.data.status === 'failed') {
548
- failedAttempts.push({
562
+ // assistant/attempt usage (e.g. paid failed attempts, retries, streaming attempts) (#228)
563
+ if (ev.type === 'assistant/attempt') {
564
+ let attemptUsage = null;
565
+ if (ev.data?.usage) {
566
+ const u = ev.data.usage;
567
+ attemptUsage = {
549
568
  inputTokens: Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0,
550
569
  outputTokens: Number(u.outputTokens || u.completionTokens) || 0,
551
570
  cacheReadTokens: Number(u.cacheReadTokens || u.cachedTokens) || 0,
552
- });
553
- hasEventUsage = true;
554
- } else {
555
- const key = `${ev.data.turn || 0}:${ev.data.step || 0}`;
556
- if (!stepUsage.has(key)) {
557
- stepUsage.set(key, {
558
- inputTokens: Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0,
559
- outputTokens: Number(u.outputTokens || u.completionTokens) || 0,
560
- cacheReadTokens: Number(u.cacheReadTokens || u.cachedTokens) || 0,
561
- });
562
- hasEventUsage = true;
571
+ };
572
+ }
573
+ // DSH 0.2.0-rc.2 AssistantStreamRecord: stream contains usage chunks
574
+ if (Array.isArray(ev.data?.stream)) {
575
+ for (const item of ev.data.stream) {
576
+ const u = item?.chunk?.usage || item?.usage;
577
+ if (u) {
578
+ if (!attemptUsage) {
579
+ attemptUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
580
+ }
581
+ attemptUsage.inputTokens += Number(u.inputTokens || u.promptTokens || u.uncachedInputTokens) || 0;
582
+ attemptUsage.outputTokens += Number(u.outputTokens || u.completionTokens) || 0;
583
+ attemptUsage.cacheReadTokens += Number(u.cacheReadTokens || u.cachedTokens) || 0;
584
+ }
563
585
  }
564
586
  }
587
+ if (attemptUsage) {
588
+ failedAttempts.push(attemptUsage);
589
+ hasEventUsage = true;
590
+ }
565
591
  }
566
592
  }
567
593
 
package/lib/runtimes.js CHANGED
@@ -46,14 +46,15 @@ function looksLikeScriptFile(token, extensions) {
46
46
 
47
47
  /** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
48
48
  export function buildNodeInvocation(task, cwd) {
49
+ const nodeExecutable = task.nodePath || process.execPath;
49
50
  const { path: filePath, args } = splitCommandLine(task.prompt);
50
51
  if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
51
- return { file: process.execPath, args: [filePath, ...args] };
52
+ return { file: nodeExecutable, args: [filePath, ...args] };
52
53
  }
53
54
  const code = String(task.prompt || '');
54
55
  const isEsm = /\bimport\s|\bexport\s/.test(code);
55
56
  const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
56
- return { file: task.nodePath || process.execPath, args: nodeArgs };
57
+ return { file: nodeExecutable, args: nodeArgs };
57
58
  }
58
59
 
59
60
  /** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
@@ -146,6 +146,9 @@ export async function executeOnce(scheduler, task, signal, options = {}) {
146
146
  result.error = err.message || String(err);
147
147
  const timedOut = result.error.toLowerCase().includes('timed out') || result.error.toLowerCase().includes('timeout');
148
148
  result.status = timedOut ? 'timeout' : 'error';
149
+ if (err.usage) result.usage = err.usage;
150
+ if (typeof err.costUsd === 'number') result.costUsd = err.costUsd;
151
+ if (err.sessionId) result.sessionId = err.sessionId;
149
152
  }
150
153
  return result;
151
154
  }
@@ -261,6 +264,48 @@ export async function executeTask(scheduler, taskId, options = {}) {
261
264
  const task = scheduler.store.get(taskId);
262
265
  if (!task) return null;
263
266
 
267
+ // Dry-run path: read-only evaluation without mutating store, history, attempts, or triggering alerts (#229)
268
+ if (options.dryRun) {
269
+ const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
270
+ const guard = checkTaskBudgetLimits(task, runs);
271
+ if (guard.exceeded) {
272
+ return {
273
+ ok: false,
274
+ dryRun: true,
275
+ status: 'error',
276
+ error: guard.reason,
277
+ preflightBlocked: true,
278
+ };
279
+ }
280
+ const preflight = await executePreflight(task);
281
+ if (!preflight.ok) {
282
+ return {
283
+ ok: false,
284
+ dryRun: true,
285
+ status: 'skipped',
286
+ error: `Preflight check skipped: ${preflight.reason}`,
287
+ preflightBlocked: true,
288
+ };
289
+ }
290
+ const start = Date.now();
291
+ const outcome = await scheduler.executeWithFallback(
292
+ task,
293
+ AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)),
294
+ options
295
+ );
296
+ return {
297
+ ok: outcome.status !== 'error' && outcome.status !== 'timeout',
298
+ dryRun: true,
299
+ status: outcome.status,
300
+ output: outcome.output,
301
+ error: outcome.error,
302
+ durationMs: Date.now() - start,
303
+ usage: outcome.usage,
304
+ costUsd: outcome.costUsd,
305
+ model: outcome.model || task.model || '',
306
+ };
307
+ }
308
+
264
309
  // Pre-flight Burn Guard check (#181): prevent executing task if budget already breached
265
310
  const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
266
311
  const guard = checkTaskBudgetLimits(task, runs);
@@ -277,22 +322,6 @@ export async function executeTask(scheduler, taskId, options = {}) {
277
322
  scheduler.store.set(task);
278
323
  }
279
324
 
280
- if (options.dryRun) {
281
- const start = Date.now();
282
- const outcome = await scheduler.executeWithFallback(task, AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)), options);
283
- return {
284
- ok: true,
285
- dryRun: true,
286
- status: outcome.status,
287
- output: outcome.output,
288
- error: outcome.error,
289
- durationMs: Date.now() - start,
290
- usage: outcome.usage,
291
- costUsd: outcome.costUsd,
292
- model: outcome.model || task.model || '',
293
- };
294
- }
295
-
296
325
  const currentRun = scheduler.beginRun(task, taskId, options);
297
326
  if (currentRun === null) return null;
298
327
 
@@ -377,12 +406,23 @@ export async function applyModelAssists(task, runInfo, options) {
377
406
  /**
378
407
  * Deliver a finished run to every configured channel (#26).
379
408
  */
380
- export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver }) {
409
+ export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver, secretTimeoutMs = 5000 }) {
381
410
  try {
382
411
  let settings = typeof store?.getSettings === 'function' ? store.getSettings() : {};
383
412
  if (resolveSecrets) {
384
413
  try {
385
- settings = { ...settings, ...(await resolveSecrets(settings)) };
414
+ let timer;
415
+ const deadline = new Promise((_, reject) => {
416
+ timer = setTimeout(() => reject(new Error(`credential resolution timed out after ${secretTimeoutMs}ms`)), secretTimeoutMs);
417
+ if (typeof timer.unref === 'function') timer.unref();
418
+ });
419
+ const resolved = await Promise.race([
420
+ Promise.resolve(resolveSecrets(settings)).finally(() => {
421
+ if (timer) clearTimeout(timer);
422
+ }),
423
+ deadline,
424
+ ]);
425
+ settings = { ...settings, ...(resolved || {}) };
386
426
  } catch (secErr) {
387
427
  logger.warn('[dsh-cron] credential resolution failed:', secErr.message);
388
428
  }
package/lib/settings.js CHANGED
@@ -24,6 +24,7 @@ export const Config = z.object({
24
24
  maxConcurrent: z.number().default(2).description('Max parallel task runs (0 = unlimited, default 2)').volatile(),
25
25
  heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval').volatile(),
26
26
  heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)').volatile(),
27
+ llmActionsEnabled: z.boolean().default(false).description('Enable LLM actions execution').volatile(),
27
28
  // --- Delivery channels (#20-#23, #26, #28, #47) ---
28
29
  template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}').volatile(),
29
30
  channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id').volatile(),
@@ -77,6 +78,7 @@ export function unwrapConfig(cfg) {
77
78
 
78
79
  export const SETTINGS_SYNC_KEYS = [
79
80
  'botToken', 'botTokenRef', 'telegramWebhookSecret', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
81
+ 'defaultTimezone', 'maxConcurrent', 'heartbeatUrl', 'heartbeatIntervalSec', 'llmActionsEnabled',
80
82
  'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
81
83
  'discordWebhookUrl', 'slackWebhookUrl',
82
84
  'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
package/lib/store.js CHANGED
@@ -39,6 +39,7 @@ export class TaskStore {
39
39
  this._saveTimer = null;
40
40
  this._savePromise = Promise.resolve();
41
41
  this._saveVersion = 0;
42
+ this._diskVersion = 0;
42
43
  this._lastWrittenVersion = 0;
43
44
  this.init();
44
45
  }
@@ -178,7 +179,7 @@ export class TaskStore {
178
179
  };
179
180
 
180
181
  const writeOp = async () => {
181
- if (this._lastWrittenVersion >= version) {
182
+ if ((this._diskVersion || 0) >= version) {
182
183
  return;
183
184
  }
184
185
  const dir = path.dirname(this.filePath);
@@ -191,13 +192,31 @@ export class TaskStore {
191
192
  await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
192
193
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
193
194
 
194
- if (this._lastWrittenVersion >= version) {
195
+ if ((this._diskVersion || 0) >= version || this._saveVersion > version) {
195
196
  await fs.promises.unlink(tmp).catch(() => {});
196
197
  return;
197
198
  }
198
199
 
199
200
  await fs.promises.rename(tmp, this.filePath);
200
201
  bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
202
+
203
+ // Monotonic versioning & sync save interleaving repair (#244)
204
+ if ((this._diskVersion || 0) > version) {
205
+ const syncData = {
206
+ version: 1,
207
+ updatedAt: new Date().toISOString(),
208
+ tasks: Array.from(this.tasks.values()),
209
+ history: Object.fromEntries(this.history.entries()),
210
+ settings: this.settings || {},
211
+ };
212
+ const randRep = Math.random().toString(36).slice(2, 8);
213
+ const tmpRep = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${randRep}`;
214
+ fs.writeFileSync(tmpRep, JSON.stringify(syncData, null, 2), { encoding: 'utf-8', mode: 0o600 });
215
+ fs.renameSync(tmpRep, this.filePath);
216
+ return;
217
+ }
218
+
219
+ this._diskVersion = version;
201
220
  this._lastWrittenVersion = version;
202
221
 
203
222
  const now = Date.now();
@@ -243,7 +262,8 @@ export class TaskStore {
243
262
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
244
263
  fs.renameSync(tmp, this.filePath);
245
264
  bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
246
- this._lastWrittenVersion = version;
265
+ this._diskVersion = Math.max(this._diskVersion || 0, version);
266
+ this._lastWrittenVersion = this._diskVersion;
247
267
 
248
268
  // Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
249
269
  const now = Date.now();
@@ -411,8 +431,12 @@ export class TaskStore {
411
431
  delivery: task.delivery !== undefined ? task.delivery : (prev.delivery || 'isolated'),
412
432
  provider: task.provider !== undefined ? task.provider : prev.provider,
413
433
  model: task.model !== undefined ? task.model : prev.model,
414
- notifyTelegram: task.notifyTelegram !== undefined ? Boolean(task.notifyTelegram) : (prev.notifyTelegram || false),
415
- onlyOnFailure: task.onlyOnFailure !== undefined ? Boolean(task.onlyOnFailure) : (prev.onlyOnFailure || false),
434
+ notifyTelegram: task.notifyTelegram !== undefined
435
+ ? (task.notifyTelegram === null || task.notifyTelegram === 'inherit' ? undefined : Boolean(task.notifyTelegram))
436
+ : prev.notifyTelegram,
437
+ onlyOnFailure: task.onlyOnFailure !== undefined
438
+ ? (task.onlyOnFailure === null || task.onlyOnFailure === 'inherit' ? undefined : Boolean(task.onlyOnFailure))
439
+ : prev.onlyOnFailure,
416
440
  timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
417
441
  overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
418
442
  kanbanMode: task.kanbanMode !== undefined ? String(task.kanbanMode) : (prev.kanbanMode || 'none'), // 'none' | 'on_failure' | 'always'
@@ -35,8 +35,8 @@ export function executeCreateTask(store, scheduler, args) {
35
35
  priority: Number(args.priority) || 5,
36
36
  selfHealingCommand: args.selfHealingCommand ? String(args.selfHealingCommand).trim() : '',
37
37
  autoDiagnose: Boolean(args.autoDiagnose),
38
- notifyTelegram: Boolean(args.notifyTelegram),
39
- onlyOnFailure: Boolean(args.onlyOnFailure),
38
+ notifyTelegram: args.notifyTelegram !== undefined ? Boolean(args.notifyTelegram) : undefined,
39
+ onlyOnFailure: args.onlyOnFailure !== undefined ? Boolean(args.onlyOnFailure) : undefined,
40
40
  timeoutSeconds: Number(args.timeoutSeconds) || 1800,
41
41
  overlapPolicy: args.overlapPolicy || 'skip',
42
42
  timezone: args.timezone ? String(args.timezone).trim() : '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.40",
3
+ "version": "0.2.42",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",