@goodandready/dsh-cron 0.2.19 → 0.2.21

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,63 +1,31 @@
1
1
  import { bestEffort } from './best-effort.js';
2
- /**
3
- * Helper to detect context window overflow errors in persistent sessions (#145).
4
- */
5
- export function isContextOverflowError(err) {
6
- const msg = String(err?.message || err || '').toLowerCase();
7
- return msg.includes('context length') ||
8
- msg.includes('context window') ||
9
- msg.includes('maximum context') ||
10
- msg.includes('too many tokens') ||
11
- msg.includes('prompt too long') ||
12
- msg.includes('token limit') ||
13
- msg.includes('context_length_exceeded') ||
14
- msg.includes('string_above_max_length');
15
- }
16
-
17
- /**
18
- * Cross-platform process tree killer (#145).
19
- * On Windows, calls `taskkill /pid <pid> /T /F` to terminate the entire process hierarchy.
20
- */
21
- export function terminateProcessTree(child, sig = 'SIGTERM', { isPosix = process.platform !== 'win32', execFn } = {}) {
22
- if (!child || child.killed) return;
23
- bestEffort('terminate-process-tree', () => {
24
- if (isPosix && child.pid) {
25
- bestEffort('kill-posix-pid', () => process.kill(-child.pid, sig));
26
- } else if (child.pid) {
27
- const runner = execFn || ((cmd) => {
28
- bestEffort('exec-taskkill-fn', () => {
29
- import('node:child_process').then(({ exec }) => {
30
- exec(cmd, { windowsHide: true }, () => {});
31
- }).catch(() => {});
32
- });
33
- });
34
- bestEffort('exec-taskkill', () => runner(`taskkill /pid ${child.pid} /T /F`));
35
- bestEffort('child-kill', () => child.kill(sig));
36
- }
37
- });
38
- }
39
-
40
- /**
41
- * Helper to test if an error code or status is transient and eligible for retry (#134).
42
- */
43
- export function isTransientError(errOrStatus) {
44
- if (typeof errOrStatus === 'number') {
45
- return errOrStatus === 429 || errOrStatus === 502 || errOrStatus === 503 || errOrStatus === 504;
46
- }
47
- const str = String(errOrStatus?.message || errOrStatus || '').toLowerCase();
48
- return str.includes('429') || str.includes('too many requests') ||
49
- str.includes('502') || str.includes('bad gateway') ||
50
- str.includes('503') || str.includes('service unavailable') ||
51
- str.includes('504') || str.includes('gateway timeout') ||
52
- str.includes('econnreset') || str.includes('etimedout');
53
- }
54
-
55
2
  import { estimateTokenCost } from './integrations.js';
56
3
  import {
57
4
  EXTERNAL_RUNTIMES,
58
5
  normalizeTaskType,
59
- buildInvocation,
60
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
+ };
61
29
 
62
30
  /**
63
31
  * Runner executing scheduled tasks in DeepSeek Harness sessions.
@@ -139,234 +107,36 @@ export class SessionRunner {
139
107
  }
140
108
  }
141
109
 
142
- /**
143
- * Resolve the working directory for a task: explicit cwd, a bound
144
- * workspace id resolved through the harness, or the process cwd (#31).
145
- */
110
+ /** Resolve working directory for task via runner-worktree */
146
111
  resolveCwd(task) {
147
- if (task.cwd) return String(task.cwd);
148
- if (task.workspaceId && this.ctx && typeof this.ctx.get === 'function') {
149
- const resolved = bestEffort('resolve-workspace-path', () => {
150
- const workspaces = this.ctx.get('workspaces');
151
- const ws = workspaces && (typeof workspaces.get === 'function'
152
- ? workspaces.get(task.workspaceId)
153
- : (workspaces[task.workspaceId] || null));
154
- return ws && (ws.path || ws.cwd || ws.root || ws.dir);
155
- });
156
- if (resolved) return String(resolved);
157
- return String(task.workspaceId);
158
- }
159
- return process.cwd();
112
+ return resolveCwd(task, this.ctx);
160
113
  }
161
114
 
162
- /** Create an isolated git worktree for a code-modifying task (#29). */
115
+ /** Create isolated worktree via runner-worktree */
163
116
  async createTaskWorktree(cwd, task) {
164
- const { execFile } = await import('node:child_process');
165
- const path = await import('node:path');
166
- const wtPath = path.join(cwd, '.dsh-cron-worktrees', `${task.id}-${Date.now()}`);
167
- await new Promise((resolve, reject) => {
168
- execFile('git', ['-C', cwd, 'worktree', 'add', '--detach', wtPath, 'HEAD'], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
169
- if (err) reject(new Error(String(stderr || err.message).trim()));
170
- else resolve(stdout);
171
- });
172
- });
173
- return wtPath;
117
+ return createTaskWorktree(cwd, task);
174
118
  }
