@goodandready/dsh-cron 0.2.3 → 0.2.5

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/runner.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { estimateTokenCost } from './integrations.js';
2
+ import {
3
+ EXTERNAL_RUNTIMES,
4
+ normalizeTaskType,
5
+ buildInvocation,
6
+ } from './runtimes.js';
2
7
 
3
8
  /**
4
9
  * Runner executing scheduled tasks in DeepSeek Harness sessions.
@@ -43,35 +48,164 @@ export class SessionRunner {
43
48
  }
44
49
  }
45
50
 
46
- async _run(task, signal) {
47
- console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
51
+ /**
52
+ * Resolve the working directory for a task: explicit cwd, a bound
53
+ * workspace id resolved through the harness, or the process cwd (#31).
54
+ */
55
+ resolveCwd(task) {
56
+ if (task.cwd) return String(task.cwd);
57
+ if (task.workspaceId && this.ctx && typeof this.ctx.get === 'function') {
58
+ try {
59
+ const workspaces = this.ctx.get('workspaces');
60
+ const ws = workspaces && (typeof workspaces.get === 'function'
61
+ ? workspaces.get(task.workspaceId)
62
+ : (workspaces[task.workspaceId] || null));
63
+ const resolved = ws && (ws.path || ws.cwd || ws.root || ws.dir);
64
+ if (resolved) return String(resolved);
65
+ } catch {}
66
+ return String(task.workspaceId);
67
+ }
68
+ return process.cwd();
69
+ }
48
70
 
