@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.
- package/README.md +23 -0
- package/lib/adaptive.js +151 -0
- package/lib/api.js +8 -0
- package/lib/client.js +4425 -4372
- package/lib/log-streamer.js +153 -0
- package/lib/runner-external.js +10 -0
- package/lib/runner.js +18 -1
- package/lib/scheduler-execution.js +26 -0
- package/lib/scheduler.js +38 -0
- package/lib/task-create.js +2 -0
- package/lib/task-patch.js +4 -0
- package/lib/task-transfer.js +2 -0
- package/package.json +1 -1
|
@@ -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();
|
package/lib/runner-external.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 {
|
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/task-create.js
CHANGED
|
@@ -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
|
}
|
package/lib/task-transfer.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
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",
|