@goodandready/dsh-cron 0.2.18 → 0.2.20

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/runner.js CHANGED
@@ -1,62 +1,31 @@
1
- /**
2
- * Helper to detect context window overflow errors in persistent sessions (#145).
3
- */
4
- export function isContextOverflowError(err) {
5
- const msg = String(err?.message || err || '').toLowerCase();
6
- return msg.includes('context length') ||
7
- msg.includes('context window') ||
8
- msg.includes('maximum context') ||
9
- msg.includes('too many tokens') ||
10
- msg.includes('prompt too long') ||
11
- msg.includes('token limit') ||
12
- msg.includes('context_length_exceeded') ||
13
- msg.includes('string_above_max_length');
14
- }
15
-
16
- /**
17
- * Cross-platform process tree killer (#145).
18
- * On Windows, calls `taskkill /pid <pid> /T /F` to terminate the entire process hierarchy.
19
- */
20
- export function terminateProcessTree(child, sig = 'SIGTERM', { isPosix = process.platform !== 'win32', execFn } = {}) {
21
- if (!child || child.killed) return;
22
- try {
23
- if (isPosix && child.pid) {
24
- try { process.kill(-child.pid, sig); } catch {}
25
- } else if (child.pid) {
26
- const runner = execFn || ((cmd) => {
27
- try {
28
- import('node:child_process').then(({ exec }) => {
29
- exec(cmd, { windowsHide: true }, () => {});
30
- }).catch(() => {});
31
- } catch {}
32
- });
33
- try { runner(`taskkill /pid ${child.pid} /T /F`); } catch {}
34
- try { child.kill(sig); } catch {}
35
- }
36
- } catch {}
37
- }
38
-
39
- /**
40
- * Helper to test if an error code or status is transient and eligible for retry (#134).
41
- */
42
- export function isTransientError(errOrStatus) {
43
- if (typeof errOrStatus === 'number') {
44
- return errOrStatus === 429 || errOrStatus === 502 || errOrStatus === 503 || errOrStatus === 504;
45
- }
46
- const str = String(errOrStatus?.message || errOrStatus || '').toLowerCase();
47
- return str.includes('429') || str.includes('too many requests') ||
48
- str.includes('502') || str.includes('bad gateway') ||
49
- str.includes('503') || str.includes('service unavailable') ||
50
- str.includes('504') || str.includes('gateway timeout') ||
51
- str.includes('econnreset') || str.includes('etimedout');
52
- }
53
-
1
+ import { bestEffort } from './best-effort.js';
54
2
  import { estimateTokenCost } from './integrations.js';
55
3
  import {
56
4
  EXTERNAL_RUNTIMES,
57
5
  normalizeTaskType,
58
- buildInvocation,
59
6
  } from './runtimes.js';
7
+ import {
8
+ isContextOverflowError,
9
+ terminateProcessTree,
10
+ isTransientError,
11
+ runHttp,
12
+ runViaRemoteWorkspace,
13
+ runExternal,
14
+ } from './runner-external.js';
15
+ import {
16
+ resolveCwd,
17
+ createTaskWorktree,
18
+ removeTaskWorktree,
19
+ } from './runner-worktree.js';
20
+
21
+ export {
22
+ isContextOverflowError,
23
+ terminateProcessTree,
24
+ isTransientError,
25
+ resolveCwd,
26
+ createTaskWorktree,
27
+ removeTaskWorktree,
28
+ };
60
29
 
61
30
  /**
62
31
  * Runner executing scheduled tasks in DeepSeek Harness sessions.
@@ -138,235 +107,36 @@ export class SessionRunner {
138
107
  }
139
108
  }
140
109
 
141
- /**
142
- * Resolve the working directory for a task: explicit cwd, a bound
143
- * workspace id resolved through the harness, or the process cwd (#31).
144
- */
110
+ /** Resolve working directory for task via runner-worktree */
145
111
  resolveCwd(task) {
146
- if (task.cwd) return String(task.cwd);
147
- if (task.workspaceId && this.ctx && typeof this.ctx.get === 'function') {
148
- try {
149
- const workspaces = this.ctx.get('workspaces');
150
- const ws = workspaces && (typeof workspaces.get === 'function'
151
- ? workspaces.get(task.workspaceId)
152
- : (workspaces[task.workspaceId] || null));
153
- const resolved = ws && (ws.path || ws.cwd || ws.root || ws.dir);
154
- if (resolved) return String(resolved);
155
- } catch {}
156
- return String(task.workspaceId);
157
- }
158
- return process.cwd();
112
+ return resolveCwd(task, this.ctx);
159
113
  }
