@goodandready/dsh-cron 0.2.5 → 0.2.7
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 +144 -6
- package/docs/README.ru.md +147 -5
- package/docs/README.zh.md +144 -6
- package/lib/api.js +533 -0
- package/lib/channels.js +6 -2
- package/lib/client.js +185 -12
- package/lib/config-jobs.js +206 -0
- package/lib/external-api.js +74 -0
- package/lib/failure-inspector.js +107 -0
- package/lib/http-utils.js +24 -0
- package/lib/index.js +176 -388
- package/lib/llm-ask.js +141 -0
- package/lib/metrics.js +97 -0
- package/lib/recipes.js +238 -0
- package/lib/runner.js +210 -171
- package/lib/scheduler.js +373 -192
- package/lib/silent-rule.js +100 -0
- package/lib/store.js +14 -3
- package/lib/task-patch.js +98 -0
- package/lib/task-transfer.js +4 -0
- package/lib/templates.js +4 -0
- package/package.json +3 -2
- package/docs/design/DESIGN.md +0 -81
- package/docs/plans/0.2.5-ui-block.md +0 -50
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.
|
|
211
|
+
// 2. Agent session, when the harness exposes one
|
|
212
212
|
if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
}
|