49
- // 1. Direct shell / script task fallback (NO-LLM)
50
- if (task.type === 'script' || task.mode === 'no-llm') {
51
- const { exec } = await import('node:child_process');
52
- return new Promise((resolve, reject) => {
53
- const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
54
- const child = exec(task.prompt, {
55
- cwd: process.cwd(),
56
- signal,
57
- maxBuffer: 10 * 1024 * 1024,
58
- shell: shellPath,
59
- env: { ...process.env },
60
- }, (err, stdout, stderr) => {
61
- if (err) {
62
- if (signal && signal.aborted) {
63
- return reject(signal.reason || new Error('Command aborted'));
64
- }
65
- return reject(err);
71
+ /** Create an isolated git worktree for a code-modifying task (#29). */
72
+ async createTaskWorktree(cwd, task) {
73
+ const { execFile } = await import('node:child_process');
74
+ const path = await import('node:path');
75
+ const wtPath = path.join(cwd, '.dsh-cron-worktrees', `${task.id}-${Date.now()}`);
76
+ await new Promise((resolve, reject) => {
77
+ execFile('git', ['-C', cwd, 'worktree', 'add', '--detach', wtPath, 'HEAD'], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
78
+ if (err) reject(new Error(String(stderr || err.message).trim()));
79
+ else resolve(stdout);
80
+ });
81
+ });
82
+ return wtPath;
83
+ }
84
+
85
+ /**
86
+ * Remove a task worktree; never throws (best-effort cleanup).
87
+ * Plain removal only — no force flags anywhere (workspace rule). A dirty
88
+ * worktree is left in place and reported instead of being force-deleted.
89
+ */
90
+ async removeTaskWorktree(cwd, wtPath) {
91
+ const { execFile } = await import('node:child_process');
92
+ await new Promise((resolve) => {
93
+ execFile('git', ['-C', cwd, 'worktree', 'remove', wtPath], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
94
+ if (err) {
95
+ console.warn(`[dsh-cron] task worktree kept (not removable without force): ${wtPath} — ${String(stderr || err.message).trim()}`);
96
+ }
97
+ execFile('git', ['-C', cwd, 'worktree', 'prune'], () => resolve());
98
+ });
99
+ });
100
+ }
101
+
102
+ /** HTTP trigger runtime (#7). */
103
+ async _runHttp(task, request, signal) {
104
+ const res = await fetch(request.url, {
105
+ method: request.method,
106
+ headers: request.headers,
107
+ body: request.body,
108
+ signal,
109
+ });
110
+ const text = await res.text().catch(() => '');
111
+ const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
112
+ if (!res.ok) {
113
+ throw new Error(`${summary}\n${String(text).slice(0, 500)}`);
114
+ }
115
+ return {
116
+ output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
117
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
118
+ costUsd: 0,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * #9 reuse-first path: when the task names a dsh-remote-workspace profile,
124
+ * execute through that plugin's `remoteSsh` service so host settings and
125
+ * credentials stay owned by it (we only reference the profile id).
126
+ * Returns null when the service or the profile is unavailable so the caller
127
+ * can fall back to a direct ssh invocation.
128
+ */
129
+ async _runViaRemoteWorkspace(task, signal) {
130
+ if (!task.sshProfileId || !this.ctx || typeof this.ctx.get !== 'function') return null;
131
+ let svc = null;
132
+ try {
133
+ svc = this.ctx.get('remoteSsh');
134
+ } catch {}
135
+ if (!svc || typeof svc.exec !== 'function') return null;
136
+
137
+ let profile = null;
138
+ try {
139
+ const settings = this.ctx.get('settings');
140
+ if (settings && typeof settings.get === 'function') {
141
+ const snap = settings.get('dsh-remote-workspace');
142
+ const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
143
+ profile = profiles.find((p) => p && p.id === task.sshProfileId) || null;
144
+ }
145
+ } catch {}
146
+ if (!profile) {
147
+ throw new Error(`remote-workspace profile "${task.sshProfileId}" was not found — check dsh-remote-workspace settings, or use sshTarget instead`);
148
+ }
149
+ const res = await svc.exec(profile, String(task.prompt || 'true'), task.cwd || profile.remoteWorkspace || undefined);
150
+ const output = [res && res.stdout, res && res.stderr].filter((x) => x && String(x).trim()).join('\n').trim();
151
+ const code = res && typeof res.code === 'number' ? res.code : 0;
152
+ if (code !== 0) {
153
+ throw new Error(`Remote command exited with code ${code}\n${output}`.slice(0, 4000));
154
+ }
155
+ return {
156
+ output: output || 'Remote command completed with code 0',
157
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
158
+ costUsd: 0,
159
+ };
160
+ }
161
+
162
+ /** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
163
+ async _runExternal(task, runtime, signal) {
164
+ if (runtime === 'ssh' && task.sshProfileId) {
165
+ const viaService = await this._runViaRemoteWorkspace(task, signal);
166
+ if (viaService) return viaService;
167
+ if (!task.sshTarget) {
168
+ throw new Error('SSH task needs either a working dsh-remote-workspace profile (sshProfileId) or an sshTarget');
169
+ }
170
+ }
171
+ const cwd = this.resolveCwd(task);
172
+ const invocation = buildInvocation({ ...task, type: runtime }, cwd);
173
+ if (invocation.http) return this._runHttp(task, invocation.http, signal);
174
+
175
+ const { exec, execFile } = await import('node:child_process');
176
+ return new Promise((resolve, reject) => {
177
+ const done = (err, stdout, stderr) => {
178
+ if (err) {
179
+ if (signal && signal.aborted) {
180
+ return reject(signal.reason || new Error('Command aborted'));
66
181
  }
67
- const out = (stdout || stderr || '').trim();
68
- resolve({
69
- output: out || 'Script completed with code 0',
70
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
71
- costUsd: 0
72
- });
182
+ const detail = String(stderr || '').trim();
183
+ return reject(new Error(detail ? `${err.message}\n${detail}` : err.message));
184
+ }
185
+ const out = (stdout || stderr || '').trim();
186
+ resolve({
187
+ output: out || `${runtime} task completed with code 0`,
188
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
189
+ costUsd: 0,
73
190
  });
74
- });
191
+ };
192
+ const options = { cwd, signal, maxBuffer: 10 * 1024 * 1024, env: invocation.env };
193
+ if (invocation.file) {
194
+ execFile(invocation.file, invocation.args || [], options, done);
195
+ } else {
196
+ const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
197
+ exec(invocation.shellCommand, { ...options, shell: shellPath }, done);
198
+ }
199
+ });
200
+ }
201
+
202
+ async _run(task, signal) {
203
+ console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
204
+
205
+ // 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
206
+ const runtime = normalizeTaskType(task.type);
207
+ if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
208
+ return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
75
209
  }
76
210
 
77
211
  // 2. If ctx.agents is available, run through agent session (LLM)
@@ -121,7 +255,26 @@ export class SessionRunner {
121
255
  ]);
122
256
  };
123
257
 
124
- const cwd = process.cwd();
258
+ const baseCwd = this.resolveCwd(task);
259
+ // #29: code-modifying tasks may run in an isolated git worktree.
260
+ let cwd = baseCwd;
261
+ let taskWorktree = null;
262
+ if (task.worktree) {
263
+ try {
264
+ taskWorktree = await this.createTaskWorktree(baseCwd, task);
265
+ cwd = taskWorktree;
266
+ } catch (wtErr) {
267
+ console.warn('[dsh-cron] task worktree not created, running in the workspace:', wtErr.message);
268
+ }
269
+ }
270
+ // #6/#10: skill- and workflow-driven tasks are agent-mediated with a
271
+ // shaped prompt naming the skill/workflow.
272
+ let agentPrompt = String(task.prompt || '');
273
+ if (runtime === 'skill' && task.skillName) {
274
+ agentPrompt = `Use the DSH skill "${task.skillName}" for this task:\n${agentPrompt}`;
275
+ } else if (runtime === 'workflow' && task.workflowName) {
276
+ agentPrompt = `Execute this through the DSH workflow "${task.workflowName}" step by step:\n${agentPrompt}`;
277
+ }
125
278
  let handle = null;
126
279
  try {
127
280
  handle = await this.ctx.agents.create({
@@ -144,7 +297,7 @@ export class SessionRunner {
144
297
 
145
298
  await raceAbort(handle.agent.whenIdle());
146
299
  handle.agent.followup(createUserMessage({
147
- content: [{ type: 'text', text: task.prompt }],
300
+ content: [{ type: 'text', text: agentPrompt }],
148
301
  source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
149
302
  }));
150
303
 
@@ -199,6 +352,10 @@ export class SessionRunner {
199
352
  }
200
353
  } catch {}
201
354
  }
355
+ // #29: dispose the isolated worktree (kept when keepWorktree is set).
356
+ if (taskWorktree && !task.keepWorktree) {
357
+ await this.removeTaskWorktree(baseCwd, taskWorktree);
358
+ }
202
359
  }
203
360
  } catch (err) {
204
361
  console.error(`[dsh-cron] agent execution failed:`, err.message);
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Runtime invocation builders for non-LLM task types (#4, #5, #7, #8, #9, #38).
3
+ *
4
+ * Every builder is a pure function returning the process invocation
5
+ * ({ file, args, env }) or the HTTP request description, so the whole matrix
6
+ * is unit-testable without spawning anything.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
13
+ export const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
14
+ export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
15
+
16
+ /** Types that execute code and therefore need the HTTP confirm header. */
17
+ export const CODE_EXECUTING_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
18
+
19
+ export function normalizeTaskType(type) {
20
+ return TASK_TYPES.includes(type) ? type : 'llm';
21
+ }
22
+
23
+ /**
24
+ * Split a leading script path from trailing CLI arguments.
25
+ * Quoted segments are preserved as single arguments.
26
+ */
27
+ export function splitCommandLine(text) {
28
+ const trimmed = String(text || '').trim();
29
+ if (!trimmed) return { path: '', args: [] };
30
+ const tokens = trimmed.match(/"[^"]*"|'[^']*'|\S+/g) || [];
31
+ const clean = tokens.map((t) => t.replace(/^["']|["']$/g, ''));
32
+ return { path: clean[0] || '', args: clean.slice(1) };
33
+ }
34
+
35
+ function looksLikeScriptFile(token, extensions) {
36
+ if (!token) return false;
37
+ const ext = path.extname(token).toLowerCase();
38
+ if (extensions.includes(ext)) return true;
39
+ try {
40
+ return fs.existsSync(token) && fs.statSync(token).isFile();
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
47
+ export function buildNodeInvocation(task, cwd) {
48
+ const { path: filePath, args } = splitCommandLine(task.prompt);
49
+ if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
50
+ return { file: process.execPath, args: [filePath, ...args] };
51
+ }
52
+ const code = String(task.prompt || '');
53
+ const isEsm = /\bimport\s|\bexport\s/.test(code);
54
+ const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
55
+ return { file: task.nodePath || process.execPath, args: nodeArgs };
56
+ }
57
+
58
+ /** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
59
+ export function detectPython(task, cwd) {
60
+ if (task.pythonPath) return task.pythonPath;
61
+ const candidates = [];
62
+ if (process.env.VIRTUAL_ENV) {
63
+ candidates.push(path.join(process.env.VIRTUAL_ENV, 'bin', 'python'));
64
+ candidates.push(path.join(process.env.VIRTUAL_ENV, 'Scripts', 'python.exe'));
65
+ }
66
+ const base = cwd || process.cwd();
67
+ candidates.push(path.join(base, '.venv', 'bin', 'python'));
68
+ candidates.push(path.join(base, '.venv', 'Scripts', 'python.exe'));
69
+ candidates.push(path.join(base, 'venv', 'bin', 'python'));
70
+ for (const candidate of candidates) {
71
+ try {
72
+ if (fs.existsSync(candidate)) return candidate;
73
+ } catch {}
74
+ }
75
+ return process.platform === 'win32' ? 'python' : 'python3';
76
+ }
77
+
78
+ /** #4: resolve a Python invocation (inline code or a .py file). */
79
+ export function buildPythonInvocation(task, cwd) {
80
+ const interpreter = detectPython(task, cwd);
81
+ const { path: filePath, args } = splitCommandLine(task.prompt);
82
+ if (looksLikeScriptFile(filePath, ['.py'])) {
83
+ return { file: interpreter, args: [filePath, ...args] };
84
+ }
85
+ return { file: interpreter, args: ['-c', String(task.prompt || '')] };
86
+ }
87
+
88
+ /** #7: build the HTTP trigger request description. */
89
+ export function buildHttpRequest(task) {
90
+ const raw = String(task.httpUrl || task.prompt || '').trim();
91
+ if (!raw) throw new Error('HTTP task requires a URL (httpUrl or prompt)');
92
+ let url;
93
+ try {
94
+ url = new URL(raw);
95
+ } catch {
96
+ throw new Error(`Invalid HTTP URL: ${raw}`);
97
+ }
98
+ let headers = {};
99
+ if (task.httpHeaders) {
100
+ if (typeof task.httpHeaders === 'object') {
101
+ headers = { ...task.httpHeaders };
102
+ } else {
103
+ try {
104
+ headers = JSON.parse(String(task.httpHeaders));
105
+ } catch {
106
+ throw new Error('httpHeaders must be a JSON object');
107
+ }
108
+ }
109
+ }
110
+ const method = String(task.httpMethod || 'GET').toUpperCase();
111
+ const body = task.httpBody !== undefined && task.httpBody !== '' ? String(task.httpBody) : undefined;
112
+ return { url: url.toString(), method, headers, body };
113
+ }
114
+
115
+ /** #9: build a remote SSH invocation. */
116
+ export function buildSshInvocation(task) {
117
+ const target = String(task.sshTarget || '').trim();
118
+ if (!target) throw new Error('SSH task requires sshTarget (user@host)');
119
+ const args = [];
120
+ if (task.sshPort) args.push('-p', String(Number(task.sshPort)));
121
+ if (task.sshKeyPath) args.push('-i', String(task.sshKeyPath));
122
+ args.push(target, String(task.prompt || 'true'));
123
+ return { file: 'ssh', args };
124
+ }
125
+
126
+ /** #8: build a Docker run invocation. */
127
+ export function buildDockerInvocation(task, env) {
128
+ const image = String(task.dockerImage || '').trim();
129
+ if (!image) throw new Error('Docker task requires dockerImage');
130
+ const args = ['run', '--rm'];
131
+ for (const [key, value] of Object.entries(env || {})) {
132
+ if (key === 'PATH' || key === 'HOME') continue; // container owns those
133
+ args.push('-e', `${key}=${value}`);
134
+ }
135
+ if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
136
+ args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
137
+ return { file: 'docker', args };
138
+ }
139
+
140
+ /** #38: merge task env vars over the base environment. */
141
+ export function buildEnv(task, baseEnv) {
142
+ const env = { ...(baseEnv || process.env) };
143
+ if (task && task.env && typeof task.env === 'object') {
144
+ for (const [key, value] of Object.entries(task.env)) {
145
+ if (value === null || value === undefined) delete env[key];
146
+ else env[key] = String(value);
147
+ }
148
+ }
149
+ return env;
150
+ }
151
+
152
+ /**
153
+ * Dispatch an external (non-LLM) runtime to its invocation.
154
+ * Returns { file, args } plus an optional `http` descriptor for the fetch path.
155
+ */
156
+ export function buildInvocation(task, cwd, baseEnv) {
157
+ const type = normalizeTaskType(task.type);
158
+ const env = buildEnv(task, baseEnv);
159
+ switch (type) {
160
+ case 'node':
161
+ return { type, env, ...buildNodeInvocation(task, cwd) };
162
+ case 'python':
163
+ return { type, env, ...buildPythonInvocation(task, cwd) };
164
+ case 'http':
165
+ return { type, env, http: buildHttpRequest(task) };
166
+ case 'ssh':
167
+ return { type, env, ...buildSshInvocation(task) };
168
+ case 'docker':
169
+ return { type, env, ...buildDockerInvocation(task, env) };
170
+ case 'script':
171
+ default:
172
+ return { type: 'script', env, file: null, args: null, shellCommand: String(task.prompt || '') };
173
+ }
174
+ }
package/lib/scheduler.js CHANGED
@@ -1,6 +1,4 @@
1
1
  import { Cron } from 'croner';
2
- import { shouldNotifyTask, formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
3
- import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
4
2
 
5
3
  function pad(v) {
6
4
  return String(v).padStart(2, '0');
@@ -142,6 +140,8 @@ export class TaskScheduler {
142
140
  this.executeFn = executeFn;
143
141
  this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
144
142
  this.defaultTimezone = options.defaultTimezone || ''; // #13
143
+ this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
144
+ this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
145
145
  this.jobs = new Map(); // taskId -> Cron instance
146
146
  this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
147
147
  this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
@@ -266,9 +266,11 @@ export class TaskScheduler {
266
266
  this.store.set(task);
267
267
 
268
268
  // If target is in the past, or when delay triggers:
269
- const timer = setTimeout(async () => {
269
+ const timer = setTimeout(() => {
270
270
  this.timers.delete(task.id);
271
- await this.runTask(task.id);
271
+ this.runTask(task.id).catch((err) => {
272
+ console.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
273
+ });
272
274
  }, delay);
273
275
 
274
276
  this.timers.set(task.id, timer);
@@ -276,7 +278,11 @@ export class TaskScheduler {
276
278
  }
277
279
 
278
280
  // Handle Recurring Cron job
279
- const job = new Cron(parsed.cronPattern, { protect: true, ...(timezone ? { timezone } : {}) }, async () => {
281
+ const job = new Cron(parsed.cronPattern, {
282
+ protect: true,
283
+ catch: (err) => console.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
284
+ ...(timezone ? { timezone } : {}),
285
+ }, async () => {
280
286
  await this.runTask(task.id);
281
287
  });
282
288
 
@@ -388,7 +394,11 @@ export class TaskScheduler {
388
394
  costUsd,
389
395
  sessionId,
390
396
  };
391
- this.store.recordRun(taskId, runInfo);
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);
401
+ }
392
402
 
393
403
  // Clean up running map
394
404
  const finishedRun = this.running.get(taskId);
@@ -398,58 +408,48 @@ export class TaskScheduler {
398
408
 
399
409
  const queuedCount = finishedRun?.queueCount || 0;
400
410
 
401
- // 1. Telegram notification delivery
402
- try {
403
- const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
404
- if (shouldNotifyTask(task, runInfo, settings)) {
405
- const botToken = settings.botToken;
406
- const chatId = settings.chatId;
407
- if (botToken && chatId) {
408
- const text = formatTaskTelegramMessage(task, runInfo);
409
- sendTelegramMessage({ botToken, chatId, text }).catch((err) => {
410
- console.error(`[dsh-cron] telegram delivery failed for task "${task.title}":`, err.message);
411
- });
412
- }
413
- }
414
- } catch (notifyErr) {
415
- console.error('[dsh-cron] notification error:', notifyErr.message);
416
- }
411
+ await this.deliverNotifications(task, runInfo);
412
+ this.finishRun(task, taskId, status, queuedCount);
413
+ }
414
+ }
417
415
 
418
- // 2. Kanban card creation
419
- try {
420
- if (shouldCreateKanbanCard(task, runInfo)) {
421
- const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
422
- const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
423
- const cardTitle = isErr ? `[Cron failure] ${task.title}` : `[Cron completed] ${task.title}`;
424
- const cardBody = [
425
- `**Cron task:** ${task.title} (\`${task.id}\`)`,
426
- `**Schedule:** ${task.scheduleText || task.schedule}`,
427
- `**Status:** ${runInfo.status}`,
428
- `**Started at:** ${new Date(runInfo.at).toISOString()}`,
429
- runInfo.error ? `\n**Error:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
430
- runInfo.output ? `\n**Log / Output:**\n\`\`\`\n${runInfo.output.slice(0, 1000)}\n\`\`\`` : '',
431
- ].filter(Boolean).join('\n\n');
432
-
433
- createKanbanCard({
434
- title: cardTitle,
435
- body: cardBody,
436
- board: 'main',
437
- column: isErr ? 'backlog' : 'done',
438
- labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
439
- kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000'
440
- }).then((res) => {
441
- if (res.success) {
442
- console.log(`[dsh-cron] created kanban card for task "${task.title}"`);
443
- }
444
- }).catch((kErr) => {
445
- console.error('[dsh-cron] kanban card error:', kErr.message);
446
- });
416
+ /**
417
+ * Deliver a finished run to every configured channel (#26).
418
+ * Never throws: a broken or slow channel is reported, not propagated, so the
419
+ * cron callback (and therefore the next tick) is never blocked by delivery.
420
+ */
421
+ async deliverNotifications(task, runInfo) {
422
+ try {
423
+ let settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
424
+ // #51: resolve delivery credentials through the credentials service
425
+ // (credential reference → legacy token → gateway file → env).
426
+ if (this.resolveSecrets) {
427
+ try {
428
+ settings = { ...settings, ...(await this.resolveSecrets(settings)) };
429
+ } catch (secErr) {
430
+ console.warn('[dsh-cron] credential resolution failed:', secErr.message);
447
431
  }
448
- } catch (kanbanErr) {
449
- console.error('[dsh-cron] kanban error:', kanbanErr.message);
450
432
  }
433
+ if (typeof this.deliver !== 'function') return;
434
+ const result = await this.deliver(task, runInfo, settings);
435
+ if (result && Array.isArray(result.delivered) && result.delivered.length) {
436
+ console.log(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
437
+ }
438
+ if (result && Array.isArray(result.failures) && result.failures.length) {
439
+ console.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
440
+ }
441
+ } catch (notifyErr) {
442
+ console.error('[dsh-cron] notification error:', notifyErr.message);
443
+ }
444
+ }
451
445
 
452
- // 3. Reschedule or mark completed for one-shot
446
+ /**
447
+ * Post-run bookkeeping: one-shot completion, next-run timestamp, retry
448
+ * backoff and draining a queued run. Each part is guarded so a store error
449
+ * cannot reject the cron callback.
450
+ */
451
+ finishRun(task, taskId, status, queuedCount) {
452
+ try {
453
453
  if (task.oneShot) {
454
454
  task.status = 'completed';
455
455
  task.nextRunAt = null;
@@ -462,8 +462,12 @@ export class TaskScheduler {
462
462
  this.store.set(task);
463
463
  }
464
464
  }
465
+ } catch (err) {
466
+ console.error('[dsh-cron] failed to persist the next run of task', taskId + ':', err.message);
467
+ }
465
468
 
466
- // 4. Retry with exponential backoff on failure (#18)
469
+ try {
470
+ // Retry with exponential backoff on failure (#18)
467
471
  const failed = status === 'error' || status === 'timeout';
468
472
  const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
469
473
  if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
@@ -481,12 +485,14 @@ export class TaskScheduler {
481
485
  task.attempts = 0;
482
486
  this.store.set(task);
483
487
  }
488
+ } catch (err) {
489
+ console.error('[dsh-cron] retry bookkeeping failed for task', taskId + ':', err.message);
490
+ }
484
491
 
485
- if (queuedCount > 0) {
486
- setImmediate(() => {
487
- this.runTask(taskId);
488
- });
489
- }
492
+ if (queuedCount > 0) {
493
+ setImmediate(() => {
494
+ this.runTask(taskId);
495
+ });
490
496
  }
491
497
  }
492
498