160
114
 
161
- /** Create an isolated git worktree for a code-modifying task (#29). */
115
+ /** Create isolated worktree via runner-worktree */
162
116
  async createTaskWorktree(cwd, task) {
163
- const { execFile } = await import('node:child_process');
164
- const path = await import('node:path');
165
- const wtPath = path.join(cwd, '.dsh-cron-worktrees', `${task.id}-${Date.now()}`);
166
- await new Promise((resolve, reject) => {
167
- execFile('git', ['-C', cwd, 'worktree', 'add', '--detach', wtPath, 'HEAD'], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
168
- if (err) reject(new Error(String(stderr || err.message).trim()));
169
- else resolve(stdout);
170
- });
171
- });
172
- return wtPath;
117
+ return createTaskWorktree(cwd, task);
173
118
  }
174
119
 
175
- /**
176
- * Remove a task worktree; never throws (best-effort cleanup).
177
- * Plain removal only — no force flags anywhere (workspace rule). A dirty
178
- * worktree is left in place and reported instead of being force-deleted.
179
- */
120
+ /** Remove isolated worktree via runner-worktree */
180
121
  async removeTaskWorktree(cwd, wtPath) {
181
- const { execFile } = await import('node:child_process');
182
- await new Promise((resolve) => {
183
- execFile('git', ['-C', cwd, 'worktree', 'remove', wtPath], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
184
- if (err) {
185
- console.warn(`[dsh-cron] task worktree kept (not removable without force): ${wtPath} — ${String(stderr || err.message).trim()}`);
186
- }
187
- execFile('git', ['-C', cwd, 'worktree', 'prune'], () => resolve());
188
- });
189
- });
122
+ return removeTaskWorktree(cwd, wtPath);
190
123
  }
191
124
 
192
- /** HTTP trigger runtime (#7) with transient retry (#134). */
125
+ /** HTTP trigger runtime via runner-external */
193
126
  async _runHttp(task, request, signal) {
194
- let attempts = 0;
195
- const maxAttempts = 3;
196
- while (attempts < maxAttempts) {
197
- attempts++;
198
- try {
199
- const res = await fetch(request.url, {
200
- method: request.method,
201
- headers: request.headers,
202
- body: request.body,
203
- signal,
204
- });
205
- const text = await res.text().catch(() => '');
206
- const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
207
- if (!res.ok) {
208
- const err = new Error(`${summary}\n${String(text).slice(0, 500)}`);
209
- if (isTransientError(res.status) && attempts < maxAttempts && !signal?.aborted) {
210
- console.warn(`[dsh-cron] HTTP request returned ${res.status}, retrying attempt ${attempts + 1}/${maxAttempts}...`);
211
- await new Promise((r) => setTimeout(r, attempts * 1000));
212
- continue;
213
- }
214
- throw err;
215
- }
216
- return {
217
- output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
218
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
219
- costUsd: 0,
220
- };
221
- } catch (err) {
222
- if (isTransientError(err) && attempts < maxAttempts && !signal?.aborted) {
223
- console.warn(`[dsh-cron] HTTP request failed (${err.message}), retrying attempt ${attempts + 1}/${maxAttempts}...`);
224
- await new Promise((r) => setTimeout(r, attempts * 1000));
225
- continue;
226
- }
227
- throw err;
228
- }
229
- }
127
+ return runHttp(task, request, signal);
230
128
  }
231
129
 
