@qihongmu/dsh-plugins-scheduled-task 0.1.0
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/cordis.patch.yml +5 -0
- package/lib/typert.remote-client.d.ts +32 -0
- package/lib/types/domain.d.ts +58 -0
- package/lib/types/domain.js +401 -0
- package/lib/types/index.d.ts +121 -0
- package/lib/types/index.js +506 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +30 -0
- package/lib/types/spec.d.ts +134 -0
- package/lib/types/spec.js +89 -0
- package/lib/types/types.d.ts +179 -0
- package/lib/types/types.js +5 -0
- package/package.json +63 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External global scheduled-task capability: durable registry, scheduler,
|
|
3
|
+
* delivery, and Remote surface. Reuses only shipped dsh API — no repo edits.
|
|
4
|
+
* @module @qihongmu/dsh-plugins-scheduled-task
|
|
5
|
+
*/
|
|
6
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
7
|
+
var useValue = arguments.length > 2;
|
|
8
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
9
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
10
|
+
}
|
|
11
|
+
return useValue ? value : void 0;
|
|
12
|
+
};
|
|
13
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
14
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
15
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
16
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
17
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
18
|
+
var _, done = false;
|
|
19
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
20
|
+
var context = {};
|
|
21
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
22
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
23
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
24
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
25
|
+
if (kind === "accessor") {
|
|
26
|
+
if (result === void 0) continue;
|
|
27
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
28
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
29
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
30
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
31
|
+
}
|
|
32
|
+
else if (_ = accept(result)) {
|
|
33
|
+
if (kind === "field") initializers.unshift(_);
|
|
34
|
+
else descriptor[key] = _;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
38
|
+
done = true;
|
|
39
|
+
};
|
|
40
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
41
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
42
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
43
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
44
|
+
import { WorkspaceId } from '@deepseek-ai/dsh-workspace';
|
|
45
|
+
import { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval';
|
|
46
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
47
|
+
import { advanceRule, buildRule, renderTaskFraming, ScheduledTaskError, ScheduledTaskId, scheduledTaskView, validatePrompt, validateTitle, } from "./domain.js";
|
|
48
|
+
import { scheduledTaskDomainSpec } from "./spec.js";
|
|
49
|
+
export { ScheduledTaskError, ScheduledTaskId, advanceRule, buildRule, renderTaskFraming, scheduledTaskView, validatePrompt, validateTitle, } from "./domain.js";
|
|
50
|
+
export { scheduledTaskDomainSpec, scheduledTaskRecord } from "./spec.js";
|
|
51
|
+
/** Largest delay Node timers represent without clamping. */
|
|
52
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
53
|
+
/** Render an unknown value for process-local diagnostics only. */
|
|
54
|
+
function renderThrown(value) {
|
|
55
|
+
return value instanceof Error ? value.message : String(value);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Retry delay after `count` consecutive admission failures: 30s doubling,
|
|
59
|
+
* capped at 5 minutes. Exported pure for tests.
|
|
60
|
+
*/
|
|
61
|
+
export function admissionBackoffMs(count) {
|
|
62
|
+
return Math.min(30_000 * 2 ** (count - 1), 300_000);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Delay until the earliest active task's scheduled instant: an overdue target
|
|
66
|
+
* fires immediately (0), a far target is clamped to the maximum timer delay,
|
|
67
|
+
* and a schedule with no finite active target arms nothing (`undefined`).
|
|
68
|
+
* Exported pure for tests; `rearm` is its only consumer.
|
|
69
|
+
*/
|
|
70
|
+
export function nextDelayMs(records, now) {
|
|
71
|
+
let earliest;
|
|
72
|
+
for (const [, record] of records) {
|
|
73
|
+
if (record.status !== 'active')
|
|
74
|
+
continue;
|
|
75
|
+
const target = Date.parse(record.rule.scheduledAt);
|
|
76
|
+
if (Number.isFinite(target) && (earliest === undefined || target < earliest))
|
|
77
|
+
earliest = target;
|
|
78
|
+
}
|
|
79
|
+
if (earliest === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
return Math.min(Math.max(earliest - now, 0), MAX_TIMER_DELAY_MS);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a resume failure means the run session simply is not persisted yet
|
|
85
|
+
* (first run of this task+project pair) — the only case where falling back to
|
|
86
|
+
* `create` is safe. Any other failure is rethrown so it surfaces as a visible
|
|
87
|
+
* task error instead of silently re-creating a session under the same id.
|
|
88
|
+
*
|
|
89
|
+
* The upstream agents service has no stable machine-readable "missing session"
|
|
90
|
+
* contract, so this matches the human-readable phrasings it emits today.
|
|
91
|
+
* Caveat: an upstream copy change silently breaks resume-vs-create
|
|
92
|
+
* discrimination (a truly missing session would then be rethrown as a visible
|
|
93
|
+
* task error, never silently re-created). If upstream ever exposes a stable
|
|
94
|
+
* error type/code, prefer that over this regex.
|
|
95
|
+
*/
|
|
96
|
+
function isMissingSessionError(error) {
|
|
97
|
+
return /\bnot found\b|\bdoes not exist\b|\bno such session\b|\bunknown session\b/
|
|
98
|
+
.test(renderThrown(error).toLowerCase());
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Deterministic run-session identity for one task+project pair. Deriving the id
|
|
102
|
+
* from the cwd makes a project edit migrate the task to a FRESH conversation in
|
|
103
|
+
* the new workspace (workspace listings index sessions by their header's
|
|
104
|
+
* canonical cwd), while toggling back to an earlier project re-attaches that
|
|
105
|
+
* project's existing history.
|
|
106
|
+
*/
|
|
107
|
+
function runSessionIdOf(taskId, cwd) {
|
|
108
|
+
const digest = createHash('sha256').update(cwd).digest('hex').slice(0, 8);
|
|
109
|
+
return SessionId(`task-${taskId}-${digest}`);
|
|
110
|
+
}
|
|
111
|
+
/** Whether a table entry is a task currently due for admission. */
|
|
112
|
+
function isDue(record, now) {
|
|
113
|
+
return record.status === 'active' && Date.parse(record.rule.scheduledAt) <= now;
|
|
114
|
+
}
|
|
115
|
+
/** Merge optional carry fields (prompt/project/model/confirmation) onto a record. */
|
|
116
|
+
function applyCarry(target, carry) {
|
|
117
|
+
if (carry.prompt !== undefined)
|
|
118
|
+
target.prompt = validatePrompt(carry.prompt);
|
|
119
|
+
if (carry.confirmBeforeChange !== undefined)
|
|
120
|
+
target.confirmBeforeChange = carry.confirmBeforeChange;
|
|
121
|
+
if (carry.workspaceId !== undefined)
|
|
122
|
+
target.workspaceId = carry.workspaceId;
|
|
123
|
+
if (carry.cwd !== undefined)
|
|
124
|
+
target.cwd = carry.cwd;
|
|
125
|
+
if (carry.model !== undefined)
|
|
126
|
+
target.model = carry.model;
|
|
127
|
+
return target;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Global scheduled-task capability. The `tasks` storage-domain table is the
|
|
131
|
+
* only durable task authority; the in-memory table is its live projection, and
|
|
132
|
+
* the single timer is a disposable projection of the earliest due target. Each
|
|
133
|
+
* task's run Session is created lazily on first fire and kept live while the
|
|
134
|
+
* process runs.
|
|
135
|
+
*/
|
|
136
|
+
let ScheduledTaskService = (() => {
|
|
137
|
+
let _classSuper = TypertRemoteService;
|
|
138
|
+
let _instanceExtraInitializers = [];
|
|
139
|
+
let _list_decorators;
|
|
140
|
+
let _create_decorators;
|
|
141
|
+
let _update_decorators;
|
|
142
|
+
let _setStatus_decorators;
|
|
143
|
+
let _delete_decorators;
|
|
144
|
+
let _markRead_decorators;
|
|
145
|
+
return class ScheduledTaskService extends _classSuper {
|
|
146
|
+
static {
|
|
147
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
148
|
+
_list_decorators = [Remote];
|
|
149
|
+
_create_decorators = [Remote];
|
|
150
|
+
_update_decorators = [Remote];
|
|
151
|
+
_setStatus_decorators = [Remote];
|
|
152
|
+
_delete_decorators = [Remote];
|
|
153
|
+
_markRead_decorators = [Remote];
|
|
154
|
+
__esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
155
|
+
__esDecorate(this, null, _create_decorators, { kind: "method", name: "create", static: false, private: false, access: { has: obj => "create" in obj, get: obj => obj.create }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
156
|
+
__esDecorate(this, null, _update_decorators, { kind: "method", name: "update", static: false, private: false, access: { has: obj => "update" in obj, get: obj => obj.update }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
157
|
+
__esDecorate(this, null, _setStatus_decorators, { kind: "method", name: "setStatus", static: false, private: false, access: { has: obj => "setStatus" in obj, get: obj => obj.setStatus }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
158
|
+
__esDecorate(this, null, _delete_decorators, { kind: "method", name: "delete", static: false, private: false, access: { has: obj => "delete" in obj, get: obj => obj.delete }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
159
|
+
__esDecorate(this, null, _markRead_decorators, { kind: "method", name: "markRead", static: false, private: false, access: { has: obj => "markRead" in obj, get: obj => obj.markRead }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
160
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
161
|
+
}
|
|
162
|
+
/** Services required before tasks can be listed, mutated, or fired. */
|
|
163
|
+
static inject = ['storageDomain', 'agents', 'sessions', 'sessionTitle', 'workspaceRegistry'];
|
|
164
|
+
table = __runInitializers(this, _instanceExtraInitializers);
|
|
165
|
+
timer;
|
|
166
|
+
handles = new Map();
|
|
167
|
+
/** Last task title pinned onto each run session (avoids re-rename spam). */
|
|
168
|
+
appliedTitles = new Map();
|
|
169
|
+
/** Run sessions already attached to their bound workspace (avoids re-attach churn). */
|
|
170
|
+
attachedSessions = new Set();
|
|
171
|
+
/** Consecutive admission failures per task, backing off the next retry. */
|
|
172
|
+
failures = new Map();
|
|
173
|
+
stopping = false;
|
|
174
|
+
constructor(ctx) {
|
|
175
|
+
super(ctx, 'scheduledTasks');
|
|
176
|
+
}
|
|
177
|
+
/** Open the domain, arm the first timer, and register teardown. */
|
|
178
|
+
async [Service.init]() {
|
|
179
|
+
const domain = await this.ctx.storageDomain.open(scheduledTaskDomainSpec);
|
|
180
|
+
this.ctx.effect(() => () => domain.close(), 'scheduled-task.domainClose');
|
|
181
|
+
this.table = domain.table('tasks');
|
|
182
|
+
this.ctx.effect(() => () => { void this.teardown(); }, 'scheduled-task.teardown');
|
|
183
|
+
this.rearm();
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* List every task, earliest target first, with wall-clock-derived view fields.
|
|
187
|
+
* @returns The complete client-facing task list.
|
|
188
|
+
*/
|
|
189
|
+
list() {
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
return [...this.requireTable().entries()]
|
|
192
|
+
.map(([, record]) => scheduledTaskView(record, now))
|
|
193
|
+
.sort((left, right) => Date.parse(left.rule.scheduledAt) - Date.parse(right.rule.scheduledAt)
|
|
194
|
+
|| String(left.id).localeCompare(String(right.id)));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Create one task from a non-empty title and exactly one schedule selector.
|
|
198
|
+
* @param input - Task instruction and schedule selector.
|
|
199
|
+
* @returns The created task view or a stable error.
|
|
200
|
+
*/
|
|
201
|
+
async create(input) {
|
|
202
|
+
try {
|
|
203
|
+
const title = validateTitle(input.title);
|
|
204
|
+
const prompt = validatePrompt(input.prompt);
|
|
205
|
+
const rule = buildRule(input, Date.now());
|
|
206
|
+
if (rule === undefined) {
|
|
207
|
+
return { ok: false, code: 'invalid_selector', message: 'scheduled tasks accept exactly one of after_seconds, at, or every_seconds.' };
|
|
208
|
+
}
|
|
209
|
+
const id = ScheduledTaskId(randomUUID());
|
|
210
|
+
const record = applyCarry({
|
|
211
|
+
id,
|
|
212
|
+
title,
|
|
213
|
+
prompt,
|
|
214
|
+
rule,
|
|
215
|
+
status: 'active',
|
|
216
|
+
sessionId: SessionId(`task-${id}`),
|
|
217
|
+
createdAt: new Date().toISOString(),
|
|
218
|
+
confirmBeforeChange: false,
|
|
219
|
+
}, input);
|
|
220
|
+
await this.requireTable().put(id, record);
|
|
221
|
+
this.rearm();
|
|
222
|
+
return { ok: true, task: scheduledTaskView(record, Date.now()) };
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return this.asMutationError(error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Edit a task's title and/or schedule; absent fields keep their stored value.
|
|
230
|
+
* @param id - Task to edit.
|
|
231
|
+
* @param input - Replacement title and/or schedule selector.
|
|
232
|
+
* @returns The updated task view or a stable error.
|
|
233
|
+
*/
|
|
234
|
+
async update(id, input) {
|
|
235
|
+
try {
|
|
236
|
+
const record = this.requireTable().get(id);
|
|
237
|
+
if (record === undefined)
|
|
238
|
+
return this.notFound(id);
|
|
239
|
+
const title = input.title === undefined ? record.title : validateTitle(input.title);
|
|
240
|
+
const rule = buildRule(input, Date.now()) ?? record.rule;
|
|
241
|
+
const next = applyCarry({ ...record, title, rule }, input);
|
|
242
|
+
await this.requireTable().put(id, next);
|
|
243
|
+
this.rearm();
|
|
244
|
+
return { ok: true, task: scheduledTaskView(next, Date.now()) };
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
return this.asMutationError(error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Pause or resume an active/paused task; completed tasks reject.
|
|
252
|
+
* @param id - Task to change.
|
|
253
|
+
* @param status - Target lifecycle status (`active` or `paused`).
|
|
254
|
+
* @returns The updated task view or a stable error.
|
|
255
|
+
*/
|
|
256
|
+
async setStatus(id, status) {
|
|
257
|
+
try {
|
|
258
|
+
const record = this.requireTable().get(id);
|
|
259
|
+
if (record === undefined)
|
|
260
|
+
return this.notFound(id);
|
|
261
|
+
if (record.status === 'completed') {
|
|
262
|
+
return { ok: false, code: 'invalid_rule', message: 'a completed task cannot change status.' };
|
|
263
|
+
}
|
|
264
|
+
const next = { ...record, status };
|
|
265
|
+
await this.requireTable().put(id, next);
|
|
266
|
+
this.rearm();
|
|
267
|
+
return { ok: true, task: scheduledTaskView(next, Date.now()) };
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
return this.asMutationError(error);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Delete one task and dispose its live run Agent, if any.
|
|
275
|
+
* @param id - Task to delete.
|
|
276
|
+
* @returns Whether a task was deleted, or a stable error.
|
|
277
|
+
*/
|
|
278
|
+
async delete(id) {
|
|
279
|
+
try {
|
|
280
|
+
const deleted = await this.requireTable().delete(id);
|
|
281
|
+
if (deleted) {
|
|
282
|
+
const handle = this.handles.get(id);
|
|
283
|
+
if (handle !== undefined) {
|
|
284
|
+
this.handles.delete(id);
|
|
285
|
+
await handle.dispose();
|
|
286
|
+
}
|
|
287
|
+
this.failures.delete(id);
|
|
288
|
+
this.rearm();
|
|
289
|
+
}
|
|
290
|
+
return { ok: true, deleted };
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return { ok: false, code: 'internal_error', message: 'The scheduled task delete failed.' };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Mark one task read; unknown ids are an idempotent no-op.
|
|
298
|
+
* @param id - Task to mark read.
|
|
299
|
+
* @returns `null` after the durable read mark, when one was written.
|
|
300
|
+
*/
|
|
301
|
+
async markRead(id) {
|
|
302
|
+
const record = this.requireTable().get(id);
|
|
303
|
+
if (record !== undefined && record.lastRunAt !== undefined) {
|
|
304
|
+
await this.requireTable().put(id, { ...record, lastReadAt: new Date().toISOString() });
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
notFound(id) {
|
|
309
|
+
return { ok: false, code: 'task_not_found', message: `no scheduled task '${id}'.` };
|
|
310
|
+
}
|
|
311
|
+
asMutationError(error) {
|
|
312
|
+
if (error instanceof ScheduledTaskError)
|
|
313
|
+
return { ok: false, code: error.code, message: error.message };
|
|
314
|
+
return { ok: false, code: 'internal_error', message: 'The scheduled task operation failed.' };
|
|
315
|
+
}
|
|
316
|
+
requireTable() {
|
|
317
|
+
if (this.table === undefined)
|
|
318
|
+
throw new Error('scheduled task service is not started yet');
|
|
319
|
+
return this.table;
|
|
320
|
+
}
|
|
321
|
+
/** Cancel and re-derive the single timer from the earliest due active task. */
|
|
322
|
+
rearm() {
|
|
323
|
+
if (this.timer !== undefined) {
|
|
324
|
+
clearTimeout(this.timer);
|
|
325
|
+
this.timer = undefined;
|
|
326
|
+
}
|
|
327
|
+
if (this.stopping)
|
|
328
|
+
return;
|
|
329
|
+
const table = this.table;
|
|
330
|
+
if (table === undefined)
|
|
331
|
+
return;
|
|
332
|
+
const delay = nextDelayMs(table.entries(), Date.now());
|
|
333
|
+
if (delay === undefined)
|
|
334
|
+
return;
|
|
335
|
+
this.timer = setTimeout(() => {
|
|
336
|
+
this.timer = undefined;
|
|
337
|
+
void this.fireDue(Date.now());
|
|
338
|
+
}, delay);
|
|
339
|
+
}
|
|
340
|
+
/** Fire every currently due active task, then re-arm. */
|
|
341
|
+
async fireDue(now) {
|
|
342
|
+
if (this.stopping)
|
|
343
|
+
return;
|
|
344
|
+
const due = [...this.requireTable().entries()].filter(([id, record]) => {
|
|
345
|
+
if (!isDue(record, now))
|
|
346
|
+
return false;
|
|
347
|
+
// Tasks in failure backoff wait for their retry window even while the
|
|
348
|
+
// stored schedule instant is still in the past.
|
|
349
|
+
return (this.failures.get(id)?.retryAfter ?? 0) <= now;
|
|
350
|
+
});
|
|
351
|
+
// Fire independently: one hanging admission must not delay the others.
|
|
352
|
+
await Promise.allSettled(due.map(([id, record]) => this.fireOne(id, record, now)));
|
|
353
|
+
this.rearm();
|
|
354
|
+
}
|
|
355
|
+
/** Queue one task run, advance its durable record, and flush its Session. */
|
|
356
|
+
async fireOne(id, record, now) {
|
|
357
|
+
try {
|
|
358
|
+
const { agent, sessionId } = await this.ensureAgent(record);
|
|
359
|
+
// Pin the conversation title to the task title (re-pins only on change,
|
|
360
|
+
// and retroactively fixes sessions created before this pinning existed).
|
|
361
|
+
this.pinSessionTitle(sessionId, record, agent);
|
|
362
|
+
// List the conversation under the bound workspace project (membership is
|
|
363
|
+
// an explicit per-workspace session list, not derived from cwd).
|
|
364
|
+
await this.attachRunSession(record, sessionId);
|
|
365
|
+
// Apply the stored confirm-before-change policy before the run's first step.
|
|
366
|
+
setApprovalPolicy(agent.session, record.confirmBeforeChange ? 'ask' : 'never');
|
|
367
|
+
agent.followup(createUserMessage({
|
|
368
|
+
content: [{ type: 'text', text: renderTaskFraming(record) }],
|
|
369
|
+
source: { kind: 'plugin', plugin: 'scheduled-task' },
|
|
370
|
+
}));
|
|
371
|
+
const runAt = new Date(now).toISOString();
|
|
372
|
+
const advanced = advanceRule(record.rule, now);
|
|
373
|
+
const next = {
|
|
374
|
+
...record,
|
|
375
|
+
sessionId,
|
|
376
|
+
rule: advanced ?? record.rule,
|
|
377
|
+
status: advanced === undefined ? 'completed' : record.status,
|
|
378
|
+
lastRunAt: runAt,
|
|
379
|
+
lastError: undefined,
|
|
380
|
+
};
|
|
381
|
+
this.failures.delete(id);
|
|
382
|
+
await this.requireTable().put(id, next);
|
|
383
|
+
await this.ctx.sessions.flush(agent.session);
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
const message = renderThrown(error);
|
|
387
|
+
this.ctx.logger.warn(`scheduled-task: could not fire task "${id}": ${message}`);
|
|
388
|
+
// Record the failure on the task and back off the retry (30s doubling to
|
|
389
|
+
// 5min) so a persistently failing admission does not hot-loop while its
|
|
390
|
+
// schedule instant stays in the past. In-memory only; a restart retries
|
|
391
|
+
// immediately.
|
|
392
|
+
const previous = this.failures.get(id)?.count ?? 0;
|
|
393
|
+
const count = previous + 1;
|
|
394
|
+
const retryAfter = now + admissionBackoffMs(count);
|
|
395
|
+
this.failures.set(id, { count, retryAfter });
|
|
396
|
+
try {
|
|
397
|
+
await this.requireTable().put(id, {
|
|
398
|
+
...record,
|
|
399
|
+
lastError: { at: new Date(now).toISOString(), message },
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
catch (persistError) {
|
|
403
|
+
this.ctx.logger.warn(`scheduled-task: could not persist failure of task "${id}": ${renderThrown(persistError)}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Attach the run session to the task's bound workspace so the conversation
|
|
409
|
+
* lists under that project instead of "ungrouped" (workspace membership is
|
|
410
|
+
* an explicit durable session list; the entity validates the session header's
|
|
411
|
+
* canonical cwd against the workspace path and attach is idempotent).
|
|
412
|
+
*/
|
|
413
|
+
async attachRunSession(record, sessionId) {
|
|
414
|
+
if (record.workspaceId === undefined)
|
|
415
|
+
return;
|
|
416
|
+
const key = `${record.workspaceId}:${sessionId}`;
|
|
417
|
+
if (this.attachedSessions.has(key))
|
|
418
|
+
return;
|
|
419
|
+
const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(record.workspaceId));
|
|
420
|
+
if (workspace === undefined) {
|
|
421
|
+
this.ctx.logger.warn(`scheduled-task: workspace "${record.workspaceId}" not found for task "${record.id}"`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
await workspace.attachSession(sessionId);
|
|
426
|
+
this.attachedSessions.add(key);
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
this.ctx.logger.warn(`scheduled-task: could not attach session "${sessionId}" to workspace: ${renderThrown(error)}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Pin the session's conversation title to the task title via the
|
|
434
|
+
* session-title service (a `user`-source rename pins against automatic
|
|
435
|
+
* regeneration). Keyed per SESSION so a migrated run session is titled too;
|
|
436
|
+
* re-pins only when the stored title changed since the last applied pin.
|
|
437
|
+
*/
|
|
438
|
+
pinSessionTitle(sessionId, record, agent) {
|
|
439
|
+
if (this.appliedTitles.get(sessionId) === record.title)
|
|
440
|
+
return;
|
|
441
|
+
const titles = this.ctx.sessionTitle;
|
|
442
|
+
try {
|
|
443
|
+
titles.rename(agent.session, record.title);
|
|
444
|
+
this.appliedTitles.set(sessionId, record.title);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
this.ctx.logger.warn(`scheduled-task: could not title session "${sessionId}": ${renderThrown(error)}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Resolve the live run Agent for a task's CURRENT project. The durable
|
|
452
|
+
* session identity is derived from the task id + cwd (`runSessionIdOf`), so
|
|
453
|
+
* an edited project transparently starts a fresh conversation inside the new
|
|
454
|
+
* workspace while returning to an earlier project resumes that project's
|
|
455
|
+
* existing history. Returns the possibly-new sessionId for persistence.
|
|
456
|
+
*/
|
|
457
|
+
async ensureAgent(record) {
|
|
458
|
+
const desiredCwd = record.cwd ?? process.cwd();
|
|
459
|
+
const targetId = runSessionIdOf(record.id, desiredCwd);
|
|
460
|
+
const agentOptions = record.model === undefined
|
|
461
|
+
? undefined
|
|
462
|
+
: { provider: record.model.provider, model: record.model.model };
|
|
463
|
+
// A retained run handle is reused only when it still matches the target.
|
|
464
|
+
const retained = this.handles.get(record.id);
|
|
465
|
+
if (retained !== undefined) {
|
|
466
|
+
if (retained.agent.session.id === targetId && retained.agent.session.header.cwd === desiredCwd) {
|
|
467
|
+
return { agent: retained.agent, sessionId: targetId };
|
|
468
|
+
}
|
|
469
|
+
this.handles.delete(record.id);
|
|
470
|
+
await retained.dispose();
|
|
471
|
+
}
|
|
472
|
+
let handle;
|
|
473
|
+
try {
|
|
474
|
+
handle = await this.ctx.agents.resume({
|
|
475
|
+
resumeSessionId: targetId,
|
|
476
|
+
...agentOptions === undefined ? {} : { agentOptions },
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
catch (resumeError) {
|
|
480
|
+
if (!isMissingSessionError(resumeError))
|
|
481
|
+
throw resumeError;
|
|
482
|
+
// Not persisted yet (first run for this task+project pair): create it in-project.
|
|
483
|
+
handle = await this.ctx.agents.create({
|
|
484
|
+
sessionId: targetId,
|
|
485
|
+
meta: { cwd: desiredCwd },
|
|
486
|
+
...agentOptions === undefined ? {} : { agentOptions },
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
this.handles.set(record.id, handle);
|
|
490
|
+
return { agent: handle.agent, sessionId: targetId };
|
|
491
|
+
}
|
|
492
|
+
/** Cancel the timer and dispose every retained run Agent. */
|
|
493
|
+
async teardown() {
|
|
494
|
+
this.stopping = true;
|
|
495
|
+
if (this.timer !== undefined) {
|
|
496
|
+
clearTimeout(this.timer);
|
|
497
|
+
this.timer = undefined;
|
|
498
|
+
}
|
|
499
|
+
const handles = [...this.handles.values()];
|
|
500
|
+
this.handles.clear();
|
|
501
|
+
await Promise.allSettled(handles.map(handle => handle.dispose()));
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
})();
|
|
505
|
+
export { ScheduledTaskService };
|
|
506
|
+
export default ScheduledTaskService;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for the external scheduled-task plugin.
|
|
3
|
+
* @module @qihongmu/dsh-plugins-scheduled-task/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis invariant-companion plugin name. */
|
|
7
|
+
export declare const name = "scheduled-task-invariant";
|
|
8
|
+
/** Service required before reserving this package's invariant ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register the package-owned invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant registry.
|
|
13
|
+
* @returns Exact registration disposer after child setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for the external scheduled-task plugin.
|
|
3
|
+
* @module @qihongmu/dsh-plugins-scheduled-task/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@qihongmu/dsh-plugins-scheduled-task';
|
|
6
|
+
/** Cordis invariant-companion plugin name. */
|
|
7
|
+
export const name = 'scheduled-task-invariant';
|
|
8
|
+
/** Service required before reserving this package's invariant ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* Owned relationship: a task enters `completed` only after its run recorded
|
|
12
|
+
* `lastRunAt`. The service sets both in one durable put, so a completed record
|
|
13
|
+
* without a run timestamp proves a write path bypassed the scheduler.
|
|
14
|
+
*/
|
|
15
|
+
const install = Object.assign((ctx, fail) => {
|
|
16
|
+
ctx.on('domain/changed', (change) => {
|
|
17
|
+
if (change.domain !== 'scheduled_task' || change.table !== 'tasks' || change.operation !== 'put')
|
|
18
|
+
return;
|
|
19
|
+
const record = change.value;
|
|
20
|
+
if (record.status === 'completed' && record.lastRunAt === undefined) {
|
|
21
|
+
fail(`scheduled task '${String(record.id)}' entered completed without lastRunAt`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}, { inject: [] });
|
|
25
|
+
/**
|
|
26
|
+
* Register the package-owned invariant companion.
|
|
27
|
+
* @param ctx - Cordis context carrying the invariant registry.
|
|
28
|
+
* @returns Exact registration disposer after child setup succeeds.
|
|
29
|
+
*/
|
|
30
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|