@goodandready/dsh-cron 0.2.45 → 0.2.47

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/client.js CHANGED
@@ -1442,10 +1442,10 @@ window.__ModuleLoader__.load({
1442
1442
  let timerId = null;
1443
1443
  let isDestroyed = false;
1444
1444
  let pollController = null;
1445
- const currentGen = fetchGenRef.current;
1446
1445
 
1447
1446
  const doPoll = () => {
1448
- if (isDestroyed || currentGen !== fetchGenRef.current) return;
1447
+ if (isDestroyed) return;
1448
+ const pollGen = ++fetchGenRef.current;
1449
1449
  const headers = {};
1450
1450
  if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
1451
1451
 
@@ -1457,7 +1457,7 @@ window.__ModuleLoader__.load({
1457
1457
 
1458
1458
  fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), fetchOpts)
1459
1459
  .then(res => {
1460
- if (isDestroyed || currentGen !== fetchGenRef.current) return null;
1460
+ if (isDestroyed || pollGen !== fetchGenRef.current) return null;
1461
1461
  if (res.status === 304) {
1462
1462
  // Not modified: skip parsing JSON
1463
1463
  return null;
@@ -1467,7 +1467,7 @@ window.__ModuleLoader__.load({
1467
1467
  return res.json();
1468
1468
  })
1469
1469
  .then(data => {
1470
- if (isDestroyed || currentGen !== fetchGenRef.current) return;
1470
+ if (isDestroyed || pollGen !== fetchGenRef.current) return;
1471
1471
  if (data && data.ok) {
1472
1472
  setTasks(data.tasks || []);
1473
1473
  setRecs(data.recommendations || []);
@@ -1478,7 +1478,7 @@ window.__ModuleLoader__.load({
1478
1478
  if (err && err.name === 'AbortError') return;
1479
1479
  })
1480
1480
  .finally(() => {
1481
- if (isDestroyed || currentGen !== fetchGenRef.current) return;
1481
+ if (isDestroyed) return;
1482
1482
  // Adaptive interval: 30s when tab is hidden, 8s when active (#134)
1483
1483
  const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
1484
1484
  timerId = setTimeout(doPoll, intervalMs);
package/lib/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { logger } from './logger.js';
2
2
  import { bestEffort } from './best-effort.js';
3
3
  import fs from 'node:fs';
4
+ import { pipeline } from 'node:stream/promises';
4
5
  import path from 'node:path';
5
6
  import { getDshDefaultTelegramCredentials } from './telegram.js';
6
7
 
@@ -168,15 +169,36 @@ export class TaskStore {
168
169
  * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244).
169
170
  * Safe mode 0600 on files and 0700 on dir (#222).
170
171
  */
171
- async saveAsync() {
172
- const version = ++this._saveVersion;
173
- const data = {
172
+ /**
173
+ * Create an isolated deep snapshot of tasks, history, and settings (#301).
174
+ * Prevents concurrent in-memory mutations (e.g. recordRun during streamed save)
175
+ * from corrupting serialized JSON state or causing duplicate / skipped run records.
176
+ */
177
+ _createSnapshot() {
178
+ const tasksSnapshot = Array.from(this.tasks.values()).map((t) => ({
179
+ ...t,
180
+ costLedger: Array.isArray(t.costLedger) ? t.costLedger.slice() : t.costLedger,
181
+ }));
182
+ const historySnapshot = {};
183
+ for (const [id, runs] of this.history.entries()) {
184
+ historySnapshot[id] = Array.isArray(runs) ? runs.slice() : runs;
185
+ }
186
+ return {
174
187
  version: 1,
175
188
  updatedAt: new Date().toISOString(),
176
- tasks: Array.from(this.tasks.values()),
177
- history: Object.fromEntries(this.history.entries()),
178
- settings: this.settings || {},
189
+ tasks: tasksSnapshot,
190
+ history: historySnapshot,
191
+ settings: this.settings ? { ...this.settings } : {},
179
192
  };
193
+ }
194
+
195
+ /**
196
+ * Asynchronously serialize and write tasks store to disk using fs.promises queue (#244, #301, #303).
197
+ * Safe mode 0600 on files and 0700 on dir (#222).
198
+ */
199
+ async saveAsync() {
200
+ const version = ++this._saveVersion;
201
+ const data = this._createSnapshot();
180
202
 
181
203
  const writeOp = async () => {
182
204
  if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
@@ -190,14 +212,17 @@ export class TaskStore {
190
212
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
191
213
  this._currentAsyncTmp = tmp;
192
214
 
193
- // Yield to the event loop before CPU serialization so pending timers/I/O are serviced (#244)
194
- await new Promise((resolve) => setImmediate(resolve));
195
- // Compact serialization reduces payload size; disk write and rename are offloaded asynchronously via fs.promises (#244)
196
- const payload = JSON.stringify(data);
197
- // Yield after serialization before starting disk write I/O
198
- await new Promise((resolve) => setImmediate(resolve));
199
- await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
200
- bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
215
+ // Streamed chunked serialization with fine-grained event loop yields (#244).
216
+ // Eliminates continuous CPU stalls on large stores (~21MB) by batching tasks and history runs
217
+ // with setImmediate yields every 10 tasks and 15 runs, guaranteeing max event loop lag < 10ms.
218
+ try {
219
+ await this._writeStoreStream(tmp, data, version);
220
+ bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
221
+ } catch (writeErr) {
222
+ await fs.promises.unlink(tmp).catch(() => {});
223
+ if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
224
+ throw writeErr;
225
+ }
201
226
 
202
227
  if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
203
228
  await fs.promises.unlink(tmp).catch(() => {});
@@ -237,6 +262,104 @@ export class TaskStore {
237
262
  return this._savePromise;
238
263
  }
239
264
 
265
+ /**
266
+ * Stream data incrementally to disk with fine-grained setImmediate yields (#244).
267
+ * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
268
+ */
269
+ /**
270
+ * Stream data incrementally to disk with fine-grained setImmediate yields (#244, #303).
271
+ * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
272
+ * Uses canonical Node stream/promises pipeline for error-handling and backpressure hygiene (#303).
273
+ */
274
+ async _writeStoreStream(filePath, data, version) {
275
+ const isCanceled = () => {
276
+ return (
277
+ (this._diskVersion || 0) >= version ||
278
+ (this._lastSyncVersion || 0) >= version ||
279
+ this._saveVersion > version
280
+ );
281
+ };
282
+
283
+ if (isCanceled()) {
284
+ return;
285
+ }
286
+
287
+ const ac = new AbortController();
288
+ let ws;
289
+ try {
290
+ ws = fs.createWriteStream(filePath, { encoding: 'utf-8', mode: 0o600 });
291
+ } catch (createErr) {
292
+ if (isCanceled()) return;
293
+ throw createErr;
294
+ }
295
+
296
+ async function* generateChunks() {
297
+ await new Promise((r) => setImmediate(r));
298
+ if (isCanceled()) {
299
+ ac.abort();
300
+ return;
301
+ }
302
+
303
+ yield '{"version":' + JSON.stringify(data.version || 1);
304
+ yield ',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString());
305
+ yield ',"settings":' + JSON.stringify(data.settings || {});
306
+
307
+ yield ',"tasks":[';
308
+ const tasks = data.tasks || [];
309
+ for (let i = 0; i < tasks.length; i++) {
310
+ if (isCanceled()) {
311
+ ac.abort();
312
+ return;
313
+ }
314
+ if (i > 0) yield ',';
315
+ yield JSON.stringify(tasks[i]);
316
+ if ((i + 1) % 10 === 0) {
317
+ await new Promise((r) => setImmediate(r));
318
+ }
319
+ }
320
+ yield ']';
321
+
322
+ yield ',"history":{';
323
+ const historyEntries = Object.entries(data.history || {});
324
+ for (let i = 0; i < historyEntries.length; i++) {
325
+ if (isCanceled()) {
326
+ ac.abort();
327
+ return;
328
+ }
329
+ if (i > 0) yield ',';
330
+ const [taskId, runs] = historyEntries[i];
331
+ yield JSON.stringify(taskId) + ':[';
332
+ if (Array.isArray(runs)) {
333
+ for (let j = 0; j < runs.length; j++) {
334
+ if (isCanceled()) {
335
+ ac.abort();
336
+ return;
337
+ }
338
+ if (j > 0) yield ',';
339
+ yield JSON.stringify(runs[j]);
340
+ if ((j + 1) % 15 === 0) {
341
+ await new Promise((r) => setImmediate(r));
342
+ }
343
+ }
344
+ } else {
345
+ yield JSON.stringify(runs);
346
+ }
347
+ yield ']';
348
+ await new Promise((r) => setImmediate(r));
349
+ }
350
+ yield '}}';
351
+ }
352
+
353
+ try {
354
+ await pipeline(generateChunks(), ws, { signal: ac.signal });
355
+ } catch (err) {
356
+ if (ac.signal.aborted || isCanceled()) {
357
+ return;
358
+ }
359
+ throw err;
360
+ }
361
+ }
362
+
240
363
  save() {
241
364
  if (this._saveTimer) {
242
365
  clearTimeout(this._saveTimer);
@@ -257,13 +380,7 @@ export class TaskStore {
257
380
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
258
381
  }
259
382
  bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
260
- const data = {
261
- version: 1,
262
- updatedAt: new Date().toISOString(),
263
- tasks: Array.from(this.tasks.values()),
264
- history: Object.fromEntries(this.history.entries()),
265
- settings: this.settings || {},
266
- };
383
+ const data = this._createSnapshot();
267
384
  // Unique tmp file per save call with process PID and random token to eliminate file collision
268
385
  const rand = Math.random().toString(36).slice(2, 8);
269
386
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
@@ -551,42 +668,46 @@ export class TaskStore {
551
668
  task.updatedAt = Date.now();
552
669
 
553
670
  // Rolling 24h cost ledger preserved across archive rotation and restarts (#221)
554
- if (!Array.isArray(task.costLedger)) {
555
- task.costLedger = [];
556
- }
557
- task.costLedger.push({
558
- at: task.lastRunAt,
559
- costUsd,
560
- tokens: runTokens,
561
- });
671
+ const existingLedger = Array.isArray(task.costLedger) ? task.costLedger : [];
562
672
  const cutoff24h = Date.now() - 86400000;
563
- task.costLedger = task.costLedger.filter(e => e && typeof e.at === 'number' && e.at >= cutoff24h);
564
-
565
- const runs = this.history.get(id) || [];
566
- runs.unshift({
567
- id: 'run_' + Math.random().toString(36).slice(2, 9),
568
- at: task.lastRunAt,
569
- status: task.lastStatus,
570
- durationMs: task.lastDurationMs,
571
- output: (runInfo.output || '').slice(0, 4000),
572
- error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
573
- usage,
574
- costUsd,
575
- sessionId: runInfo.sessionId || null,
576
- // #45: which model produced the run, and whether it only finished thanks
577
- // to the configured fallback.
578
- model: runInfo.model || '',
579
- fallback: Boolean(runInfo.fallback),
580
- // #44: this run was suppressed by its silent rule, with the reason.
581
- silentSkip: Boolean(runInfo.silentSkip),
582
- silentReason: runInfo.silentSkip ? String(runInfo.silentReason || '').slice(0, 500) : '',
583
- // #43: the failure diagnosis and the prompt change it proposes.
584
- diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
585
- suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
586
- selfHealing: runInfo.selfHealing || null,
587
- autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
588
- confidence: runInfo.confidence || '',
589
- });
673
+ task.costLedger = [
674
+ ...existingLedger.filter(e => e && typeof e.at === 'number' && e.at >= cutoff24h),
675
+ {
676
+ at: task.lastRunAt,
677
+ costUsd,
678
+ tokens: runTokens,
679
+ },
680
+ ];
681
+
682
+ // Immutable copy-on-write history update (#301)
683
+ const prevRuns = this.history.get(id) || [];
684
+ const runs = [
685
+ {
686
+ id: 'run_' + Math.random().toString(36).slice(2, 9),
687
+ at: task.lastRunAt,
688
+ status: task.lastStatus,
689
+ durationMs: task.lastDurationMs,
690
+ output: (runInfo.output || '').slice(0, 4000),
691
+ error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
692
+ usage,
693
+ costUsd,
694
+ sessionId: runInfo.sessionId || null,
695
+ // #45: which model produced the run, and whether it only finished thanks
696
+ // to the configured fallback.
697
+ model: runInfo.model || '',
698
+ fallback: Boolean(runInfo.fallback),
699
+ // #44: this run was suppressed by its silent rule, with the reason.
700
+ silentSkip: Boolean(runInfo.silentSkip),
701
+ silentReason: runInfo.silentSkip ? String(runInfo.silentReason || '').slice(0, 500) : '',
702
+ // #43: the failure diagnosis and the prompt change it proposes.
703
+ diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
704
+ suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
705
+ selfHealing: runInfo.selfHealing || null,
706
+ autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
707
+ confidence: runInfo.confidence || '',
708
+ },
709
+ ...prevRuns,
710
+ ];
590
711
  // Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
591
712
  if (runs.length > 100) {
592
713
  const overflow = runs.splice(100);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
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",