232
- /**
233
- * #9 reuse-first path: when the task names a dsh-remote-workspace profile,
234
- * execute through that plugin's `remoteSsh` service so host settings and
235
- * credentials stay owned by it (we only reference the profile id).
236
- * Returns null when the service or the profile is unavailable so the caller
237
- * can fall back to a direct ssh invocation.
238
- */
130
+ /** Remote workspace execution via runner-external */
239
131
  async _runViaRemoteWorkspace(task, signal) {
240
- if (!task.sshProfileId || !this.ctx || typeof this.ctx.get !== 'function') return null;
241
- let svc = null;
242
- try {
243
- svc = this.ctx.get('remoteSsh');
244
- } catch {}
245
- if (!svc || typeof svc.exec !== 'function') return null;
246
-
247
- let profile = null;
248
- try {
249
- const settings = this.ctx.get('settings');
250
- if (settings && typeof settings.get === 'function') {
251
- const snap = settings.get('dsh-remote-workspace');
252
- const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
253
- profile = profiles.find((p) => p && p.id === task.sshProfileId) || null;
254
- }
255
- } catch {}
256
- if (!profile) {
257
- throw new Error(`remote-workspace profile "${task.sshProfileId}" was not found — check dsh-remote-workspace settings, or use sshTarget instead`);
258
- }
259
- const res = await svc.exec(profile, String(task.prompt || 'true'), task.cwd || profile.remoteWorkspace || undefined);
260
- const output = [res && res.stdout, res && res.stderr].filter((x) => x && String(x).trim()).join('\n').trim();
261
- const code = res && typeof res.code === 'number' ? res.code : 0;
262
- if (code !== 0) {
263
- throw new Error(`Remote command exited with code ${code}\n${output}`.slice(0, 4000));
264
- }
265
- return {
266
- output: output || 'Remote command completed with code 0',
267
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
268
- costUsd: 0,
269
- };
132
+ return runViaRemoteWorkspace(task, signal, this.ctx);
270
133
  }
271
134
 
