@goodandready/dsh-cron 0.2.4 → 0.2.6

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
@@ -208,190 +208,229 @@ export class SessionRunner {
208
208
  return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
209
209
  }
210
210
 
211
- // 2. If ctx.agents is available, run through agent session (LLM)
211
+ // 2. Agent session, when the harness exposes one
212
212
  if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
213
- try {
214
- const { randomUUID } = await import('node:crypto');
215
- let createUserMessage = (m) => m;
216
- try {
217
- const llmModule = await import('@deepseek-ai/dsh-llm');
218
- if (llmModule && typeof llmModule.createUserMessage === 'function') {
219
- createUserMessage = llmModule.createUserMessage;
220
- }
221
- } catch {}
213
+ return this._runAgentSession(task, runtime, signal);
214
+ }
222
215
 
223
- let sessionModule = null;
224
- try {
225
- sessionModule = await import('@deepseek-ai/dsh-session');
226
- } catch {}
216
+ // 3. Legacy session service
217
+ if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
218
+ return this._runLegacySession(task);
219
+ }
227
220
 
228
- const mintSessionId = () => {
229
- const sid = `cron-exec-${task.id}-${randomUUID()}`;
230
- if (sessionModule && typeof sessionModule.SessionId === 'function') {
231
- return sessionModule.SessionId(sid);
232
- }
233
- return sid;
234
- };
235
-
236
- const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
237
- const provider = task.provider || defaultSel?.provider;
238
- const model = task.model || defaultSel?.model || undefined;
239
-
240
- // The agent session cannot take the AbortSignal natively, so any abort
241
- // (timeout, overlap replace, pause, scheduler stop) races against the
242
- // session awaits and forces an immediate dispose (#88).
243
- const abortError = () => (signal && signal.reason) || new Error('Task aborted');
244
- const raceAbort = (promise) => {
245
- if (!signal) return promise;
246
- return Promise.race([
247
- promise,
248
- new Promise((_, reject) => {
249
- if (signal.aborted) {
250
- reject(abortError());
251
- return;
252
- }
253
- signal.addEventListener('abort', () => reject(abortError()), { once: true });
254
- }),
255
- ]);
256
- };
257
-
258
- const baseCwd = this.resolveCwd(task);
259
- // #29: code-modifying tasks may run in an isolated git worktree.
260
- let cwd = baseCwd;
261
- let taskWorktree = null;
262
- if (task.worktree) {
263
- try {
264
- taskWorktree = await this.createTaskWorktree(baseCwd, task);
265
- cwd = taskWorktree;
266
- } catch (wtErr) {
267
- console.warn('[dsh-cron] task worktree not created, running in the workspace:', wtErr.message);
268
- }
269
- }
270
- // #6/#10: skill- and workflow-driven tasks are agent-mediated with a
271
- // shaped prompt naming the skill/workflow.
272
- let agentPrompt = String(task.prompt || '');
273
- if (runtime === 'skill' && task.skillName) {
274
- agentPrompt = `Use the DSH skill "${task.skillName}" for this task:\n${agentPrompt}`;
275
- } else if (runtime === 'workflow' && task.workflowName) {
276
- agentPrompt = `Execute this through the DSH workflow "${task.workflowName}" step by step:\n${agentPrompt}`;
277
- }
278
- let handle = null;
279
- try {
280
- handle = await this.ctx.agents.create({
281
- sessionId: mintSessionId(),
282
- meta: { cwd, ephemeral: true, internal: true },
283
- agentOptions: provider && model ? { provider, model } : undefined,
284
- });
285
-
286
- // Per-job permission preset (#32): read-only | workspace-write | full.
287
- if (task.permissionPreset && task.permissionPreset !== 'default') {
288
- try {
289
- const presets = this.ctx.get && this.ctx.get('permissionPresets');
290
- if (presets && typeof presets.set === 'function') {
291
- presets.set(handle.agent.session, task.permissionPreset);
292
- }
293
- } catch (ppErr) {
294
- console.warn('[dsh-cron] permission preset not applied:', ppErr.message);
295
- }
296
- }
221
+ // 4. Nothing to run through: report the prompt as triggered
222
+ return this._runBare(task);
223
+ }
297
224
 
