@goodandready/dsh-cron 0.3.2 → 0.3.3

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.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Real-time Task Execution Log Streaming (SSE) (#336)
3
+ *
4
+ * Keeps an in-memory Ring Buffer of output chunks for active tasks and
5
+ * broadcasts them to connected Server-Sent Events (SSE) clients.
6
+ */
7
+
8
+ import { EventEmitter } from 'node:events';
9
+ import { logger } from './logger.js';
10
+
11
+ const MAX_BUFFER_LINES = 500;
12
+ const RETENTION_MS = 60000; // retain logs for 60s after completion for late inspectors
13
+
14
+ export class LogStreamer extends EventEmitter {
15
+ constructor() {
16
+ super();
17
+ this.streams = new Map(); // taskId -> StreamEntry
18
+ }
19
+
20
+ startStream(taskId, runId) {
21
+ if (!taskId) return;
22
+ const existing = this.streams.get(taskId);
23
+ if (existing && existing.cleanupTimer) {
24
+ clearTimeout(existing.cleanupTimer);
25
+ }
26
+ const entry = {
27
+ taskId,
28
+ runId: runId || `run_${Date.now()}`,
29
+ startTime: Date.now(),
30
+ buffer: [],
31
+ subscribers: new Set(),
32
+ completed: false,
33
+ outcome: null,
34
+ cleanupTimer: null,
35
+ };
36
+ this.streams.set(taskId, entry);
37
+ this.emit('start', { taskId, runId: entry.runId });
38
+ return entry;
39
+ }
40
+
41
+ appendLog(taskId, text, streamType = 'stdout') {
42
+ if (!taskId || text === undefined || text === null) return;
43
+ const entry = this.streams.get(taskId);
44
+ if (!entry) return;
45
+
46
+ const chunkStr = String(text);
47
+ const item = {
48
+ chunk: chunkStr,
49
+ stream: streamType,
50
+ ts: Date.now(),
51
+ };
52
+
53
+ entry.buffer.push(item);
54
+ if (entry.buffer.length > MAX_BUFFER_LINES) {
55
+ entry.buffer.shift();
56
+ }
57
+
58
+ const ssePayload = `event: log\ndata: ${JSON.stringify(item)}\n\n`;
59
+ for (const res of entry.subscribers) {
60
+ try {
61
+ res.write(ssePayload);
62
+ } catch (err) {
63
+ entry.subscribers.delete(res);
64
+ }
65
+ }
66
+ }
67
+
68
+ completeStream(taskId, outcome = {}) {
69
+ if (!taskId) return;
70
+ const entry = this.streams.get(taskId);
71
+ if (!entry) return;
72
+
73
+ entry.completed = true;
74
+ entry.outcome = {
75
+ status: outcome.status || 'success',
76
+ durationMs: Date.now() - entry.startTime,
77
+ error: outcome.error || null,
78
+ exitCode: outcome.exitCode !== undefined ? outcome.exitCode : (outcome.status === 'error' ? 1 : 0),
79
+ };
80
+
81
+ const ssePayload = `event: complete\ndata: ${JSON.stringify(entry.outcome)}\n\n`;
82
+ for (const res of entry.subscribers) {
83
+ try {
84
+ res.write(ssePayload);
85
+ res.end();
86
+ } catch (err) {
87
+ // client closed
88
+ }
89
+ }
90
+ entry.subscribers.clear();
91
+
92
+ // Schedule cleanup of memory after retention period
93
+ entry.cleanupTimer = setTimeout(() => {
94
+ this.streams.delete(taskId);
95
+ }, RETENTION_MS);
96
+ if (entry.cleanupTimer.unref) entry.cleanupTimer.unref();
97
+ }
98
+
99
+ getStream(taskId) {
100
+ return this.streams.get(taskId) || null;
101
+ }
102
+
103
+ handleSseRequest(taskId, req, res) {
104
+ res.writeHead(200, {
105
+ 'Content-Type': 'text/event-stream; charset=utf-8',
106
+ 'Cache-Control': 'no-cache, no-transform',
107
+ 'Connection': 'keep-alive',
108
+ 'X-Accel-Buffering': 'no',
109
+ 'Access-Control-Allow-Origin': '*',
110
+ });
111
+
112
+ const entry = this.streams.get(taskId);
113
+
114
+ // Initial event with current state and buffered logs
115
+ const initData = {
116
+ taskId,
117
+ runId: entry?.runId || null,
118
+ status: entry ? (entry.completed ? 'completed' : 'running') : 'idle',
119
+ bufferedLogs: entry?.buffer || [],
120
+ };
121
+ res.write(`event: init\ndata: ${JSON.stringify(initData)}\n\n`);
122
+
123
+ if (!entry || entry.completed) {
124
+ // Not running or already completed
125
+ if (entry?.completed) {
126
+ res.write(`event: complete\ndata: ${JSON.stringify(entry.outcome)}\n\n`);
127
+ }
128
+ res.end();
129
+ return;
130
+ }
131
+
132
+ // Subscribe to live events
133
+ entry.subscribers.add(res);
134
+
135
+ // Keepalive ping every 15 seconds
136
+ const pingTimer = setInterval(() => {
137
+ try {
138
+ res.write(': keepalive\n\n');
139
+ } catch (e) {
140
+ clearInterval(pingTimer);
141
+ entry.subscribers.delete(res);
142
+ }
143
+ }, 15000);
144
+ if (pingTimer.unref) pingTimer.unref();
145
+
146
+ req.on('close', () => {
147
+ clearInterval(pingTimer);
148
+ entry.subscribers.delete(res);
149
+ });
150
+ }
151
+ }
152
+
153
+ export const logStreamer = new LogStreamer();
@@ -1,3 +1,4 @@
1
+ import { logStreamer } from './log-streamer.js';
1
2
  import { logger } from './logger.js';
2
3
  import { bestEffort } from './best-effort.js';
3
4
  import {
@@ -59,6 +60,10 @@ export function isTransientError(errOrStatus) {
59
60
 
60
61
  /** HTTP trigger runtime (#7) with transient retry (#134). */
61
62
  export async function runHttp(task, request, signal) {
63
+ const streamRunId = `run_${Date.now()}`;
64
+ logStreamer.startStream(task.id, streamRunId);
65
+ logStreamer.appendLog(task.id, `> ${request.method} ${request.url}
66
+ `, 'stdout');
62
67
  let attempts = 0;
63
68
  const maxAttempts = 3;
64
69
  while (attempts < maxAttempts) {
@@ -201,6 +206,8 @@ export async function runExternal(task, runtime, signal, execOptions = {}, { ctx
201
206
  if (execOptions?.prevOutput !== undefined) {
202
207
  runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
203
208
  }
209
+ const streamRunId = execOptions?.runId || `run_${Date.now()}`;
210
+ logStreamer.startStream(task.id, streamRunId);
204
211
  const options = {
205
212
  cwd,
206
213
  env: runEnv,
@@ -218,15 +225,18 @@ export async function runExternal(task, runtime, signal, execOptions = {}, { ctx
218
225
 
219
226
  child.stdout.on('data', (chunk) => {
220
227
  if (stdout.length < maxBuffer) stdout += chunk;
228
+ logStreamer.appendLog(task.id, chunk.toString('utf-8'), 'stdout');
221
229
  });
222
230
  child.stderr.on('data', (chunk) => {
223
231
  if (stderr.length < maxBuffer) stderr += chunk;
232
+ logStreamer.appendLog(task.id, chunk.toString('utf-8'), 'stderr');
224
233
  });
225
234
 
226
235
  child.on('error', (err) => {
227
236
  if (settled) return;
228
237
  settled = true;
229
238
  if (signal) signal.removeEventListener('abort', abortHandler);
239
+ logStreamer.completeStream(task.id, { status: 'error', error: err.message, exitCode: 1 });
230
240
  reject(err);
231
241
  });
232
242
 
package/lib/runner.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { logStreamer } from './log-streamer.js';
1
2
  import { logger } from './logger.js';
2
3
  import { bestEffort } from './best-effort.js';
3
4
  import { estimateTokenCost } from './integrations.js';
@@ -92,6 +93,11 @@ export class SessionRunner {
92
93
  }, { once: true });
93
94
  }
94
95
 
96
+ const streamRunId = options.runId || `run_${Date.now()}`;
97
+ logStreamer.startStream(task.id, streamRunId);
98
+ logStreamer.appendLog(task.id, `[dsh-cron] Starting task "${task.title}" (type: ${task.type || 'llm'})
99
+ `, 'stdout');
100
+
95
101
  const timeoutPromise = new Promise((_, reject) => {
96
102
  timeoutId = setTimeout(() => {
97
103
  const timeoutErr = new Error(`Task execution timed out after ${timeoutSeconds} seconds`);
@@ -103,7 +109,18 @@ export class SessionRunner {
103
109
  const executionPromise = this._run(task, controller.signal, options);
104
110
 
105
111
  try {
106
- return await Promise.race([executionPromise, timeoutPromise]);
112
+ const res = await Promise.race([executionPromise, timeoutPromise]);
113
+ logStreamer.appendLog(task.id, `
114
+ [dsh-cron] Task execution finished
115
+ `, 'stdout');
116
+ logStreamer.completeStream(task.id, { status: 'success' });
117
+ return res;
118
+ } catch (err) {
119
+ logStreamer.appendLog(task.id, `
120
+ [dsh-cron] Task execution error: ${err.message}
121
+ `, 'stderr');
122
+ logStreamer.completeStream(task.id, { status: 'error', error: err.message });
123
+ throw err;
107
124
  } finally {
108
125
  if (timeoutId) clearTimeout(timeoutId);
109
126
  }
@@ -1,3 +1,4 @@
1
+ import { evaluateAdaptiveRun, calculateNextAdaptiveState } from './adaptive.js';
1
2
  import { approvalManager } from './approval.js';
2
3
  import { globalBudgetManager } from './global-budgets.js';
3
4
  import { validateRunResult } from './validators.js';
@@ -512,6 +513,28 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
512
513
  export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount, meta = {}) {
513
514
  scheduler.countRun(status);
514
515
 
516
+ // Adaptive polling backoff (#329)
517
+ if (taskSnapshot && taskSnapshot.adaptive && taskSnapshot.adaptive.enabled && !taskSnapshot.oneShot && taskSnapshot.status === 'active' && !scheduler.isStopped) {
518
+ const history = scheduler.store.getHistory ? scheduler.store.getHistory(taskId, 2) : [];
519
+ const prevRun = history.length > 1 ? history[1] : null;
520
+ const outcomeForEval = meta?.outcome || { status };
521
+ const runInfoForEval = meta?.runInfo || {};
522
+ const evalRes = evaluateAdaptiveRun(taskSnapshot, outcomeForEval, runInfoForEval, prevRun);
523
+ const nextAdaptiveState = calculateNextAdaptiveState(taskSnapshot, evalRes.isIdle);
524
+ taskSnapshot.adaptiveState = nextAdaptiveState;
525
+ scheduler.store.set(taskSnapshot);
526
+
527
+ if (nextAdaptiveState.backedOff) {
528
+ logger.info(`[dsh-cron] adaptive backoff active for "${taskSnapshot.title}" (${taskSnapshot.id}): interval scaled to ${nextAdaptiveState.effectiveIntervalStr} (idle count: ${nextAdaptiveState.idleCount})`);
529
+ scheduler.armAdaptiveTimer(taskId, nextAdaptiveState.currentIntervalMs);
530
+ } else {
531
+ scheduler.clearAdaptiveTimer(taskId);
532
+ if (!scheduler.jobs.has(taskId)) {
533
+ scheduler.scheduleTask(taskSnapshot);
534
+ }
535
+ }
536
+ }
537
+
515
538
  // If this run was superseded by a newer run or scheduler is stopped, skip updating task
516
539
  if (meta.isCurrentRun === false || scheduler.isStopped) {
517
540
  return;
@@ -783,6 +806,9 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
783
806
  isCurrentRun,
784
807
  runId: currentRun?.id,
785
808
  queuedRuns,
809
+ outcome,
810
+ runInfo,
811
+ silent,
786
812
  });
787
813
 
788
814
  if (scheduler.isStopped) return;
package/lib/scheduler.js CHANGED
@@ -317,6 +317,7 @@ export class TaskScheduler {
317
317
  this.jobs = new Map();
318
318
  this.timers = new Map();
319
319
  this.retryTimers = new Map();
320
+ this.adaptiveTimers = new Map();
320
321
  this.running = new Map();
321
322
  this.runCounters = {};
322
323
  this.queue = [];
@@ -349,6 +350,37 @@ export class TaskScheduler {
349
350
  }
350
351
  }
351
352
 
353
+ clearAdaptiveTimer(taskId) {
354
+ const t = this.adaptiveTimers.get(taskId);
355
+ if (t) {
356
+ clearTimeout(t);
357
+ this.adaptiveTimers.delete(taskId);
358
+ }
359
+ }
360
+
361
+ armAdaptiveTimer(taskId, delayMs) {
362
+ this.clearAdaptiveTimer(taskId);
363
+ if (this.jobs.has(taskId)) {
364
+ this.jobs.get(taskId).stop();
365
+ this.jobs.delete(taskId);
366
+ }
367
+ const task = this.store.get(taskId);
368
+ if (task) {
369
+ task.nextRunAt = Date.now() + delayMs;
370
+ this.store.set(task);
371
+ }
372
+ const timer = setTimeout(() => {
373
+ this.adaptiveTimers.delete(taskId);
374
+ if (!this.isStopped) {
375
+ this.runTask(taskId, { isAdaptive: true }).catch((err) => {
376
+ logger.error(`[dsh-cron] adaptive run of task ${taskId} failed:`, (err && err.message) || err);
377
+ });
378
+ }
379
+ }, delayMs);
380
+ if (timer.unref) timer.unref();
381
+ this.adaptiveTimers.set(taskId, timer);
382
+ }
383
+
352
384
  startHeartbeatWatcher(intervalMs = 30000) {
353
385
  return startHeartbeatWatcher(this, intervalMs);
354
386
  }
@@ -450,6 +482,11 @@ export class TaskScheduler {
450
482
  }
451
483
  this.retryTimers.clear();
452
484
 
485
+ for (const [, timer] of this.adaptiveTimers.entries()) {
486
+ bestEffort('clear-adaptive-timer', () => clearTimeout(timer), this.logger);
487
+ }
488
+ this.adaptiveTimers.clear();
489
+
453
490
  for (const [, run] of this.running.entries()) {
454
491
  bestEffort('abort-running', () => run.controller.abort(new Error('Scheduler stopped')), this.logger);
455
492
  }
@@ -461,6 +498,7 @@ export class TaskScheduler {
461
498
  }
462
499
 
463
500
  clearScheduled(taskId) {
501
+ this.clearAdaptiveTimer(taskId);
464
502
  if (this.watcherManager) {
465
503
  this.watcherManager.unwatchTask(taskId);
466
504
  }
@@ -1,3 +1,4 @@
1
+ import { sanitizeAdaptiveConfig } from './adaptive.js';
1
2
  import { sanitizeValidators } from './validators.js';
2
3
  import { normalizeTaskType, EXTERNAL_RUNTIMES } from './runtimes.js';
3
4
  import { validateTaskType } from './task-transfer.js';
@@ -48,6 +49,7 @@ export function executeCreateTask(store, scheduler, args) {
48
49
  onFailure: args.onFailure ? String(args.onFailure).trim() : '',
49
50
  dependsOn: args.dependsOn !== undefined ? sanitizeDependsOn(args.dependsOn) : undefined,
50
51
  joinPolicy: args.joinPolicy !== undefined ? sanitizeJoinPolicy(args.joinPolicy) : undefined,
52
+ adaptive: args.adaptive !== undefined ? sanitizeAdaptiveConfig(args.adaptive) : undefined,
51
53
  heartbeatIntervalSeconds: Number(args.heartbeatIntervalSeconds) || 0,
52
54
  gracePeriodSeconds: Number(args.gracePeriodSeconds) || 300,
53
55
  preflightType: args.preflightType ? String(args.preflightType).trim().toLowerCase() : 'none',
package/lib/task-patch.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeAdaptiveConfig } from './adaptive.js';
1
2
  import { sanitizeValidators } from './validators.js';
2
3
  import { sanitizeFileWatch } from './task-create.js';
3
4
  import { sanitizeDependsOn, sanitizeJoinPolicy, validateDAG } from './dag.js';
@@ -101,6 +102,9 @@ export function buildTaskPatch(current, body) {
101
102
  if (patch.fileWatch !== undefined) {
102
103
  patch.fileWatch = patch.fileWatch && typeof patch.fileWatch === 'object' ? sanitizeFileWatch(patch.fileWatch) : null;
103
104
  }
105
+ if (patch.adaptive !== undefined) {
106
+ patch.adaptive = patch.adaptive ? sanitizeAdaptiveConfig(patch.adaptive) : null;
107
+ }
104
108
  if (patch.validators !== undefined) {
105
109
  patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
106
110
  }
@@ -79,6 +79,8 @@ export const PATCHABLE_TASK_FIELDS = [
79
79
  'fileWatch',
80
80
  'dependsOn',
81
81
  'joinPolicy',
82
+ 'adaptive',
83
+ 'adaptiveState',
82
84
  ];
83
85
 
84
86
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",