272
- /** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
135
+ /** Non-LLM external runtimes via runner-external */
273
136
  async _runExternal(task, runtime, signal, execOptions = {}) {
274
- if (runtime === 'ssh' && task.sshProfileId) {
275
- const viaService = await this._runViaRemoteWorkspace(task, signal);
276
- if (viaService) return viaService;
277
- if (!task.sshTarget) {
278
- throw new Error('SSH task needs either a working dsh-remote-workspace profile (sshProfileId) or an sshTarget');
279
- }
280
- }
281
- const cwd = this.resolveCwd(task);
282
- const invocation = buildInvocation({ ...task, type: runtime }, cwd);
283
- if (invocation.http) return this._runHttp(task, invocation.http, signal);
284
-
285
- const { spawn, exec } = await import('node:child_process');
286
- return new Promise((resolve, reject) => {
287
- let child = null;
288
- let settled = false;
289
- let stdout = '';
290
- let stderr = '';
291
- const maxBuffer = 10 * 1024 * 1024;
292
- const isPosix = process.platform !== 'win32';
293
-
294
- const killProcessTree = (sig = 'SIGTERM') => {
295
- terminateProcessTree(child, sig, {
296
- isPosix,
297
- execFn: (cmd) => {
298
- try { exec(cmd, { windowsHide: true }, () => {}); } catch {}
299
- }
300
- });
301
- };
302
-
303
- const abortHandler = () => {
304
- killProcessTree('SIGTERM');
305
- setTimeout(() => killProcessTree('SIGKILL'), 300);
306
- };
307
-
308
- if (signal) {
309
- if (signal.aborted) {
310
- return reject(signal.reason || new Error('Command aborted'));
311
- }
312
- signal.addEventListener('abort', abortHandler, { once: true });
313
- }
314
-
315
- const runEnv = { ...invocation.env };
316
- if (execOptions?.prevOutput !== undefined) {
317
- runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
318
- }
319
- const options = {
320
- cwd,
321
- env: runEnv,
322
- detached: isPosix,
323
- stdio: ['ignore', 'pipe', 'pipe'],
324
- };
325
-
326
- if (invocation.file) {
327
- child = spawn(invocation.file, invocation.args || [], options);
328
- } else {
329
- const shellPath = isPosix ? (process.env.SHELL || '/bin/bash') : (process.env.ComSpec || 'cmd.exe');
330
- const shellArgs = isPosix ? ['-c', invocation.shellCommand] : ['/d', '/s', '/c', invocation.shellCommand];
331
- child = spawn(shellPath, shellArgs, options);
332
- }
333
-
334
- child.stdout.on('data', (chunk) => {
335
- if (stdout.length < maxBuffer) stdout += chunk;
336
- });
337
- child.stderr.on('data', (chunk) => {
338
- if (stderr.length < maxBuffer) stderr += chunk;
339
- });
340
-
341
- child.on('error', (err) => {
342
- if (settled) return;
343
- settled = true;
344
- if (signal) signal.removeEventListener('abort', abortHandler);
345
- reject(err);
346
- });
347
-
348
- child.on('close', (code, sig) => {
349
- if (settled) return;
350
- settled = true;
351
- if (signal) signal.removeEventListener('abort', abortHandler);
352
-
353
- if (signal && signal.aborted) {
354
- return reject(signal.reason || new Error('Command aborted'));
355
- }
356
-
357
- if (code !== 0 && code !== null) {
358
- const detail = String(stderr || '').trim();
359
- const msg = `${runtime} task exited with code ${code}`;
360
- return reject(new Error(detail ? `${msg}\n${detail}` : msg));
361
- }
362
-
363
- const out = (stdout || stderr || '').trim();
364
- resolve({
365
- output: out || `${runtime} task completed with code 0`,
366
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
367
- costUsd: 0,
368
- });
369
- });
137
+ return runExternal(task, runtime, signal, execOptions, {
138
+ ctx: this.ctx,
139
+ resolveCwdFn: (t) => this.resolveCwd(t),
370
140
  });
371
141
  }
372
142
 
@@ -438,22 +208,22 @@ export class SessionRunner {
438
208
  // Context window overflow auto-recovery in persistent sessions (#145)
439
209
  if (prep.targetSessionId && isContextOverflowError(err) && !signal?.aborted) {
440
210
  console.warn(`[dsh-cron] Persistent session "${prep.targetSessionId}" exceeded context window (${err.message}). Auto-recovering with fresh session...`);
441
- try {
211
+ bestEffort('archive-rotated-session', () => {
442
212
  const sessions = this.ctx?.get?.('sessions');
443
213
  if (sessions && typeof sessions.archive === 'function') {
444
214
  sessions.archive(prep.targetSessionId);
445
215
  }
446
- } catch {}
216
+ });
447
217
  const rotatedSessionId = `${prep.targetSessionId}-${Date.now().toString(36)}`;
448
218
  console.log(`[dsh-cron] Rotated persistent session "${prep.targetSessionId}" -> "${rotatedSessionId}"`);
449
219
  if (this.store) {
450
- try {
220
+ bestEffort('update-stored-rotated-session', () => {
451
221
  const stored = this.store.get(task.id);
452
222
  if (stored) {
453
223
  stored.targetSessionId = rotatedSessionId;
454
224
  this.store.set(stored);
455
225
  }
456
- } catch {}
226
+ });
457
227
  }
458
228
  task.targetSessionId = rotatedSessionId;
459
229
  effectiveTask.targetSessionId = rotatedSessionId;
@@ -464,20 +234,16 @@ export class SessionRunner {
464
234
  throw err;
465
235
  }
466
236
  }
237
+
467
238
  /** Everything an agent run needs before the session is created. */
468
239
  async _prepareAgentRun(task, runtime, signal) {
469
240
  const { randomUUID } = await import('node:crypto');
470
241
  let createUserMessage = (m) => m;
471
- try {
472
- const llmModule = await import('@deepseek-ai/dsh-llm');
473
- if (llmModule && typeof llmModule.createUserMessage === 'function') {
474
- createUserMessage = llmModule.createUserMessage;
475
- }
476
- } catch {}
477
- let sessionModule = null;
478
- try {
479
- sessionModule = await import('@deepseek-ai/dsh-session');
480
- } catch {}
242
+ const llmModule = await bestEffort('import-dsh-llm', () => import('@deepseek-ai/dsh-llm'));
243
+ if (llmModule && typeof llmModule.createUserMessage === 'function') {
244
+ createUserMessage = llmModule.createUserMessage;
245
+ }
246
+ const sessionModule = await bestEffort('import-dsh-session', () => import('@deepseek-ai/dsh-session'));
481
247
 
482
248
  const targetSessionId = resolveTargetSessionId(task.targetSessionId, task.targetSessionReset);
483
249
 
@@ -550,12 +316,10 @@ export class SessionRunner {
550
316
  _shapeAgentPrompt(task, runtime) {
551
317
  const prompt = String(task.prompt || '');
552
318
  if (runtime === 'skill' && task.skillName) {
553
- return `Use the DSH skill "${task.skillName}" for this task:
554
- ${prompt}`;
319
+ return `Use the DSH skill "${task.skillName}" for this task:\n${prompt}`;
555
320
  }
556
321
  if (runtime === 'workflow' && task.workflowName) {
557
- return `Execute this through the DSH workflow "${task.workflowName}" step by step:
558
- ${prompt}`;
322
+ return `Execute this through the DSH workflow "${task.workflowName}" step by step:\n${prompt}`;
559
323
  }
560
324
  return prompt;
561
325
  }
@@ -665,23 +429,23 @@ ${prompt}`;
665
429
  /** Token usage reported by the session, when the core exposes it. */
666
430
  _extractUsage(handle) {
667
431
  const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
668
- try {
432
+ bestEffort('extract-session-usage', () => {
669
433
  const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
670
434
  if (sessionUsage) {
671
435
  usage.inputTokens = sessionUsage.inputTokens || sessionUsage.promptTokens || 0;
672
436
  usage.outputTokens = sessionUsage.outputTokens || sessionUsage.completionTokens || 0;
673
437
  usage.cacheReadTokens = sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0;
674
438
  }
675
- } catch {}
439
+ });
676
440
  return usage;
677
441
  }
678
442
 
679
443
  /** Dispose the session, archive it best-effort, drop the worktree (#30, #29). */
680
444
  async _disposeAgentRun(task, prep, handle) {
681
445
  let archivedSessionId = null;
682
- try {
683
- archivedSessionId = (handle && handle.agent && handle.agent.session && handle.agent.session.id) || null;
684
- } catch {}
446
+ archivedSessionId = bestEffort('get-archived-session-id', () => {
447
+ return (handle && handle.agent && handle.agent.session && handle.agent.session.id) || null;
448
+ }) || null;
685
449
  try {
686
450
  if (handle && typeof handle.dispose === 'function') {
687
451
  await handle.dispose();
@@ -694,12 +458,12 @@ ${prompt}`;
694
458
  // Best-effort: keep ephemeral cron sessions out of the main chat list (#30).
695
459
  // Persistent sessions (targetSessionId configured) stay unarchived so users can interact with them (#143).
696
460
  if (archivedSessionId && this.ctx && this.ctx.get && !prep.targetSessionId) {
697
- try {
461
+ bestEffort('archive-session-cleanup', () => {
698
462
  const sessions = this.ctx.get('sessions');
699
463
  if (sessions && typeof sessions.archive === 'function') {
700
464
  sessions.archive(archivedSessionId);
701
465
  }
702
- } catch {}
466
+ });
703
467
  }
704
468
  // #29: dispose the isolated worktree (kept when keepWorktree is set).
705
469
  if (prep.taskWorktree && !task.keepWorktree) {
@@ -707,3 +471,4 @@ ${prompt}`;
707
471
  }
708
472
  }
709
473
  }
474
+
package/lib/runtimes.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  /**
2
3
  * Runtime invocation builders for non-LLM task types (#4, #5, #7, #8, #9, #38).
3
4
  *
@@ -68,9 +69,8 @@ function detectPython(task, cwd) {
68
69
  candidates.push(path.join(base, '.venv', 'Scripts', 'python.exe'));
69
70
  candidates.push(path.join(base, 'venv', 'bin', 'python'));
70
71
  for (const candidate of candidates) {
71
- try {
72
- if (fs.existsSync(candidate)) return candidate;
73
- } catch {}
72
+ const exists = bestEffort('check-python-candidate', () => fs.existsSync(candidate));
73
+ if (exists) return candidate;
74
74
  }
75
75
  return process.platform === 'win32' ? 'python' : 'python3';
76
76
  }