175
119
 
176
- /**
177
- * Remove a task worktree; never throws (best-effort cleanup).
178
- * Plain removal only — no force flags anywhere (workspace rule). A dirty
179
- * worktree is left in place and reported instead of being force-deleted.
180
- */
120
+ /** Remove isolated worktree via runner-worktree */
181
121
  async removeTaskWorktree(cwd, wtPath) {
182
- const { execFile } = await import('node:child_process');
183
- await new Promise((resolve) => {
184
- execFile('git', ['-C', cwd, 'worktree', 'remove', wtPath], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
185
- if (err) {
186
- console.warn(`[dsh-cron] task worktree kept (not removable without force): ${wtPath} — ${String(stderr || err.message).trim()}`);
187
- }
188
- execFile('git', ['-C', cwd, 'worktree', 'prune'], () => resolve());
189
- });
190
- });
122
+ return removeTaskWorktree(cwd, wtPath);
191
123
  }
192
124
 
193
- /** HTTP trigger runtime (#7) with transient retry (#134). */
125
+ /** HTTP trigger runtime via runner-external */
194
126
  async _runHttp(task, request, signal) {
195
- let attempts = 0;
196
- const maxAttempts = 3;
197
- while (attempts < maxAttempts) {
198
- attempts++;
199
- try {
200
- const res = await fetch(request.url, {
201
- method: request.method,
202
- headers: request.headers,
203
- body: request.body,
204
- signal,
205
- });
206
- const text = await res.text().catch(() => '');
207
- const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
208
- if (!res.ok) {
209
- const err = new Error(`${summary}\n${String(text).slice(0, 500)}`);
210
- if (isTransientError(res.status) && attempts < maxAttempts && !signal?.aborted) {
211
- console.warn(`[dsh-cron] HTTP request returned ${res.status}, retrying attempt ${attempts + 1}/${maxAttempts}...`);
212
- await new Promise((r) => setTimeout(r, attempts * 1000));
213
- continue;
214
- }
215
- throw err;
216
- }
217
- return {
218
- output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
219
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
220
- costUsd: 0,
221
- };
222
- } catch (err) {
223
- if (isTransientError(err) && attempts < maxAttempts && !signal?.aborted) {
224
- console.warn(`[dsh-cron] HTTP request failed (${err.message}), retrying attempt ${attempts + 1}/${maxAttempts}...`);
225
- await new Promise((r) => setTimeout(r, attempts * 1000));
226
- continue;
227
- }
228
- throw err;
229
- }
230
- }
127
+ return runHttp(task, request, signal);
231
128
  }
232
129
 
233
- /**
234
- * #9 reuse-first path: when the task names a dsh-remote-workspace profile,
235
- * execute through that plugin's `remoteSsh` service so host settings and
236
- * credentials stay owned by it (we only reference the profile id).
237
- * Returns null when the service or the profile is unavailable so the caller
238
- * can fall back to a direct ssh invocation.
239
- */
130
+ /** Remote workspace execution via runner-external */
240
131
  async _runViaRemoteWorkspace(task, signal) {
241
- if (!task.sshProfileId || !this.ctx || typeof this.ctx.get !== 'function') return null;
242
- let svc = null;
243
- svc = bestEffort('get-remoteSsh', () => this.ctx.get('remoteSsh'));
244
- if (!svc || typeof svc.exec !== 'function') return null;
245
-
246
- let profile = null;
247
- profile = bestEffort('get-remoteSsh-profile', () => {
248
- const settings = this.ctx.get('settings');
249
- if (settings && typeof settings.get === 'function') {
250
- const snap = settings.get('dsh-remote-workspace');
251
- const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
252
- return profiles.find((p) => p && p.id === task.sshProfileId) || null;
253
- }
254
- return null;
255
- });
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
- bestEffort('exec-cmd', () => exec(cmd, { windowsHide: true }, () => {}));
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
 
@@ -464,6 +234,7 @@ 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');
@@ -545,12 +316,10 @@ export class SessionRunner {
545
316
  _shapeAgentPrompt(task, runtime) {
546
317
  const prompt = String(task.prompt || '');
547
318
  if (runtime === 'skill' && task.skillName) {
548
- return `Use the DSH skill "${task.skillName}" for this task:
549
- ${prompt}`;
319
+ return `Use the DSH skill "${task.skillName}" for this task:\n${prompt}`;
550
320
  }
551
321
  if (runtime === 'workflow' && task.workflowName) {
552
- return `Execute this through the DSH workflow "${task.workflowName}" step by step:
553
- ${prompt}`;
322
+ return `Execute this through the DSH workflow "${task.workflowName}" step by step:\n${prompt}`;
554
323
  }
555
324
  return prompt;
556
325
  }
@@ -702,3 +471,4 @@ ${prompt}`;
702
471
  }
703
472
  }
704
473
  }
474
+