298
- await raceAbort(handle.agent.whenIdle());
299
- handle.agent.followup(createUserMessage({
300
- content: [{ type: 'text', text: agentPrompt }],
301
- source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
302
- }));
225
+ /** Legacy session service path; falls back to the bare report. */
226
+ async _runLegacySession(task) {
227
+ try {
228
+ const session = await this.ctx.session.create({
229
+ title: `[Cron] ${task.title}`,
230
+ ephemeral: task.delivery === 'isolated',
231
+ });
232
+ if (session && typeof session.send === 'function') {
233
+ const result = await session.send(task.prompt);
234
+ const out = typeof result === 'string' ? result : JSON.stringify(result);
235
+ const usage = session.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
236
+ return { output: out, usage, costUsd: estimateTokenCost(task.model, usage) };
237
+ }
238
+ } catch (err) {
239
+ console.error(`[dsh-cron] session execution failed:`, err.message);
240
+ throw err;
241
+ }
242
+ return this._runBare(task);
243
+ }
303
244
 
304
- // Await agent turn completion (#67), abortable on timeout/overlap (#88)
305
- if (typeof handle.agent.whenIdle === 'function') {
306
- await raceAbort(handle.agent.whenIdle());
307
- }
245
+ _runBare(task) {
246
+ return {
247
+ output: `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`,
248
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
249
+ costUsd: 0,
250
+ };
251
+ }
308
252
 
