@goodandready/dsh-cron 0.3.2 → 0.3.4
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/analytics-digest.js +236 -0
- package/lib/api.js +49 -0
- package/lib/client.js +4721 -4465
- package/lib/log-streamer.js +153 -0
- package/lib/prompt-evolver.js +148 -0
- package/lib/routes.js +79 -0
- package/lib/runner-external.js +10 -0
- package/lib/runner.js +18 -1
- package/lib/scheduler-execution.js +36 -0
- package/lib/scheduler.js +38 -0
- package/lib/task-create.js +5 -0
- package/lib/task-patch.js +13 -0
- package/lib/task-transfer.js +4 -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();
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Auto-Evolver & Versioning Engine (#331)
|
|
3
|
+
*
|
|
4
|
+
* Tracks prompt revisions, maintains versioned prompt history, computes
|
|
5
|
+
* empirical Success Rates across versions, and enables 1-click rollback.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_PROMPT_HISTORY = 20;
|
|
9
|
+
|
|
10
|
+
export function sanitizePromptHistory(history) {
|
|
11
|
+
if (!Array.isArray(history)) return [];
|
|
12
|
+
return history
|
|
13
|
+
.filter((h) => h && typeof h === 'object' && typeof h.prompt === 'string')
|
|
14
|
+
.slice(-MAX_PROMPT_HISTORY)
|
|
15
|
+
.map((h, idx) => ({
|
|
16
|
+
version: typeof h.version === 'number' && h.version > 0 ? h.version : (idx + 1),
|
|
17
|
+
prompt: String(h.prompt || ''),
|
|
18
|
+
note: typeof h.note === 'string' ? h.note.slice(0, 100) : 'Revision',
|
|
19
|
+
runs: typeof h.runs === 'number' && h.runs >= 0 ? h.runs : 0,
|
|
20
|
+
successes: typeof h.successes === 'number' && h.successes >= 0 ? h.successes : 0,
|
|
21
|
+
successRate: typeof h.successRate === 'number' && h.successRate >= 0 && h.successRate <= 1
|
|
22
|
+
? Number(h.successRate.toFixed(3))
|
|
23
|
+
: (typeof h.runs === 'number' && h.runs > 0 ? Number(((h.successes || 0) / h.runs).toFixed(3)) : 1.0),
|
|
24
|
+
createdAt: typeof h.createdAt === 'number' ? h.createdAt : Date.now(),
|
|
25
|
+
lastRunAt: typeof h.lastRunAt === 'number' ? h.lastRunAt : null,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function trackPromptEdit(currentTask, newPrompt, note = 'Manual edit') {
|
|
30
|
+
if (!currentTask || typeof newPrompt !== 'string') {
|
|
31
|
+
return {
|
|
32
|
+
prompt: currentTask ? currentTask.prompt : newPrompt,
|
|
33
|
+
promptVersion: currentTask ? (currentTask.promptVersion || 1) : 1,
|
|
34
|
+
promptHistory: currentTask ? sanitizePromptHistory(currentTask.promptHistory) : [],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const trimmedNew = newPrompt.trim();
|
|
39
|
+
const trimmedOld = (currentTask.prompt || '').trim();
|
|
40
|
+
|
|
41
|
+
let history = sanitizePromptHistory(currentTask.promptHistory);
|
|
42
|
+
|
|
43
|
+
// If prompt has not changed, keep current version and baseline history if needed
|
|
44
|
+
if (trimmedNew === trimmedOld) {
|
|
45
|
+
if (history.length === 0 && trimmedOld) {
|
|
46
|
+
history.push({
|
|
47
|
+
version: currentTask.promptVersion || 1,
|
|
48
|
+
prompt: currentTask.prompt,
|
|
49
|
+
note: 'Initial baseline',
|
|
50
|
+
runs: typeof currentTask.runs === 'number' ? currentTask.runs : 0,
|
|
51
|
+
successes: typeof currentTask.successes === 'number' ? currentTask.successes : 0,
|
|
52
|
+
successRate: currentTask.runs > 0
|
|
53
|
+
? Number(((currentTask.successes || 0) / currentTask.runs).toFixed(3))
|
|
54
|
+
: 1.0,
|
|
55
|
+
createdAt: currentTask.createdAt || Date.now(),
|
|
56
|
+
lastRunAt: currentTask.lastRun || null,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
prompt: currentTask.prompt,
|
|
61
|
+
promptVersion: currentTask.promptVersion || 1,
|
|
62
|
+
promptHistory: history,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If no history exists yet, record baseline version 1 with existing task metrics before bumping
|
|
67
|
+
if (history.length === 0 && trimmedOld) {
|
|
68
|
+
history.push({
|
|
69
|
+
version: 1,
|
|
70
|
+
prompt: currentTask.prompt,
|
|
71
|
+
note: 'Initial baseline',
|
|
72
|
+
runs: typeof currentTask.runs === 'number' ? currentTask.runs : 0,
|
|
73
|
+
successes: typeof currentTask.successes === 'number' ? currentTask.successes : 0,
|
|
74
|
+
successRate: currentTask.runs > 0
|
|
75
|
+
? Number(((currentTask.successes || 0) / currentTask.runs).toFixed(3))
|
|
76
|
+
: 1.0,
|
|
77
|
+
createdAt: currentTask.createdAt || Date.now(),
|
|
78
|
+
lastRunAt: currentTask.lastRun || null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const maxVersion = history.reduce((max, h) => Math.max(max, h.version || 1), currentTask.promptVersion || 1);
|
|
83
|
+
const nextVersion = maxVersion + 1;
|
|
84
|
+
|
|
85
|
+
history.push({
|
|
86
|
+
version: nextVersion,
|
|
87
|
+
prompt: newPrompt,
|
|
88
|
+
note: String(note || 'Manual edit').slice(0, 100),
|
|
89
|
+
runs: 0,
|
|
90
|
+
successes: 0,
|
|
91
|
+
successRate: 1.0,
|
|
92
|
+
createdAt: Date.now(),
|
|
93
|
+
lastRunAt: null,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
prompt: newPrompt,
|
|
98
|
+
promptVersion: nextVersion,
|
|
99
|
+
promptHistory: sanitizePromptHistory(history),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function recordPromptRunOutcome(task, isSuccess) {
|
|
104
|
+
if (!task || typeof task.prompt !== 'string') return task?.promptHistory || [];
|
|
105
|
+
|
|
106
|
+
let history = sanitizePromptHistory(task.promptHistory);
|
|
107
|
+
const activeVersion = task.promptVersion || 1;
|
|
108
|
+
|
|
109
|
+
let entry = history.find((h) => h.version === activeVersion);
|
|
110
|
+
if (!entry) {
|
|
111
|
+
entry = {
|
|
112
|
+
version: activeVersion,
|
|
113
|
+
prompt: task.prompt,
|
|
114
|
+
note: 'Active prompt',
|
|
115
|
+
runs: 0,
|
|
116
|
+
successes: 0,
|
|
117
|
+
successRate: 1.0,
|
|
118
|
+
createdAt: Date.now(),
|
|
119
|
+
lastRunAt: null,
|
|
120
|
+
};
|
|
121
|
+
history.push(entry);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
entry.runs = (entry.runs || 0) + 1;
|
|
125
|
+
if (isSuccess) {
|
|
126
|
+
entry.successes = (entry.successes || 0) + 1;
|
|
127
|
+
}
|
|
128
|
+
entry.successRate = Number((entry.successes / entry.runs).toFixed(3));
|
|
129
|
+
entry.lastRunAt = Date.now();
|
|
130
|
+
|
|
131
|
+
task.promptHistory = sanitizePromptHistory(history);
|
|
132
|
+
return task.promptHistory;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function rollbackPromptVersion(task, targetVersion) {
|
|
136
|
+
if (!task) return null;
|
|
137
|
+
const history = sanitizePromptHistory(task.promptHistory);
|
|
138
|
+
const target = history.find((h) => h.version === targetVersion);
|
|
139
|
+
if (!target) {
|
|
140
|
+
throw new Error(`Target prompt version ${targetVersion} not found in history`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const edit = trackPromptEdit(task, target.prompt, `Rollback to v${targetVersion}`);
|
|
144
|
+
task.prompt = edit.prompt;
|
|
145
|
+
task.promptVersion = edit.promptVersion;
|
|
146
|
+
task.promptHistory = edit.promptHistory;
|
|
147
|
+
return task;
|
|
148
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
|
|
1
2
|
import { listVerifiedRecipes } from './recipes-catalog.js';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { createCronApiHandler } from './api.js';
|
|
@@ -100,6 +101,84 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
100
101
|
}
|
|
101
102
|
}), 'dsh-cron: /chat/start');
|
|
102
103
|
|
|
104
|
+
// Analytics digest routes (#330)
|
|
105
|
+
ctx.effect(() => ctx.webServer.register({
|
|
106
|
+
kind: 'prefix',
|
|
107
|
+
path: '/dsh-cron/analytics',
|
|
108
|
+
handler: async (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
111
|
+
const path = url.pathname;
|
|
112
|
+
|
|
113
|
+
if (req.method === 'GET') {
|
|
114
|
+
const days = parseInt(url.searchParams.get('days') || '7', 10);
|
|
115
|
+
const tasks = store.list ? store.list() : Object.values(store.tasks || {});
|
|
116
|
+
let history = [];
|
|
117
|
+
if (store.history) {
|
|
118
|
+
if (typeof store.history.values === 'function') {
|
|
119
|
+
for (const runs of store.history.values()) {
|
|
120
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
121
|
+
}
|
|
122
|
+
} else if (typeof store.history === 'object') {
|
|
123
|
+
for (const runs of Object.values(store.history)) {
|
|
124
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const costLedger = store.costLedger || [];
|
|
129
|
+
const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
|
|
130
|
+
const markdown = formatDigestMarkdown(digest);
|
|
131
|
+
sendJson(res, 200, { ok: true, digest, markdown });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (req.method === 'POST') {
|
|
136
|
+
if (rejectCrossOrigin(req, res)) return;
|
|
137
|
+
const { body } = await readBody(req, res);
|
|
138
|
+
const days = parseInt(body?.days || url.searchParams.get('days') || '7', 10);
|
|
139
|
+
const tasks = store.list ? store.list() : Object.values(store.tasks || {});
|
|
140
|
+
let history = [];
|
|
141
|
+
if (store.history) {
|
|
142
|
+
if (typeof store.history.values === 'function') {
|
|
143
|
+
for (const runs of store.history.values()) {
|
|
144
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
145
|
+
}
|
|
146
|
+
} else if (typeof store.history === 'object') {
|
|
147
|
+
for (const runs of Object.values(store.history)) {
|
|
148
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const costLedger = store.costLedger || [];
|
|
153
|
+
const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
|
|
154
|
+
const settings = store.getSettings ? store.getSettings() : {};
|
|
155
|
+
const deliveryConfig = {
|
|
156
|
+
telegram: body?.telegram !== false && Boolean(settings.botToken && settings.chatId),
|
|
157
|
+
webhookUrl: body?.webhookUrl || settings.webhookUrl,
|
|
158
|
+
};
|
|
159
|
+
const channels = {
|
|
160
|
+
sendTelegram: async (text) => sendTelegramMessage({ botToken: settings.botToken, chatId: settings.chatId, text }),
|
|
161
|
+
sendWebhook: async (targetUrl, payload) => {
|
|
162
|
+
const r = await fetch(targetUrl, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'Content-Type': 'application/json' },
|
|
165
|
+
body: JSON.stringify(payload),
|
|
166
|
+
});
|
|
167
|
+
return { ok: r.ok, status: r.status };
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
const delivery = await sendAnalyticsDigest({ digest, channels, deliveryConfig });
|
|
171
|
+
sendJson(res, 200, { ok: true, digest, delivery });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
176
|
+
} catch (err) {
|
|
177
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}), 'dsh-cron: /analytics');
|
|
181
|
+
|
|
103
182
|
// GET /dsh-cron/recipes (#48): the built-in recipe catalog.
|
|
104
183
|
ctx.effect(() => ctx.webServer.register({
|
|
105
184
|
kind: 'exact',
|
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,5 @@
|
|
|
1
|
+
import { recordPromptRunOutcome } from './prompt-evolver.js';
|
|
2
|
+
import { evaluateAdaptiveRun, calculateNextAdaptiveState } from './adaptive.js';
|
|
1
3
|
import { approvalManager } from './approval.js';
|
|
2
4
|
import { globalBudgetManager } from './global-budgets.js';
|
|
3
5
|
import { validateRunResult } from './validators.js';
|
|
@@ -512,6 +514,37 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
|
|
|
512
514
|
export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount, meta = {}) {
|
|
513
515
|
scheduler.countRun(status);
|
|
514
516
|
|
|
517
|
+
// Track prompt version success rate outcome (#331)
|
|
518
|
+
if (taskSnapshot && taskSnapshot.prompt && (taskSnapshot.promptVersion || taskSnapshot.promptHistory)) {
|
|
519
|
+
const isSuccess = status === 'success';
|
|
520
|
+
recordPromptRunOutcome(taskSnapshot, isSuccess);
|
|
521
|
+
if (scheduler.store && typeof scheduler.store.set === 'function') {
|
|
522
|
+
scheduler.store.set(taskSnapshot);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Adaptive polling backoff (#329)
|
|
527
|
+
if (taskSnapshot && taskSnapshot.adaptive && taskSnapshot.adaptive.enabled && !taskSnapshot.oneShot && taskSnapshot.status === 'active' && !scheduler.isStopped) {
|
|
528
|
+
const history = scheduler.store.getHistory ? scheduler.store.getHistory(taskId, 2) : [];
|
|
529
|
+
const prevRun = history.length > 1 ? history[1] : null;
|
|
530
|
+
const outcomeForEval = meta?.outcome || { status };
|
|
531
|
+
const runInfoForEval = meta?.runInfo || {};
|
|
532
|
+
const evalRes = evaluateAdaptiveRun(taskSnapshot, outcomeForEval, runInfoForEval, prevRun);
|
|
533
|
+
const nextAdaptiveState = calculateNextAdaptiveState(taskSnapshot, evalRes.isIdle);
|
|
534
|
+
taskSnapshot.adaptiveState = nextAdaptiveState;
|
|
535
|
+
scheduler.store.set(taskSnapshot);
|
|
536
|
+
|
|
537
|
+
if (nextAdaptiveState.backedOff) {
|
|
538
|
+
logger.info(`[dsh-cron] adaptive backoff active for "${taskSnapshot.title}" (${taskSnapshot.id}): interval scaled to ${nextAdaptiveState.effectiveIntervalStr} (idle count: ${nextAdaptiveState.idleCount})`);
|
|
539
|
+
scheduler.armAdaptiveTimer(taskId, nextAdaptiveState.currentIntervalMs);
|
|
540
|
+
} else {
|
|
541
|
+
scheduler.clearAdaptiveTimer(taskId);
|
|
542
|
+
if (!scheduler.jobs.has(taskId)) {
|
|
543
|
+
scheduler.scheduleTask(taskSnapshot);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
515
548
|
// If this run was superseded by a newer run or scheduler is stopped, skip updating task
|
|
516
549
|
if (meta.isCurrentRun === false || scheduler.isStopped) {
|
|
517
550
|
return;
|
|
@@ -783,6 +816,9 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
783
816
|
isCurrentRun,
|
|
784
817
|
runId: currentRun?.id,
|
|
785
818
|
queuedRuns,
|
|
819
|
+
outcome,
|
|
820
|
+
runInfo,
|
|
821
|
+
silent,
|
|
786
822
|
});
|
|
787
823
|
|
|
788
824
|
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,5 @@
|
|
|
1
|
+
import { sanitizePromptHistory } from './prompt-evolver.js';
|
|
2
|
+
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
1
3
|
import { sanitizeValidators } from './validators.js';
|
|
2
4
|
import { normalizeTaskType, EXTERNAL_RUNTIMES } from './runtimes.js';
|
|
3
5
|
import { validateTaskType } from './task-transfer.js';
|
|
@@ -32,6 +34,8 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
32
34
|
schedule: parsed.cronPattern || args.schedule,
|
|
33
35
|
scheduleText: parsed.humanText,
|
|
34
36
|
prompt: args.prompt,
|
|
37
|
+
promptVersion: args.prompt ? 1 : undefined,
|
|
38
|
+
promptHistory: args.prompt ? [{ version: 1, prompt: args.prompt, note: 'Initial creation', runs: 0, successes: 0, successRate: 1.0, createdAt: Date.now(), lastRunAt: null }] : undefined,
|
|
35
39
|
type: taskType,
|
|
36
40
|
delivery: args.delivery || 'isolated',
|
|
37
41
|
status: 'active',
|
|
@@ -48,6 +52,7 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
48
52
|
onFailure: args.onFailure ? String(args.onFailure).trim() : '',
|
|
49
53
|
dependsOn: args.dependsOn !== undefined ? sanitizeDependsOn(args.dependsOn) : undefined,
|
|
50
54
|
joinPolicy: args.joinPolicy !== undefined ? sanitizeJoinPolicy(args.joinPolicy) : undefined,
|
|
55
|
+
adaptive: args.adaptive !== undefined ? sanitizeAdaptiveConfig(args.adaptive) : undefined,
|
|
51
56
|
heartbeatIntervalSeconds: Number(args.heartbeatIntervalSeconds) || 0,
|
|
52
57
|
gracePeriodSeconds: Number(args.gracePeriodSeconds) || 300,
|
|
53
58
|
preflightType: args.preflightType ? String(args.preflightType).trim().toLowerCase() : 'none',
|
package/lib/task-patch.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { trackPromptEdit, sanitizePromptHistory } from './prompt-evolver.js';
|
|
2
|
+
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
1
3
|
import { sanitizeValidators } from './validators.js';
|
|
2
4
|
import { sanitizeFileWatch } from './task-create.js';
|
|
3
5
|
import { sanitizeDependsOn, sanitizeJoinPolicy, validateDAG } from './dag.js';
|
|
@@ -101,9 +103,20 @@ export function buildTaskPatch(current, body) {
|
|
|
101
103
|
if (patch.fileWatch !== undefined) {
|
|
102
104
|
patch.fileWatch = patch.fileWatch && typeof patch.fileWatch === 'object' ? sanitizeFileWatch(patch.fileWatch) : null;
|
|
103
105
|
}
|
|
106
|
+
if (patch.adaptive !== undefined) {
|
|
107
|
+
patch.adaptive = patch.adaptive ? sanitizeAdaptiveConfig(patch.adaptive) : null;
|
|
108
|
+
}
|
|
104
109
|
if (patch.validators !== undefined) {
|
|
105
110
|
patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
|
|
106
111
|
}
|
|
112
|
+
if (patch.prompt !== undefined && current && typeof current.prompt === 'string') {
|
|
113
|
+
const edit = trackPromptEdit(current, patch.prompt, body && body.promptNote ? body.promptNote : 'Manual edit');
|
|
114
|
+
patch.prompt = edit.prompt;
|
|
115
|
+
patch.promptVersion = edit.promptVersion;
|
|
116
|
+
patch.promptHistory = edit.promptHistory;
|
|
117
|
+
} else if (patch.promptHistory !== undefined) {
|
|
118
|
+
patch.promptHistory = sanitizePromptHistory(patch.promptHistory);
|
|
119
|
+
}
|
|
107
120
|
if (patch.costLimitUsd !== undefined) {
|
|
108
121
|
patch.costLimitUsd = patch.costLimitUsd === null ? null : (Number(patch.costLimitUsd) > 0 ? Number(patch.costLimitUsd) : null);
|
|
109
122
|
}
|
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.4",
|
|
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",
|