@goodandready/dsh-cron 0.2.8 → 0.2.9

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
@@ -317,6 +317,14 @@ Environment used by the check: `DSH_WEB_BASE` (default `http://127.0.0.1:3080`),
317
317
  ### 20. Internal Refactor: Schedule Parsing and Arming (#97)
318
318
  Developer-facing, no behaviour change. `parseScheduleExpression` was split into small functions that keep the same branch order — `parseAtExpression`, `parseRelativeOneShot`, `parseIntervalExpression`, `parseAliasExpression`, `parseCronExpression` — and `scheduleTask` into `clearScheduled`, `scheduleOneShot` and `scheduleCron`. The existing test suite passed unchanged and targeted tests were added for branch precedence and error messages.
319
319
 
320
+ ### 21. Performance & Process Isolation Pack (Added in v0.2.9, #134)
321
+ - **Process-Tree Termination**: Shell and script tasks run in an isolated process group (`detached: true` on POSIX); aborts and timeouts send `SIGTERM` followed by `SIGKILL` to the entire group (`-child.pid`) to eliminate orphan and zombie processes.
322
+ - **Concurrency Throttling**: Default safe limit `maxConcurrent = 2` prevents CPU and memory spikes during overlapping scheduled runs.
323
+ - **Transient Error Retry**: Exponential backoff retry (up to 3 attempts) for transient network and provider errors (`429`, `502`, `503`, `504`, `ECONNRESET`).
324
+ - **Network & UI Optimization**: `GET /dsh-cron/tasks` provides `ETag` and responds with `304 Not Modified`; client UI adapts polling frequency (`visibilitychange`: 30s in background tabs, 8s in active tabs).
325
+ - **History Rotation & Archival**: Active store holds the latest 100 runs per task; older entries are automatically archived into `tasks-history-archive.json`.
326
+ - **Autonomous PR Reviewer Recipe (#33)**: Preconfigured recipe in Template Hub with optional toggle `prReviewerEnabled` in settings.
327
+
320
328
  ---
321
329
 
322
330
  ## 📦 Installation
package/lib/api.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * unchanged.
6
6
  */
7
7
 
8
- import { randomUUID } from 'node:crypto';
8
+ import { randomUUID, createHash } from 'node:crypto';
9
9
  import { parseScheduleExpression } from './scheduler.js';
10
10
  import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
11
11
  import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
@@ -88,7 +88,7 @@ async function handleTaskCollection({ store, scheduler, collection, req, res, ur
88
88
  return;
89
89
  }
90
90
  if (req.method === 'GET') {
91
- listTasks({ store, scheduler, collection, res, url });
91
+ listTasks({ store, scheduler, collection, req, res, url });
92
92
  return;
93
93
  }
94
94
  if (req.method === 'POST') {
@@ -98,7 +98,7 @@ async function handleTaskCollection({ store, scheduler, collection, req, res, ur
98
98
  sendJson(res, 405, NOT_ALLOWED);
99
99
  }
100
100
 
101
- function listTasks({ store, scheduler, collection, res, url }) {
101
+ function listTasks({ store, scheduler, collection, req, res, url }) {
102
102
  const status = url.searchParams.get('status') || 'all';
103
103
  const query = url.searchParams.get('query') || '';
104
104
  const list = store.list({ status, query }).map((t) => ({
@@ -107,7 +107,26 @@ function listTasks({ store, scheduler, collection, res, url }) {
107
107
  runningSince: scheduler.runningSince(t.id),
108
108
  }));
109
109
  const stats = store.getAggregatedStats();
110
- sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
110
+ const payload = { ok: true, tasks: list, recommendations: collection.recommendations, stats };
111
+
112
+ // Calculate ETag based on payload JSON representation (#134)
113
+ const bodyString = JSON.stringify(payload);
114
+ const etag = 'W/"' + createHash('sha1').update(bodyString).digest('hex') + '"';
115
+
116
+ if (req && req.headers) {
117
+ const ifNoneMatch = req.headers['if-none-match'];
118
+ if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === etag.slice(2))) {
119
+ res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'no-cache' });
120
+ res.end();
121
+ return;
122
+ }
123
+ }
124
+
125
+ if (typeof res.setHeader === 'function') {
126
+ res.setHeader('ETag', etag);
127
+ res.setHeader('Cache-Control', 'no-cache');
128
+ }
129
+ sendJson(res, 200, payload);
111
130
  }
112
131
 
113
132
  async function createOrUpdateTask({ store, scheduler, req, res }) {
package/lib/client.js CHANGED
@@ -1035,11 +1035,29 @@ window.__ModuleLoader__.load({
1035
1035
  }
1036
1036
  };
1037
1037
 
1038
+ const lastEtagRef = React.useRef('');
1039
+
1038
1040
  React.useEffect(() => {
1039
1041
  fetchTasks();
1040
- const pollInterval = setInterval(() => { // Background polling without resetting loading spinner
1041
- fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
1042
- .then(res => res.json())
1042
+
1043
+ let timerId = null;
1044
+ let isDestroyed = false;
1045
+
1046
+ const doPoll = () => {
1047
+ if (isDestroyed) return;
1048
+ const headers = {};
1049
+ if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
1050
+
1051
+ fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), { headers })
1052
+ .then(res => {
1053
+ if (res.status === 304) {
1054
+ // Not modified: skip parsing JSON
1055
+ return null;
1056
+ }
1057
+ const etag = res.headers.get('ETag');
1058
+ if (etag) lastEtagRef.current = etag;
1059
+ return res.json();
1060
+ })
1043
1061
  .then(data => {
1044
1062
  if (data && data.ok) {
1045
1063
  setTasks(data.tasks || []);
@@ -1047,9 +1065,36 @@ window.__ModuleLoader__.load({
1047
1065
  setStats(data.stats || null);
1048
1066
  }
1049
1067
  })
1050
- .catch(() => {});
1051
- }, 8000);
1052
- return () => clearInterval(pollInterval);
1068
+ .catch(() => {})
1069
+ .finally(() => {
1070
+ if (isDestroyed) return;
1071
+ // Adaptive interval: 30s when tab is hidden, 8s when active (#134)
1072
+ const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
1073
+ timerId = setTimeout(doPoll, intervalMs);
1074
+ });
1075
+ };
1076
+
1077
+ // Trigger immediate fetch when visibility returns
1078
+ const onVisibilityChange = () => {
1079
+ if (typeof document !== 'undefined' && !document.hidden) {
1080
+ if (timerId) clearTimeout(timerId);
1081
+ doPoll();
1082
+ }
1083
+ };
1084
+
1085
+ if (typeof document !== 'undefined') {
1086
+ document.addEventListener('visibilitychange', onVisibilityChange);
1087
+ }
1088
+
1089
+ timerId = setTimeout(doPoll, 8000);
1090
+
1091
+ return () => {
1092
+ isDestroyed = true;
1093
+ if (timerId) clearTimeout(timerId);
1094
+ if (typeof document !== 'undefined') {
1095
+ document.removeEventListener('visibilitychange', onVisibilityChange);
1096
+ }
1097
+ };
1053
1098
  }, [tab, query]);
1054
1099
  React.useEffect(() => { loadModels(); }, []);
1055
1100
 
package/lib/index.js CHANGED
@@ -100,6 +100,7 @@ export const Config = z.object({
100
100
  giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
101
101
  giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
102
102
  giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
103
+ prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)'),
103
104
  // --- Declarative jobs (#50, ADR-0001) ---
104
105
  // The array is intentionally permissive: each entry is validated on its own
105
106
  // with a precise message (see lib/config-jobs.js), so one broken entry cannot
@@ -122,7 +123,7 @@ export const SETTINGS_SYNC_KEYS = [
122
123
  'pushplusTokenRef',
123
124
  'pushplusUrl',
124
125
  'ttsBaseUrl',
125
- 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
126
+ 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'prReviewerEnabled',
126
127
  'apiToken',
127
128
  ];
128
129
 
package/lib/recipes.js CHANGED
@@ -19,6 +19,7 @@ export const RECIPE_CATEGORIES = [
19
19
  { id: 'storage', title: 'Storage' },
20
20
  { id: 'security', title: 'Security' },
21
21
  { id: 'maintenance', title: 'Maintenance windows' },
22
+ { id: 'ci', title: 'CI & Code review' },
22
23
  ];
23
24
 
24
25
  /** Commands a recipe must never contain, checked by the test suite. */
@@ -190,6 +191,18 @@ export const RECIPES = [
190
191
  prompt: 'uptime && echo "---" && uname -r && echo "---" && df -h / | tail -n 1',
191
192
  channels: ['telegram'],
192
193
  },
194
+ {
195
+ id: 'recipe_pr_reviewer',
196
+ category: 'ci',
197
+ title: 'Autonomous PR Reviewer (#33)',
198
+ description: 'Periodic inspection of open Pull Requests in Gitea with agent review analysis.',
199
+ type: 'llm',
200
+ schedule: '*/30 * * * *',
201
+ scheduleText: 'Every 30 minutes',
202
+ prompt: 'Check open Pull Requests in Gitea repository. Inspect changed diffs, evaluate code quality, verify test coverage and post a concise review summary.',
203
+ channels: ['telegram', 'gitea'],
204
+ silentRule: 'Stay silent when there are no open PRs or no new commits to review.',
205
+ },
193
206
  ];
194
207
 
195
208
  /** Detached copy of the catalog, safe to hand to the UI. */
package/lib/runner.js CHANGED
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Helper to test if an error code or status is transient and eligible for retry (#134).
3
+ */
4
+ export function isTransientError(errOrStatus) {
5
+ if (typeof errOrStatus === 'number') {
6
+ return errOrStatus === 429 || errOrStatus === 502 || errOrStatus === 503 || errOrStatus === 504;
7
+ }
8
+ const str = String(errOrStatus?.message || errOrStatus || '').toLowerCase();
9
+ return str.includes('429') || str.includes('too many requests') ||
10
+ str.includes('502') || str.includes('bad gateway') ||
11
+ str.includes('503') || str.includes('service unavailable') ||
12
+ str.includes('504') || str.includes('gateway timeout') ||
13
+ str.includes('econnreset') || str.includes('etimedout');
14
+ }
15
+
1
16
  import { estimateTokenCost } from './integrations.js';
2
17
  import {
3
18
  EXTERNAL_RUNTIMES,
@@ -99,24 +114,44 @@ export class SessionRunner {
99
114
  });
100
115
  }
101
116
 
102
- /** HTTP trigger runtime (#7). */
117
+ /** HTTP trigger runtime (#7) with transient retry (#134). */
103
118
  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)}`);
119
+ let attempts = 0;
120
+ const maxAttempts = 3;
121
+ while (attempts < maxAttempts) {
122
+ attempts++;
123
+ try {
124
+ const res = await fetch(request.url, {
125
+ method: request.method,
126
+ headers: request.headers,
127
+ body: request.body,
128
+ signal,
129
+ });
130
+ const text = await res.text().catch(() => '');
131
+ const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
132
+ if (!res.ok) {
133
+ const err = new Error(`${summary}\n${String(text).slice(0, 500)}`);
134
+ if (isTransientError(res.status) && attempts < maxAttempts && !signal?.aborted) {
135
+ console.warn(`[dsh-cron] HTTP request returned ${res.status}, retrying attempt ${attempts + 1}/${maxAttempts}...`);
136
+ await new Promise((r) => setTimeout(r, attempts * 1000));
137
+ continue;
138
+ }
139
+ throw err;
140
+ }
141
+ return {
142
+ output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
143
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
144
+ costUsd: 0,
145
+ };
146
+ } catch (err) {
147
+ if (isTransientError(err) && attempts < maxAttempts && !signal?.aborted) {
148
+ console.warn(`[dsh-cron] HTTP request failed (${err.message}), retrying attempt ${attempts + 1}/${maxAttempts}...`);
149
+ await new Promise((r) => setTimeout(r, attempts * 1000));
150
+ continue;
151
+ }
152
+ throw err;
153
+ }
114
154
  }
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
155
  }
121
156
 
122
157
  /**
@@ -172,30 +207,89 @@ export class SessionRunner {
172
207
  const invocation = buildInvocation({ ...task, type: runtime }, cwd);
173
208
  if (invocation.http) return this._runHttp(task, invocation.http, signal);
174
209
 
175
- const { exec, execFile } = await import('node:child_process');
210
+ const { spawn } = await import('node:child_process');
176
211
  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'));
212
+ let child = null;
213
+ let settled = false;
214
+ let stdout = '';
215
+ let stderr = '';
216
+ const maxBuffer = 10 * 1024 * 1024;
217
+ const isPosix = process.platform !== 'win32';
218
+
219
+ const killProcessTree = (sig = 'SIGTERM') => {
220
+ if (!child || child.killed) return;
221
+ try {
222
+ if (isPosix && child.pid) {
223
+ try { process.kill(-child.pid, sig); } catch {}
224
+ } else if (child.pid) {
225
+ child.kill(sig);
181
226
  }
227
+ } catch {}
228
+ };
229
+
230
+ const abortHandler = () => {
231
+ killProcessTree('SIGTERM');
232
+ setTimeout(() => killProcessTree('SIGKILL'), 300);
233
+ };
234
+
235
+ if (signal) {
236
+ if (signal.aborted) {
237
+ return reject(signal.reason || new Error('Command aborted'));
238
+ }
239
+ signal.addEventListener('abort', abortHandler, { once: true });
240
+ }
241
+
242
+ const options = {
243
+ cwd,
244
+ env: invocation.env,
245
+ detached: isPosix,
246
+ stdio: ['ignore', 'pipe', 'pipe'],
247
+ };
248
+
249
+ if (invocation.file) {
250
+ child = spawn(invocation.file, invocation.args || [], options);
251
+ } else {
252
+ const shellPath = isPosix ? (process.env.SHELL || '/bin/bash') : (process.env.ComSpec || 'cmd.exe');
253
+ const shellArgs = isPosix ? ['-c', invocation.shellCommand] : ['/d', '/s', '/c', invocation.shellCommand];
254
+ child = spawn(shellPath, shellArgs, options);
255
+ }
256
+
257
+ child.stdout.on('data', (chunk) => {
258
+ if (stdout.length < maxBuffer) stdout += chunk;
259
+ });
260
+ child.stderr.on('data', (chunk) => {
261
+ if (stderr.length < maxBuffer) stderr += chunk;
262
+ });
263
+
264
+ child.on('error', (err) => {
265
+ if (settled) return;
266
+ settled = true;
267
+ if (signal) signal.removeEventListener('abort', abortHandler);
268
+ reject(err);
269
+ });
270
+
271
+ child.on('close', (code, sig) => {
272
+ if (settled) return;
273
+ settled = true;
274
+ if (signal) signal.removeEventListener('abort', abortHandler);
275
+
276
+ if (signal && signal.aborted) {
277
+ return reject(signal.reason || new Error('Command aborted'));
278
+ }
279
+
280
+ if (code !== 0 && code !== null) {
182
281
  const detail = String(stderr || '').trim();
183
- return reject(new Error(detail ? `${err.message}\n${detail}` : err.message));
282
+ const msg = `${runtime} task exited with code ${code}`;
283
+ return reject(new Error(detail ? `${msg}\n${detail}` : msg));
184
284
  }
285
+
185
286
  const out = (stdout || stderr || '').trim();
186
287
  resolve({
187
288
  output: out || `${runtime} task completed with code 0`,
188
289
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
189
290
  costUsd: 0,
190
291
  });
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
- }
292
+ });
199
293
  });
200
294
  }
201
295
 
package/lib/scheduler.js CHANGED
@@ -169,7 +169,7 @@ export class TaskScheduler {
169
169
  constructor(store, executeFn, options = {}) {
170
170
  this.store = store;
171
171
  this.executeFn = executeFn;
172
- this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
172
+ this.maxConcurrent = options.maxConcurrent !== undefined && Number(options.maxConcurrent) >= 0 ? Number(options.maxConcurrent) : 2; // default 2 parallel runs (#134, #52)
173
173
  this.defaultTimezone = options.defaultTimezone || ''; // #13
174
174
  this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
175
175
  this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
package/lib/store.js CHANGED
@@ -295,11 +295,41 @@ export class TaskStore {
295
295
  suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
296
296
  confidence: runInfo.confidence || '',
297
297
  });
298
- if (runs.length > 50) runs.length = 50;
298
+ // Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
299
+ if (runs.length > 100) {
300
+ const overflow = runs.splice(100);
301
+ this._archiveHistoryRuns(id, overflow);
302
+ }
299
303
  this.history.set(id, runs);
300
304
  this.save();
301
305
  }
302
306
 
307
+ /**
308
+ * Append rotated overflow history entries into tasks-history-archive.json (#134).
309
+ */
310
+ _archiveHistoryRuns(id, overflowRuns) {
311
+ if (!Array.isArray(overflowRuns) || overflowRuns.length === 0) return;
312
+ try {
313
+ const dir = path.dirname(this.filePath);
314
+ const archivePath = path.join(dir, 'tasks-history-archive.json');
315
+ let archive = {};
316
+ if (fs.existsSync(archivePath)) {
317
+ try {
318
+ archive = JSON.parse(fs.readFileSync(archivePath, 'utf-8')) || {};
319
+ } catch {}
320
+ }
321
+ if (!Array.isArray(archive[id])) archive[id] = [];
322
+ archive[id].push(...overflowRuns);
323
+ // Write atomically with tmp file
324
+ const rand = Math.random().toString(36).slice(2, 8);
325
+ const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
326
+ fs.writeFileSync(tmp, JSON.stringify(archive, null, 2), 'utf-8');
327
+ fs.renameSync(tmp, archivePath);
328
+ } catch (archiveErr) {
329
+ console.error('[dsh-cron] history archive error:', archiveErr.message);
330
+ }
331
+ }
332
+
303
333
  getHistory(id, limit = 20) {
304
334
  const runs = this.history.get(id) || [];
305
335
  return runs.slice(0, limit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",