@goodandready/dsh-cron 0.2.42 → 0.2.43

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
@@ -1499,7 +1499,7 @@ window.__ModuleLoader__.load({
1499
1499
 
1500
1500
  const actionInProgressRef = React.useRef(new Set());
1501
1501
 
1502
- const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1502
+ const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1503
1503
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1504
1504
  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
1505
1505
  try {
@@ -1507,7 +1507,8 @@ window.__ModuleLoader__.load({
1507
1507
  ...options,
1508
1508
  signal: controller ? controller.signal : options.signal,
1509
1509
  });
1510
- return res;
1510
+ const data = await res.json().catch(() => ({}));
1511
+ return { res, data };
1511
1512
  } catch (err) {
1512
1513
  if (err && err.name === 'AbortError') {
1513
1514
  const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
@@ -1519,13 +1520,13 @@ window.__ModuleLoader__.load({
1519
1520
  if (timer) clearTimeout(timer);
1520
1521
  }
1521
1522
  };
1523
+ const fetchWithTimeout = fetchJsonWithTimeout;
1522
1524
 
1523
1525
  const handleToggleTask = async (id) => {
1524
1526
  if (actionInProgressRef.current.has(id)) return;
1525
1527
  actionInProgressRef.current.add(id);
1526
1528
  try {
1527
- const res = await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
1528
- const data = await res.json().catch(() => ({}));
1529
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' }, 15000);
1529
1530
  if (!res.ok || (data && data.ok === false)) {
1530
1531
  const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
1531
1532
  alert(msg);
@@ -1547,8 +1548,7 @@ window.__ModuleLoader__.load({
1547
1548
  const prevStatus = prevTask ? prevTask.lastStatus : null;
1548
1549
  setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
1549
1550
  try {
1550
- const res = await fetchWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
1551
- const data = await res.json().catch(() => ({}));
1551
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' }, 15000);
1552
1552
  if (!res.ok || (data && data.ok === false)) {
1553
1553
  setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
1554
1554
  const msg = (data && data.error) || `Run failed (HTTP ${res.status})`;
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { bestEffort } from './best-effort.js';
3
4
  import { setLogger, logger } from './logger.js';
4
5
  import { registerPluginUpdater } from './updater.js';
@@ -119,7 +120,8 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgUrl, 'utf8')).version || '0.2.2
119
120
  export function apply(ctx, rawConfig) {
120
121
  if (ctx && ctx.logger) setLogger(ctx.logger);
121
122
  const config = unwrapConfig(rawConfig);
122
- const store = new TaskStore();
123
+ const storePath = (config && (config.cronDir || config.storePath)) ? (config.storePath || path.join(config.cronDir, 'tasks.json')) : undefined;
124
+ const store = new TaskStore(storePath);
123
125
 
124
126
  // Sync settings declared in the profile config into the store (#184)
125
127
  if (config && typeof config === 'object') {
@@ -210,7 +212,8 @@ export function apply(ctx, rawConfig) {
210
212
  let activeVolatileConfig = {};
211
213
  function syncVolatileConfig(updatedConfig) {
212
214
  try {
213
- const cfg = updatedConfig || ctx.config || config || {};
215
+ const isPlainConfigObject = updatedConfig && typeof updatedConfig === 'object' && !Array.isArray(updatedConfig);
216
+ const cfg = isPlainConfigObject ? { ...(ctx.config || config || {}), ...updatedConfig } : (ctx.config || config || {});
214
217
  const unwrapped = unwrapConfig(cfg);
215
218
  activeVolatileConfig = { ...activeVolatileConfig, ...unwrapped };
216
219
  if (typeof unwrapped.maxConcurrent === 'number') {
package/lib/runner.js CHANGED
@@ -449,8 +449,13 @@ export class SessionRunner {
449
449
  }
450
450
  }
451
451
 
452
- if (turnEvents.length > 0 && !hasTerminalEvent) {
453
- const err = new Error('Agent turn ended without terminal completion event');
452
+ const hasEventLog = typeof session?.snapshotEvents === 'function' || Array.isArray(session?.log) || Array.isArray(handle.agent?.log);
453
+ if (hasEventLog && !hasTerminalEvent) {
454
+ const err = new Error(
455
+ turnEvents.length === 0
456
+ ? 'Agent turn produced no events before quiescence'
457
+ : 'Agent turn ended without terminal completion event'
458
+ );
454
459
  err.turnFailed = true;
455
460
  err.usage = usage;
456
461
  err.costUsd = costUsd;
package/lib/store.js CHANGED
@@ -179,7 +179,7 @@ export class TaskStore {
179
179
  };
180
180
 
181
181
  const writeOp = async () => {
182
- if ((this._diskVersion || 0) >= version) {
182
+ if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
183
183
  return;
184
184
  }
185
185
  const dir = path.dirname(this.filePath);
@@ -188,37 +188,31 @@ export class TaskStore {
188
188
 
189
189
  const rand = Math.random().toString(36).slice(2, 8);
190
190
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
191
- const payload = JSON.stringify(data, null, 2);
191
+ this._currentAsyncTmp = tmp;
192
+
193
+ // Use compact serialization without whitespace indentation to eliminate event loop lag on large stores (#244)
194
+ const payload = JSON.stringify(data);
192
195
  await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
193
196
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
194
197
 
195
- if ((this._diskVersion || 0) >= version || this._saveVersion > version) {
198
+ if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
196
199
  await fs.promises.unlink(tmp).catch(() => {});
200
+ if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
197
201
  return;
198
202
  }
199
203
 
200
- await fs.promises.rename(tmp, this.filePath);
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;
204
+ try {
205
+ await fs.promises.rename(tmp, this.filePath);
206
+ bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
207
+ this._currentAsyncTmp = null;
208
+ this._diskVersion = version;
209
+ this._lastWrittenVersion = version;
210
+ } catch (err) {
211
+ this._currentAsyncTmp = null;
212
+ // If tmp was unlinked by newer sync save, rename will fail with ENOENT - safely ignore
213
+ if (err.code !== 'ENOENT') throw err;
217
214
  }
218
215
 
219
- this._diskVersion = version;
220
- this._lastWrittenVersion = version;
221
-
222
216
  const now = Date.now();
223
217
  if (now - this._lastBackupTime > 60000) {
224
218
  this._lastBackupTime = now;
@@ -242,6 +236,14 @@ export class TaskStore {
242
236
  this._saveTimer = null;
243
237
  }
244
238
  const version = ++this._saveVersion;
239
+ this._lastSyncVersion = version;
240
+
241
+ // Immediately cancel and unlink any in-flight async tmp file so async rename cannot touch disk (#244)
242
+ if (this._currentAsyncTmp) {
243
+ const inFlightTmp = this._currentAsyncTmp;
244
+ this._currentAsyncTmp = null;
245
+ bestEffort('unlink-async-tmp', () => fs.unlinkSync(inFlightTmp));
246
+ }
245
247
  try {
246
248
  const dir = path.dirname(this.filePath);
247
249
  if (!fs.existsSync(dir)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.42",
3
+ "version": "0.2.43",
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",