309
- // Try extracting token usage from session history if available
310
- let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
311
- try {
312
- const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
313
- if (sessionUsage) {
314
- usage = {
315
- inputTokens: sessionUsage.inputTokens || sessionUsage.promptTokens || 0,
316
- outputTokens: sessionUsage.outputTokens || sessionUsage.completionTokens || 0,
317
- cacheReadTokens: sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0,
318
- };
319
- }
320
- } catch {}
321
-
322
- const costUsd = estimateTokenCost(model, usage);
323
-
324
- return {
325
- output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
326
- usage,
327
- costUsd,
328
- sessionId: handle.agent.session?.id || null
329
- };
330
- } finally {
331
- let archivedSessionId = null;
332
- try {
333
- archivedSessionId = (handle && handle.agent && handle.agent.session && handle.agent.session.id) || null;
334
- } catch {}
335
- try {
336
- if (handle && typeof handle.dispose === 'function') {
337
- await handle.dispose();
338
- } else if (handle && handle.agent && typeof handle.agent.destroy === 'function') {
339
- await handle.agent.destroy();
340
- }
341
- } catch (disposeErr) {
342
- console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
343
- }
344
- // Best-effort: keep ephemeral cron sessions out of the main chat
345
- // list (#30). The archive call degrades silently when the core
346
- // does not expose it.
347
- if (archivedSessionId && this.ctx && this.ctx.get) {
348
- try {
349
- const sessions = this.ctx.get('sessions');
350
- if (sessions && typeof sessions.archive === 'function') {
351
- sessions.archive(archivedSessionId);
352
- }
353
- } catch {}
354
- }
355
- // #29: dispose the isolated worktree (kept when keepWorktree is set).
356
- if (taskWorktree && !task.keepWorktree) {
357
- await this.removeTaskWorktree(baseCwd, taskWorktree);
253
+ async _runAgentSession(task, runtime, signal) {
254
+ try {
255
+ const prep = await this._prepareAgentRun(task, runtime, signal);
256
+ return await this._executeAgentTurn(task, prep);
257
+ } catch (err) {
258
+ console.error(`[dsh-cron] agent execution failed:`, err.message);
259
+ throw err;
260
+ }
261
+ }
262
+ /** Everything an agent run needs before the session is created. */
263
+ async _prepareAgentRun(task, runtime, signal) {
264
+ const { randomUUID } = await import('node:crypto');
265
+ let createUserMessage = (m) => m;
266
+ try {
267
+ const llmModule = await import('@deepseek-ai/dsh-llm');
268
+ if (llmModule && typeof llmModule.createUserMessage === 'function') {
269
+ createUserMessage = llmModule.createUserMessage;
270
+ }
271
+ } catch {}
272
+ let sessionModule = null;
273
+ try {
274
+ sessionModule = await import('@deepseek-ai/dsh-session');
275
+ } catch {}
276
+
277
+ const mintSessionId = () => {
278
+ const sid = `cron-exec-${task.id}-${randomUUID()}`;
279
+ if (sessionModule && typeof sessionModule.SessionId === 'function') {
280
+ return sessionModule.SessionId(sid);
281
+ }
282
+ return sid;
283
+ };
284
+
285
+ const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
286
+ return {
287
+ createUserMessage,
288
+ mintSessionId,
289
+ provider: task.provider || defaultSel?.provider,
290
+ model: task.model || defaultSel?.model || undefined,
291
+ ...(await this._resolveRunContext(task)),
292
+ agentPrompt: this._shapeAgentPrompt(task, runtime),
293
+ raceAbort: this._makeAbortRacer(signal),
294
+ };
295
+ }
296
+
297
+ /**
298
+ * The agent session cannot take the AbortSignal natively, so any abort
299
+ * (timeout, overlap replace, pause, scheduler stop) races against the session
300
+ * awaits and forces an immediate dispose (#88).
301
+ */
302
+ _makeAbortRacer(signal) {
303
+ const abortError = () => (signal && signal.reason) || new Error('Task aborted');
304
+ return (promise) => {
305
+ if (!signal) return promise;
306
+ return Promise.race([
307
+ promise,
308
+ new Promise((_, reject) => {
309
+ if (signal.aborted) {
310
+ reject(abortError());
311
+ return;
358
312
  }
359
- }
360
- } catch (err) {
361
- console.error(`[dsh-cron] agent execution failed:`, err.message);
362
- throw err;
313
+ signal.addEventListener('abort', () => reject(abortError()), { once: true });
314
+ }),
315
+ ]);
316
+ };
317
+ }
318
+
319
+ /** Working directory for the run, in an isolated worktree when requested (#29). */
320
+ async _resolveRunContext(task) {
321
+ const baseCwd = this.resolveCwd(task);
322
+ let cwd = baseCwd;
323
+ let taskWorktree = null;
324
+ if (task.worktree) {
325
+ try {
326
+ taskWorktree = await this.createTaskWorktree(baseCwd, task);
327
+ cwd = taskWorktree;
328
+ } catch (wtErr) {
329
+ console.warn('[dsh-cron] task worktree not created, running in the workspace:', wtErr.message);
363
330
  }
364
331
  }
332
+ return { baseCwd, cwd, taskWorktree };
333
+ }
365
334
 
366
- // 3. Check if legacy session service is present
367
- if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
368
- try {
369
- const session = await this.ctx.session.create({
370
- title: `[Cron] ${task.title}`,
371
- ephemeral: task.delivery === 'isolated',
372
- });
335
+ /** #6/#10: skill- and workflow-driven tasks name the skill/workflow. */
336
+ _shapeAgentPrompt(task, runtime) {
337
+ const prompt = String(task.prompt || '');
338
+ if (runtime === 'skill' && task.skillName) {
339
+ return `Use the DSH skill "${task.skillName}" for this task:
340
+ ${prompt}`;
341
+ }
342
+ if (runtime === 'workflow' && task.workflowName) {
343
+ return `Execute this through the DSH workflow "${task.workflowName}" step by step:
344
+ ${prompt}`;
345
+ }
346
+ return prompt;
347
+ }
373
348
 
374
- if (session && typeof session.send === 'function') {
375
- const result = await session.send(task.prompt);
376
- const out = typeof result === 'string' ? result : JSON.stringify(result);
377
- const usage = session.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
378
- const costUsd = estimateTokenCost(task.model, usage);
379
- return {
380
- output: out,
381
- usage,
382
- costUsd
383
- };
384
- }
385
- } catch (err) {
386
- console.error(`[dsh-cron] session execution failed:`, err.message);
387
- throw err;
349
+ /** Create the session, drive one turn, and always clean up. */
350
+ async _executeAgentTurn(task, prep) {
351
+ let handle = null;
352
+ try {
353
+ handle = await this.ctx.agents.create({
354
+ sessionId: prep.mintSessionId(),
355
+ meta: { cwd: prep.cwd, ephemeral: true, internal: true },
356
+ agentOptions: prep.provider && prep.model ? { provider: prep.provider, model: prep.model } : undefined,
357
+ });
358
+ this._applyPermissionPreset(task, handle);
359
+ await prep.raceAbort(handle.agent.whenIdle());
360
+ handle.agent.followup(prep.createUserMessage({
361
+ content: [{ type: 'text', text: prep.agentPrompt }],
362
+ source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
363
+ }));
364
+ // Await agent turn completion (#67), abortable on timeout/overlap (#88)
365
+ if (typeof handle.agent.whenIdle === 'function') {
366
+ await prep.raceAbort(handle.agent.whenIdle());
388
367
  }
368
+ const usage = this._extractUsage(handle);
369
+ return {
370
+ output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
371
+ usage,
372
+ costUsd: estimateTokenCost(prep.model, usage),
373
+ sessionId: handle.agent.session?.id || null,
374
+ };
375
+ } finally {
376
+ await this._disposeAgentRun(task, prep, handle);
389
377
  }
378
+ }
390
379
 
391
- return {
392
- output: `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`,
393
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
394
- costUsd: 0
395
- };
380
+ /** Per-job permission preset (#32): read-only | workspace-write | full. */
381
+ _applyPermissionPreset(task, handle) {
382
+ if (!task.permissionPreset || task.permissionPreset === 'default') return;
383
+ try {
384
+ const presets = this.ctx.get && this.ctx.get('permissionPresets');
385
+ if (presets && typeof presets.set === 'function') {
386
+ presets.set(handle.agent.session, task.permissionPreset);
387
+ }
388
+ } catch (ppErr) {
389
+ console.warn('[dsh-cron] permission preset not applied:', ppErr.message);
390
+ }
391
+ }
392
+
393
+ /** Token usage reported by the session, when the core exposes it. */
394
+ _extractUsage(handle) {
395
+ const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
396
+ try {
397
+ const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
398
+ if (sessionUsage) {
399
+ usage.inputTokens = sessionUsage.inputTokens || sessionUsage.promptTokens || 0;
400
+ usage.outputTokens = sessionUsage.outputTokens || sessionUsage.completionTokens || 0;
401
+ usage.cacheReadTokens = sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0;
402
+ }
403
+ } catch {}
404
+ return usage;
405
+ }
406
+
407
+ /** Dispose the session, archive it best-effort, drop the worktree (#30, #29). */
408
+ async _disposeAgentRun(task, prep, handle) {
409
+ let archivedSessionId = null;
410
+ try {
411
+ archivedSessionId = (handle && handle.agent && handle.agent.session && handle.agent.session.id) || null;
412
+ } catch {}
413
+ try {
414
+ if (handle && typeof handle.dispose === 'function') {
415
+ await handle.dispose();
416
+ } else if (handle && handle.agent && typeof handle.agent.destroy === 'function') {
417
+ await handle.agent.destroy();
418
+ }
419
+ } catch (disposeErr) {
420
+ console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
421
+ }
422
+ // Best-effort: keep ephemeral cron sessions out of the main chat list (#30).
423
+ if (archivedSessionId && this.ctx && this.ctx.get) {
424
+ try {
425
+ const sessions = this.ctx.get('sessions');
426
+ if (sessions && typeof sessions.archive === 'function') {
427
+ sessions.archive(archivedSessionId);
428
+ }
429
+ } catch {}
430
+ }
431
+ // #29: dispose the isolated worktree (kept when keepWorktree is set).
432
+ if (prep.taskWorktree && !task.keepWorktree) {
433
+ await this.removeTaskWorktree(prep.baseCwd, prep.taskWorktree);
434
+ }
396
435
  }
397
436
  }