@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/README.md +16 -1
- package/README.ru.md +17 -2
- package/README.zh.md +17 -2
- package/lib/api-helpers.js +68 -0
- package/lib/api-import-export.js +145 -0
- package/lib/api-webhook.js +115 -0
- package/lib/api.js +24 -309
- package/lib/best-effort.js +29 -0
- package/lib/channels.js +6 -1
- package/lib/client.js +80 -41
- package/lib/index.js +25 -361
- package/lib/llm-actions.js +9 -6
- package/lib/llm-ask.js +8 -9
- package/lib/recipes.js +3 -1
- package/lib/routes.js +194 -0
- package/lib/runner-external.js +238 -0
- package/lib/runner-worktree.js +55 -0
- package/lib/runner.js +57 -292
- package/lib/runtimes.js +3 -3
- package/lib/scheduler-execution.js +494 -0
- package/lib/scheduler-lifecycle.js +143 -0
- package/lib/scheduler.js +120 -643
- package/lib/secrets.js +6 -7
- package/lib/settings.js +120 -0
- package/lib/store.js +5 -8
- package/lib/task-create.js +60 -0
- package/lib/templates.js +5 -1
- package/package.json +5 -4
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
|
-
|
|
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
|
|
115
|
+
/** Create isolated worktree via runner-worktree */
|
|
162
116
|
async createTaskWorktree(cwd, task) {
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
125
|
+
/** HTTP trigger runtime via runner-external */
|
|
193
126
|
async _runHttp(task, request, signal) {
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
135
|
+
/** Non-LLM external runtimes via runner-external */
|
|
273
136
|
async _runExternal(task, runtime, signal, execOptions = {}) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
72
|
-
|
|
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
|
}
|