dsh-offpeak-guard 0.2.0 → 0.3.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/CHANGELOG.md +11 -0
- package/README.en.md +40 -7
- package/README.md +43 -8
- package/client.js +221 -4
- package/cordis.patch.yml +4 -0
- package/docs/settings-panel.png +0 -0
- package/index.js +72 -0
- package/lib/guard.js +414 -28
- package/lib/store.js +40 -3
- package/lib/tools.js +59 -13
- package/package.json +1 -1
package/lib/guard.js
CHANGED
|
@@ -11,26 +11,32 @@
|
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
14
15
|
import {
|
|
15
16
|
describeWindow,
|
|
16
17
|
formatClock,
|
|
17
18
|
formatDateClock,
|
|
18
19
|
formatDuration,
|
|
19
20
|
resolveRules,
|
|
21
|
+
zonedParts,
|
|
20
22
|
} from './peak.js';
|
|
21
23
|
import { StateFile, emptyState, writeStatusFiles } from './store.js';
|
|
22
24
|
|
|
23
25
|
const DEFAULTS = {
|
|
24
26
|
enabled: true,
|
|
27
|
+
pauseMode: 'pause-now',
|
|
25
28
|
pauseRunningOnPeak: true,
|
|
26
29
|
deferNewTurnsOnPeak: true,
|
|
27
30
|
resumeOnOffPeak: true,
|
|
28
31
|
modelPattern: 'deepseek',
|
|
32
|
+
peakPricedModels: [],
|
|
29
33
|
exemptModels: [],
|
|
30
34
|
pollSeconds: 15,
|
|
31
35
|
resumeDelaySeconds: 3,
|
|
32
36
|
bypassMinutes: 10,
|
|
33
37
|
alertMinutes: 10,
|
|
38
|
+
holidaySource: 'auto',
|
|
39
|
+
holidayApi: 'https://timor.tech/api/holiday/year',
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
function clampNumber(value, fallback, min, max) {
|
|
@@ -51,6 +57,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
51
57
|
const rules = resolveRules(config);
|
|
52
58
|
const options = {
|
|
53
59
|
enabled: config.enabled !== false,
|
|
60
|
+
pauseMode: config.pauseMode === 'finish-turn' ? 'finish-turn' : 'pause-now',
|
|
54
61
|
pauseRunningOnPeak: config.pauseRunningOnPeak !== false && DEFAULTS.pauseRunningOnPeak,
|
|
55
62
|
deferNewTurnsOnPeak: config.deferNewTurnsOnPeak !== false && DEFAULTS.deferNewTurnsOnPeak,
|
|
56
63
|
resumeOnOffPeak: config.resumeOnOffPeak !== false && DEFAULTS.resumeOnOffPeak,
|
|
@@ -58,16 +65,24 @@ export function createGuard(ctx, config = {}) {
|
|
|
58
65
|
typeof config.modelPattern === 'string' && config.modelPattern.trim() !== ''
|
|
59
66
|
? config.modelPattern.trim().toLowerCase()
|
|
60
67
|
: DEFAULTS.modelPattern,
|
|
68
|
+
peakPricedModels: Array.isArray(config.peakPricedModels) ? config.peakPricedModels.map((item) => String(item)) : [],
|
|
61
69
|
exemptModels: Array.isArray(config.exemptModels) ? config.exemptModels.map((item) => String(item)) : [],
|
|
62
70
|
pollMs: clampNumber(config.pollSeconds, DEFAULTS.pollSeconds, 5, 300) * 1000,
|
|
63
71
|
resumeDelayMs: clampNumber(config.resumeDelaySeconds, DEFAULTS.resumeDelaySeconds, 0, 120) * 1000,
|
|
64
72
|
bypassMs: clampNumber(config.bypassMinutes, DEFAULTS.bypassMinutes, 0, 120) * 60 * 1000,
|
|
65
73
|
alertMinutes: clampNumber(config.alertMinutes, DEFAULTS.alertMinutes, 0, 120),
|
|
74
|
+
holidaySource: config.holidaySource === 'builtin' ? 'builtin' : 'auto',
|
|
75
|
+
holidayApi:
|
|
76
|
+
typeof config.holidayApi === 'string' && config.holidayApi.trim() !== ''
|
|
77
|
+
? config.holidayApi.trim().replace(/\/+$/, '')
|
|
78
|
+
: DEFAULTS.holidayApi,
|
|
66
79
|
};
|
|
67
80
|
|
|
68
81
|
const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh');
|
|
69
82
|
const dataDir = path.join(home, 'offpeak-guard');
|
|
83
|
+
let pauseMode = options.pauseMode;
|
|
70
84
|
const stateFile = new StateFile(path.join(dataDir, 'state.json'));
|
|
85
|
+
const rawStateRead = () => stateFile.read();
|
|
71
86
|
const statusFiles = [path.join(dataDir, 'status.json')];
|
|
72
87
|
const mirror = path.join(os.homedir(), '.dsh', 'offpeak-guard', 'status.json');
|
|
73
88
|
if (path.resolve(mirror) !== path.resolve(statusFiles[0])) statusFiles.push(mirror);
|
|
@@ -75,7 +90,15 @@ export function createGuard(ctx, config = {}) {
|
|
|
75
90
|
const bypass = new Map();
|
|
76
91
|
const deferring = new Set();
|
|
77
92
|
const rulesCache = new Map();
|
|
93
|
+
const childParent = new Map();
|
|
94
|
+
const waitingBusy = new Set();
|
|
78
95
|
const busy = { tick: false, pause: false, resume: false, plans: false };
|
|
96
|
+
let exemptSessions = {};
|
|
97
|
+
let modelRules = {};
|
|
98
|
+
let dynamicHolidays = [];
|
|
99
|
+
let holidayFetchedAt = 0;
|
|
100
|
+
let holidayRefreshing = false;
|
|
101
|
+
const holidayCachePath = path.join(dataDir, 'holidays.json');
|
|
79
102
|
let timer = null;
|
|
80
103
|
let lastPhase = null;
|
|
81
104
|
let resumeAt = 0;
|
|
@@ -99,6 +122,20 @@ export function createGuard(ctx, config = {}) {
|
|
|
99
122
|
}
|
|
100
123
|
};
|
|
101
124
|
|
|
125
|
+
async function readState() {
|
|
126
|
+
const state = await rawStateRead();
|
|
127
|
+
exemptSessions = state.exemptSessions ?? {};
|
|
128
|
+
modelRules = state.modelRules ?? {};
|
|
129
|
+
if (state.settings?.pauseMode === 'finish-turn' || state.settings?.pauseMode === 'pause-now') {
|
|
130
|
+
pauseMode = state.settings.pauseMode;
|
|
131
|
+
}
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isExempt(sessionId) {
|
|
136
|
+
return Object.prototype.hasOwnProperty.call(exemptSessions, sessionId);
|
|
137
|
+
}
|
|
138
|
+
|
|
102
139
|
function sessionOf(agent) {
|
|
103
140
|
if (agent?.session !== undefined) return agent.session;
|
|
104
141
|
const id = agent?.id;
|
|
@@ -129,11 +166,47 @@ export function createGuard(ctx, config = {}) {
|
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
function isPeakPriced(provider, model) {
|
|
132
|
-
if (typeof model !== 'string' || model === ''
|
|
169
|
+
if (typeof model !== 'string' || model === '') return false;
|
|
170
|
+
if (options.exemptModels.includes(model)) return false;
|
|
171
|
+
const exactKey = `${provider ?? ''}/${model}`;
|
|
172
|
+
if (Object.prototype.hasOwnProperty.call(modelRules, exactKey)) return modelRules[exactKey] === true;
|
|
173
|
+
if (Object.prototype.hasOwnProperty.call(modelRules, model)) return modelRules[model] === true;
|
|
174
|
+
if (options.peakPricedModels.length > 0) {
|
|
175
|
+
return options.peakPricedModels.includes(exactKey) || options.peakPricedModels.includes(model);
|
|
176
|
+
}
|
|
133
177
|
const needle = options.modelPattern;
|
|
134
178
|
return model.toLowerCase().includes(needle) || String(provider ?? '').toLowerCase().includes(needle);
|
|
135
179
|
}
|
|
136
180
|
|
|
181
|
+
function currentTurn(agent) {
|
|
182
|
+
try {
|
|
183
|
+
const session = sessionOf(agent);
|
|
184
|
+
if (session === undefined || session === null) return 0;
|
|
185
|
+
const boundary = service('agents')?.turnBoundaryOf?.(session);
|
|
186
|
+
return Number(boundary?.lastTurn) || 0;
|
|
187
|
+
} catch {
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function hasRunningChildren(sessionId) {
|
|
193
|
+
try {
|
|
194
|
+
const subagents = service('subagents');
|
|
195
|
+
if (subagents === undefined || typeof subagents.listChildren !== 'function') return false;
|
|
196
|
+
const children = await subagents.listChildren(sessionId);
|
|
197
|
+
for (const child of Array.isArray(children) ? children : []) {
|
|
198
|
+
if (child?.activity === 'running') return true;
|
|
199
|
+
if (child?.id !== undefined) {
|
|
200
|
+
const live = service('agents')?.get?.(child.id);
|
|
201
|
+
if (live?.status === 'running') return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
137
210
|
function titleOf(session) {
|
|
138
211
|
try {
|
|
139
212
|
const snapshot = service('sessionTitle')?.get?.(session);
|
|
@@ -164,10 +237,20 @@ export function createGuard(ctx, config = {}) {
|
|
|
164
237
|
|
|
165
238
|
function rulesFor(provider) {
|
|
166
239
|
const key = String(provider ?? '');
|
|
167
|
-
if (!rulesCache.has(key))
|
|
240
|
+
if (!rulesCache.has(key)) {
|
|
241
|
+
const merged = {
|
|
242
|
+
...config,
|
|
243
|
+
holidays: [...(Array.isArray(config.holidays) ? config.holidays : []), ...dynamicHolidays],
|
|
244
|
+
};
|
|
245
|
+
rulesCache.set(key, resolveRules(merged, provider));
|
|
246
|
+
}
|
|
168
247
|
return rulesCache.get(key);
|
|
169
248
|
}
|
|
170
249
|
|
|
250
|
+
function clearRulesCache() {
|
|
251
|
+
rulesCache.clear();
|
|
252
|
+
}
|
|
253
|
+
|
|
171
254
|
function currentProvider() {
|
|
172
255
|
const active = liveAgents().find((agent) => agent.status === 'running');
|
|
173
256
|
if (active !== undefined) return modelOf(active).provider;
|
|
@@ -219,15 +302,21 @@ export function createGuard(ctx, config = {}) {
|
|
|
219
302
|
sessions[agent.id] = {
|
|
220
303
|
id: route.model,
|
|
221
304
|
provider: route.provider,
|
|
305
|
+
title: titleOf(session),
|
|
222
306
|
peakPriced: isPeakPriced(route.provider, route.model),
|
|
307
|
+
exempt: isExempt(agent.id),
|
|
223
308
|
};
|
|
224
309
|
}
|
|
225
310
|
const activeRules = rulesFor(currentProvider());
|
|
311
|
+
const waitingCount = state.interrupted.filter(
|
|
312
|
+
(item) => item.status === 'waiting-turn' || item.status === 'waiting-children',
|
|
313
|
+
).length;
|
|
226
314
|
return {
|
|
227
315
|
version: 1,
|
|
228
316
|
generatedAt: Date.now(),
|
|
229
317
|
preset: desc.preset,
|
|
230
318
|
presetLabel: desc.presetLabel,
|
|
319
|
+
pauseMode,
|
|
231
320
|
timezone: desc.timezone,
|
|
232
321
|
peakWeekdays: activeRules.peakWeekdays,
|
|
233
322
|
peakWindows: activeRules.peakWindows.map((item) => ({ start: item.start, end: item.end })),
|
|
@@ -235,11 +324,17 @@ export function createGuard(ctx, config = {}) {
|
|
|
235
324
|
phase: desc.phase,
|
|
236
325
|
phaseLabel: desc.phaseLabel,
|
|
237
326
|
holiday: desc.holiday === true,
|
|
327
|
+
holidaySource: options.holidaySource,
|
|
328
|
+
holidaySyncedAt: holidayFetchedAt,
|
|
238
329
|
localTime: desc.localTime,
|
|
239
330
|
weekday: desc.weekday,
|
|
240
331
|
window: desc.window,
|
|
241
332
|
next: desc.next,
|
|
242
333
|
alertMinutes: options.alertMinutes,
|
|
334
|
+
modelPattern: options.modelPattern,
|
|
335
|
+
peakPricedModels: options.peakPricedModels,
|
|
336
|
+
exemptModels: options.exemptModels,
|
|
337
|
+
modelRules,
|
|
243
338
|
model: {
|
|
244
339
|
id: selected.model,
|
|
245
340
|
label: label ?? selected.model,
|
|
@@ -247,6 +342,11 @@ export function createGuard(ctx, config = {}) {
|
|
|
247
342
|
peakPriced: isPeakPriced(selected.provider, selected.model),
|
|
248
343
|
},
|
|
249
344
|
sessions,
|
|
345
|
+
exemptSessions: Object.entries(exemptSessions).map(([sessionId, record]) => ({
|
|
346
|
+
sessionId,
|
|
347
|
+
title: record?.title ?? null,
|
|
348
|
+
at: record?.at ?? 0,
|
|
349
|
+
})),
|
|
250
350
|
guard: {
|
|
251
351
|
enabled: options.enabled,
|
|
252
352
|
pauseRunningOnPeak: options.pauseRunningOnPeak,
|
|
@@ -269,6 +369,8 @@ export function createGuard(ctx, config = {}) {
|
|
|
269
369
|
})),
|
|
270
370
|
counts: {
|
|
271
371
|
interrupted: paused.length,
|
|
372
|
+
waiting: waitingCount,
|
|
373
|
+
exempt: Object.keys(exemptSessions).length,
|
|
272
374
|
plans: plans.length,
|
|
273
375
|
resumed: state.interrupted.filter((item) => item.status === 'resumed').length,
|
|
274
376
|
},
|
|
@@ -327,32 +429,41 @@ export function createGuard(ctx, config = {}) {
|
|
|
327
429
|
}
|
|
328
430
|
|
|
329
431
|
/**
|
|
330
|
-
*
|
|
432
|
+
* 峰时暂停正在跑的、使用峰谷计价模型的根会话。
|
|
433
|
+
* pause-now:立刻取消;finish-turn:当前轮跑完后再停。
|
|
434
|
+
* 有子代理在跑时先放行,子代理结束后再按规则处理父会话。
|
|
331
435
|
* @param {'window-start'|'manual'} cause
|
|
332
436
|
*/
|
|
333
437
|
async function pauseRunning(cause) {
|
|
334
438
|
if (busy.pause) return [];
|
|
335
439
|
busy.pause = true;
|
|
336
440
|
try {
|
|
337
|
-
const state = await
|
|
441
|
+
const state = await readState();
|
|
338
442
|
const now = Date.now();
|
|
339
|
-
const
|
|
443
|
+
const handled = [];
|
|
340
444
|
let changed = false;
|
|
341
445
|
for (const agent of liveAgents()) {
|
|
342
446
|
if (agent.status !== 'running') continue;
|
|
343
447
|
const session = guardableSession(agent);
|
|
344
448
|
if (session === null) continue;
|
|
449
|
+
if (isExempt(agent.id)) continue;
|
|
345
450
|
const { provider, model } = modelOf(agent);
|
|
346
451
|
if (!isPeakPriced(provider, model)) continue;
|
|
347
452
|
if ((bypass.get(agent.id) ?? 0) > now) continue;
|
|
348
453
|
const desc = describeWindow(now, rulesFor(provider));
|
|
349
454
|
if (desc.phase !== 'peak') continue;
|
|
350
|
-
const existing = state.interrupted.find(
|
|
455
|
+
const existing = state.interrupted.find(
|
|
456
|
+
(item) =>
|
|
457
|
+
item.sessionId === agent.id &&
|
|
458
|
+
(item.status === 'paused' || item.status === 'waiting-turn' || item.status === 'waiting-children'),
|
|
459
|
+
);
|
|
351
460
|
if (existing !== undefined) {
|
|
352
461
|
existing.at = now;
|
|
353
462
|
changed = true;
|
|
354
463
|
continue;
|
|
355
464
|
}
|
|
465
|
+
const children = await hasRunningChildren(agent.id);
|
|
466
|
+
const allowedTurn = currentTurn(agent);
|
|
356
467
|
const item = {
|
|
357
468
|
id: shortId('pause'),
|
|
358
469
|
sessionId: agent.id,
|
|
@@ -362,24 +473,120 @@ export function createGuard(ctx, config = {}) {
|
|
|
362
473
|
at: now,
|
|
363
474
|
windowStart: desc.window?.startAt ?? null,
|
|
364
475
|
windowEnd: desc.window?.endAt ?? null,
|
|
365
|
-
status: 'paused',
|
|
366
|
-
cause,
|
|
476
|
+
status: children ? 'waiting-children' : pauseMode === 'finish-turn' ? 'waiting-turn' : 'paused',
|
|
477
|
+
cause: children ? 'children' : pauseMode === 'finish-turn' ? 'finish-turn' : cause,
|
|
478
|
+
allowedTurn,
|
|
367
479
|
};
|
|
368
480
|
state.interrupted.unshift(item);
|
|
369
|
-
|
|
481
|
+
handled.push(item);
|
|
482
|
+
if (item.status === 'paused') {
|
|
483
|
+
try {
|
|
484
|
+
await cancelSession(agent.id);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
item.status = 'failed';
|
|
487
|
+
item.error = messageOf(error);
|
|
488
|
+
log('warn', `暂停会话 ${agent.id} 失败:${messageOf(error)}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (handled.length > 0 || changed) await stateFile.write(state);
|
|
493
|
+
return handled;
|
|
494
|
+
} finally {
|
|
495
|
+
busy.pause = false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** 等待中的会话:父会话已空闲/当前轮已结束则完成;有新轮或子代理结束则按规则取消。 */
|
|
500
|
+
async function checkWaitingFor(state, sessionId) {
|
|
501
|
+
const items = state.interrupted.filter(
|
|
502
|
+
(item) =>
|
|
503
|
+
item.sessionId === sessionId && (item.status === 'waiting-turn' || item.status === 'waiting-children'),
|
|
504
|
+
);
|
|
505
|
+
if (items.length === 0) return false;
|
|
506
|
+
const agent = service('agents')?.get?.(sessionId);
|
|
507
|
+
let changed = false;
|
|
508
|
+
const children = await hasRunningChildren(sessionId);
|
|
509
|
+
for (const item of items) {
|
|
510
|
+
if (item.status === 'waiting-children') {
|
|
511
|
+
if (children) continue;
|
|
512
|
+
if (agent?.status === 'running') {
|
|
513
|
+
try {
|
|
514
|
+
await cancelSession(sessionId);
|
|
515
|
+
item.status = 'paused';
|
|
516
|
+
item.at = Date.now();
|
|
517
|
+
item.cause = 'children-ended';
|
|
518
|
+
changed = true;
|
|
519
|
+
} catch (error) {
|
|
520
|
+
item.status = 'failed';
|
|
521
|
+
item.error = messageOf(error);
|
|
522
|
+
changed = true;
|
|
523
|
+
}
|
|
524
|
+
} else {
|
|
525
|
+
item.status = 'completed';
|
|
526
|
+
changed = true;
|
|
527
|
+
}
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const turn = currentTurn(agent);
|
|
531
|
+
if (agent?.status === 'running' && turn > (Number(item.allowedTurn) || 0)) {
|
|
370
532
|
try {
|
|
371
|
-
await cancelSession(
|
|
533
|
+
await cancelSession(sessionId);
|
|
534
|
+
item.status = 'paused';
|
|
535
|
+
item.at = Date.now();
|
|
536
|
+
item.cause = 'turn-boundary';
|
|
537
|
+
changed = true;
|
|
372
538
|
} catch (error) {
|
|
373
539
|
item.status = 'failed';
|
|
374
540
|
item.error = messageOf(error);
|
|
375
|
-
|
|
541
|
+
changed = true;
|
|
376
542
|
}
|
|
543
|
+
} else if (agent?.status !== 'running') {
|
|
544
|
+
item.status = 'completed';
|
|
545
|
+
changed = true;
|
|
377
546
|
}
|
|
378
|
-
|
|
379
|
-
|
|
547
|
+
}
|
|
548
|
+
return changed;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function checkWaiting(state) {
|
|
552
|
+
const ids = [...new Set(state.interrupted
|
|
553
|
+
.filter((item) => item.status === 'waiting-turn' || item.status === 'waiting-children')
|
|
554
|
+
.map((item) => item.sessionId))];
|
|
555
|
+
let changed = false;
|
|
556
|
+
for (const sessionId of ids) {
|
|
557
|
+
changed = (await checkWaitingFor(state, sessionId)) || changed;
|
|
558
|
+
}
|
|
559
|
+
return changed;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function checkWaitingForId(sessionId) {
|
|
563
|
+
if (waitingBusy.has(sessionId)) return;
|
|
564
|
+
waitingBusy.add(sessionId);
|
|
565
|
+
try {
|
|
566
|
+
const state = await readState();
|
|
567
|
+
const changed = await checkWaitingFor(state, sessionId);
|
|
568
|
+
if (changed) {
|
|
569
|
+
await stateFile.write(state);
|
|
570
|
+
await status();
|
|
571
|
+
}
|
|
572
|
+
} catch (error) {
|
|
573
|
+
log('warn', `检查等待会话 ${sessionId} 失败:${messageOf(error)}`);
|
|
380
574
|
} finally {
|
|
381
|
-
|
|
575
|
+
waitingBusy.delete(sessionId);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** 谷价开始时把仍在等待的项收尾:已完成当前轮的直接完成,不再无意义地恢复一轮。 */
|
|
580
|
+
async function settleWaiting(state) {
|
|
581
|
+
let changed = false;
|
|
582
|
+
for (const item of state.interrupted) {
|
|
583
|
+
if (item.status === 'waiting-turn' || item.status === 'waiting-children') {
|
|
584
|
+
item.status = 'completed';
|
|
585
|
+
item.completedAt = Date.now();
|
|
586
|
+
changed = true;
|
|
587
|
+
}
|
|
382
588
|
}
|
|
589
|
+
return changed;
|
|
383
590
|
}
|
|
384
591
|
|
|
385
592
|
/**
|
|
@@ -391,7 +598,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
391
598
|
if (busy.resume) return [];
|
|
392
599
|
busy.resume = true;
|
|
393
600
|
try {
|
|
394
|
-
const state = await
|
|
601
|
+
const state = await readState();
|
|
395
602
|
const items = state.interrupted.filter((item) => {
|
|
396
603
|
if (onlySessionIds === undefined) return item.status === 'paused';
|
|
397
604
|
return onlySessionIds.includes(item.sessionId) && (item.status === 'paused' || item.status === 'failed');
|
|
@@ -427,7 +634,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
427
634
|
if (busy.plans) return [];
|
|
428
635
|
busy.plans = true;
|
|
429
636
|
try {
|
|
430
|
-
const state = await
|
|
637
|
+
const state = await readState();
|
|
431
638
|
const due = state.plans.filter((item) => item.status === 'queued');
|
|
432
639
|
if (due.length === 0) return [];
|
|
433
640
|
const executed = [];
|
|
@@ -455,12 +662,76 @@ export function createGuard(ctx, config = {}) {
|
|
|
455
662
|
}
|
|
456
663
|
}
|
|
457
664
|
|
|
458
|
-
|
|
665
|
+
async function loadHolidayCache() {
|
|
666
|
+
try {
|
|
667
|
+
const text = await readFile(holidayCachePath, 'utf8');
|
|
668
|
+
const parsed = JSON.parse(text);
|
|
669
|
+
if (Array.isArray(parsed?.dates)) {
|
|
670
|
+
dynamicHolidays = parsed.dates.filter((item) => /^\d{4}-\d{2}-\d{2}$/.test(String(item)));
|
|
671
|
+
holidayFetchedAt = Number(parsed.fetchedAt) || 0;
|
|
672
|
+
clearRulesCache();
|
|
673
|
+
}
|
|
674
|
+
} catch {
|
|
675
|
+
/* 没有缓存时使用内置节假日 */
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function parseHolidayYear(year, body) {
|
|
680
|
+
const out = [];
|
|
681
|
+
const holidays = body?.holiday;
|
|
682
|
+
if (holidays === null || typeof holidays !== 'object') return out;
|
|
683
|
+
for (const [day, entry] of Object.entries(holidays)) {
|
|
684
|
+
if (!/^\d{2}-\d{2}$/.test(day)) continue;
|
|
685
|
+
const type = entry?.type?.type ?? entry?.type;
|
|
686
|
+
if (type === 'workday') continue;
|
|
687
|
+
if (entry?.holiday === true || type === 'holiday') out.push(`${year}-${day}`);
|
|
688
|
+
}
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function refreshHolidays(force = false) {
|
|
693
|
+
if (holidayRefreshing || options.holidaySource !== 'auto') return;
|
|
694
|
+
if (!force && Date.now() - holidayFetchedAt < 24 * 60 * 60 * 1000) return;
|
|
695
|
+
holidayRefreshing = true;
|
|
696
|
+
try {
|
|
697
|
+
const year = zonedParts(new Date(), rulesFor(currentProvider()).timezone).year;
|
|
698
|
+
const years = [year, year + 1];
|
|
699
|
+
const dates = new Set();
|
|
700
|
+
let fetched = false;
|
|
701
|
+
for (const item of years) {
|
|
702
|
+
try {
|
|
703
|
+
const response = await fetch(`${options.holidayApi}/${item}`, {
|
|
704
|
+
signal: AbortSignal.timeout(8000),
|
|
705
|
+
headers: { accept: 'application/json' },
|
|
706
|
+
});
|
|
707
|
+
if (!response.ok) continue;
|
|
708
|
+
const body = await response.json();
|
|
709
|
+
for (const date of parseHolidayYear(item, body)) dates.add(date);
|
|
710
|
+
fetched = true;
|
|
711
|
+
} catch {
|
|
712
|
+
/* 单个年份失败不影响其它年份 */
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (dates.size > 0) {
|
|
716
|
+
dynamicHolidays = [...dates];
|
|
717
|
+
holidayFetchedAt = Date.now();
|
|
718
|
+
clearRulesCache();
|
|
719
|
+
await writeFile(holidayCachePath, JSON.stringify({ fetchedAt: holidayFetchedAt, dates: dynamicHolidays }, null, 2), 'utf8').catch(() => {});
|
|
720
|
+
} else if (fetched) {
|
|
721
|
+
holidayFetchedAt = Date.now();
|
|
722
|
+
}
|
|
723
|
+
} finally {
|
|
724
|
+
holidayRefreshing = false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** 峰时新起的任务:按模式停住/等子代理,谷价再恢复。 */
|
|
459
729
|
async function onAgentRunning(agent) {
|
|
460
730
|
if (disposed || !options.enabled || !options.deferNewTurnsOnPeak || busy.pause) return;
|
|
461
731
|
if (deferring.has(agent.id)) return;
|
|
462
732
|
const now = Date.now();
|
|
463
733
|
if ((bypass.get(agent.id) ?? 0) > now) return;
|
|
734
|
+
if (isExempt(agent.id)) return;
|
|
464
735
|
const session = guardableSession(agent);
|
|
465
736
|
if (session === null) return;
|
|
466
737
|
const { provider, model } = modelOf(agent);
|
|
@@ -469,8 +740,62 @@ export function createGuard(ctx, config = {}) {
|
|
|
469
740
|
if (desc.phase !== 'peak') return;
|
|
470
741
|
deferring.add(agent.id);
|
|
471
742
|
try {
|
|
472
|
-
const state = await
|
|
473
|
-
|
|
743
|
+
const state = await readState();
|
|
744
|
+
const waiting = state.interrupted.find(
|
|
745
|
+
(item) =>
|
|
746
|
+
item.sessionId === agent.id &&
|
|
747
|
+
(item.status === 'waiting-turn' || item.status === 'waiting-children' || item.status === 'paused'),
|
|
748
|
+
);
|
|
749
|
+
if (waiting !== undefined) {
|
|
750
|
+
if (waiting.status === 'paused') return;
|
|
751
|
+
const children = await hasRunningChildren(agent.id);
|
|
752
|
+
if (waiting.status === 'waiting-children') {
|
|
753
|
+
if (children) return;
|
|
754
|
+
waiting.status = 'paused';
|
|
755
|
+
waiting.at = now;
|
|
756
|
+
waiting.cause = 'children-ended';
|
|
757
|
+
await stateFile.write(state);
|
|
758
|
+
await cancelSession(agent.id);
|
|
759
|
+
await publish(describeWindow(Date.now(), rulesFor(provider)), state);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const turn = currentTurn(agent);
|
|
763
|
+
if (turn <= (Number(waiting.allowedTurn) || 0)) return;
|
|
764
|
+
waiting.status = 'paused';
|
|
765
|
+
waiting.at = now;
|
|
766
|
+
waiting.cause = 'turn-boundary';
|
|
767
|
+
await stateFile.write(state);
|
|
768
|
+
try {
|
|
769
|
+
await cancelSession(agent.id);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
waiting.status = 'failed';
|
|
772
|
+
waiting.error = messageOf(error);
|
|
773
|
+
await stateFile.write(state);
|
|
774
|
+
log('warn', `暂停会话 ${agent.id} 失败:${messageOf(error)}`);
|
|
775
|
+
}
|
|
776
|
+
await publish(describeWindow(Date.now(), rulesFor(provider)), state);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const children = await hasRunningChildren(agent.id);
|
|
780
|
+
if (children) {
|
|
781
|
+
const item = {
|
|
782
|
+
id: shortId('pause'),
|
|
783
|
+
sessionId: agent.id,
|
|
784
|
+
title: titleOf(session),
|
|
785
|
+
model,
|
|
786
|
+
provider,
|
|
787
|
+
at: now,
|
|
788
|
+
windowStart: desc.window?.startAt ?? null,
|
|
789
|
+
windowEnd: desc.window?.endAt ?? null,
|
|
790
|
+
status: 'waiting-children',
|
|
791
|
+
cause: 'children',
|
|
792
|
+
allowedTurn: currentTurn(agent),
|
|
793
|
+
};
|
|
794
|
+
state.interrupted.unshift(item);
|
|
795
|
+
await stateFile.write(state);
|
|
796
|
+
await publish(describeWindow(Date.now(), rulesFor(provider)), state);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
474
799
|
const item = {
|
|
475
800
|
id: shortId('pause'),
|
|
476
801
|
sessionId: agent.id,
|
|
@@ -503,10 +828,14 @@ export function createGuard(ctx, config = {}) {
|
|
|
503
828
|
if (disposed || busy.tick) return;
|
|
504
829
|
busy.tick = true;
|
|
505
830
|
try {
|
|
506
|
-
const state = await
|
|
831
|
+
const state = await readState();
|
|
507
832
|
const now = Date.now();
|
|
508
833
|
const desc = describeWindow(now, rulesFor(currentProvider()));
|
|
509
834
|
|
|
835
|
+
if (options.holidaySource === 'auto' && now - holidayFetchedAt > 24 * 60 * 60 * 1000) {
|
|
836
|
+
void refreshHolidays();
|
|
837
|
+
}
|
|
838
|
+
|
|
510
839
|
if (!restoreChecked) {
|
|
511
840
|
restoreChecked = true;
|
|
512
841
|
const recentlyActive = Number(state.meta.lastTick) > 0 && now - Number(state.meta.lastTick) < 5 * 60 * 1000;
|
|
@@ -523,6 +852,8 @@ export function createGuard(ctx, config = {}) {
|
|
|
523
852
|
}
|
|
524
853
|
|
|
525
854
|
if (desc.phase === 'offpeak') {
|
|
855
|
+
const settled = await settleWaiting(state);
|
|
856
|
+
if (settled) await stateFile.write(state);
|
|
526
857
|
if (options.resumeOnOffPeak) {
|
|
527
858
|
if (resumeAt === 0) resumeAt = now + options.resumeDelayMs;
|
|
528
859
|
if (now >= resumeAt) {
|
|
@@ -533,6 +864,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
533
864
|
await runDuePlans();
|
|
534
865
|
} else {
|
|
535
866
|
resumeAt = 0;
|
|
867
|
+
if (await checkWaiting(state)) await stateFile.write(state);
|
|
536
868
|
if (options.pauseRunningOnPeak && lastPhase === 'offpeak') {
|
|
537
869
|
await pauseRunning('window-start');
|
|
538
870
|
}
|
|
@@ -555,6 +887,18 @@ export function createGuard(ctx, config = {}) {
|
|
|
555
887
|
if (status !== 'running' || agent === undefined) return;
|
|
556
888
|
void onAgentRunning(agent);
|
|
557
889
|
});
|
|
890
|
+
const offSubStart = ctx.on('subagent/start', (info) => {
|
|
891
|
+
const child = info?.childSessionId ?? info?.childId ?? info?.id;
|
|
892
|
+
const parent = info?.parentSessionId ?? info?.parentId ?? info?.parent?.id;
|
|
893
|
+
if (child !== undefined && parent !== undefined) childParent.set(String(child), String(parent));
|
|
894
|
+
});
|
|
895
|
+
const offSubEnd = ctx.on('subagent/end', (info) => {
|
|
896
|
+
const child = info?.childSessionId ?? info?.childId ?? info?.id;
|
|
897
|
+
const parent = info?.parentSessionId ?? info?.parentId ?? (child !== undefined ? childParent.get(String(child)) : undefined);
|
|
898
|
+
if (child !== undefined) childParent.delete(String(child));
|
|
899
|
+
if (parent !== undefined) void checkWaitingForId(String(parent));
|
|
900
|
+
});
|
|
901
|
+
void loadHolidayCache().then(() => refreshHolidays(true));
|
|
558
902
|
void tick();
|
|
559
903
|
timer = setInterval(() => {
|
|
560
904
|
void tick();
|
|
@@ -564,16 +908,18 @@ export function createGuard(ctx, config = {}) {
|
|
|
564
908
|
disposed = true;
|
|
565
909
|
if (timer !== null) clearInterval(timer);
|
|
566
910
|
timer = null;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
911
|
+
for (const off of [offStatus, offSubStart, offSubEnd]) {
|
|
912
|
+
try {
|
|
913
|
+
off?.();
|
|
914
|
+
} catch {
|
|
915
|
+
/* ignore */
|
|
916
|
+
}
|
|
571
917
|
}
|
|
572
918
|
};
|
|
573
919
|
}
|
|
574
920
|
|
|
575
921
|
async function status() {
|
|
576
|
-
const state = await
|
|
922
|
+
const state = await readState();
|
|
577
923
|
const desc = describeWindow(Date.now(), rulesFor(currentProvider()));
|
|
578
924
|
await publish(desc, state);
|
|
579
925
|
return { desc, state, snapshot: await snapshotOf(desc, state) };
|
|
@@ -595,7 +941,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
595
941
|
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
|
596
942
|
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
|
|
597
943
|
if (title === '' && prompt === '') throw new Error('规划至少要有 title 或 prompt');
|
|
598
|
-
const state = await
|
|
944
|
+
const state = await readState();
|
|
599
945
|
const sessionId = typeof input.sessionId === 'string' && input.sessionId.trim() !== '' ? input.sessionId.trim() : input.fallbackSessionId;
|
|
600
946
|
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
601
947
|
throw new Error('无法确定要执行的会话:请在已知会话里调用,或显式传 sessionId');
|
|
@@ -616,7 +962,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
616
962
|
}
|
|
617
963
|
|
|
618
964
|
async function removePlan(id) {
|
|
619
|
-
const state = await
|
|
965
|
+
const state = await readState();
|
|
620
966
|
const before = state.plans.length;
|
|
621
967
|
state.plans = state.plans.filter((item) => item.id !== id);
|
|
622
968
|
const removed = before !== state.plans.length;
|
|
@@ -626,7 +972,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
626
972
|
}
|
|
627
973
|
|
|
628
974
|
async function clearPlans() {
|
|
629
|
-
const state = await
|
|
975
|
+
const state = await readState();
|
|
630
976
|
const removed = state.plans.filter((item) => item.status === 'queued').length;
|
|
631
977
|
state.plans = state.plans.filter((item) => item.status !== 'queued');
|
|
632
978
|
await stateFile.write(state);
|
|
@@ -640,6 +986,43 @@ export function createGuard(ctx, config = {}) {
|
|
|
640
986
|
return executed;
|
|
641
987
|
}
|
|
642
988
|
|
|
989
|
+
async function setExempt(sessionId, exempt) {
|
|
990
|
+
const id = String(sessionId);
|
|
991
|
+
const state = await readState();
|
|
992
|
+
if (exempt === true) {
|
|
993
|
+
const session = service('sessions')?.get?.(id);
|
|
994
|
+
state.exemptSessions[id] = { title: session !== undefined ? titleOf(session) : null, at: Date.now() };
|
|
995
|
+
} else {
|
|
996
|
+
delete state.exemptSessions[id];
|
|
997
|
+
}
|
|
998
|
+
await stateFile.write(state);
|
|
999
|
+
exemptSessions = state.exemptSessions ?? {};
|
|
1000
|
+
await status();
|
|
1001
|
+
return { sessionId: id, exempt: exempt === true };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function setModelRule(provider, model, value) {
|
|
1005
|
+
const key = `${String(provider ?? '')}/${String(model ?? '')}`;
|
|
1006
|
+
const state = await readState();
|
|
1007
|
+
if (value === null || value === undefined) delete state.modelRules[key];
|
|
1008
|
+
else state.modelRules[key] = value === true;
|
|
1009
|
+
await stateFile.write(state);
|
|
1010
|
+
modelRules = state.modelRules ?? {};
|
|
1011
|
+
await status();
|
|
1012
|
+
return { key, value: value === true ? true : null };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function setPauseMode(mode) {
|
|
1016
|
+
const next = mode === 'finish-turn' ? 'finish-turn' : 'pause-now';
|
|
1017
|
+
const state = await readState();
|
|
1018
|
+
state.settings = state.settings ?? {};
|
|
1019
|
+
state.settings.pauseMode = next;
|
|
1020
|
+
await stateFile.write(state);
|
|
1021
|
+
pauseMode = next;
|
|
1022
|
+
await status();
|
|
1023
|
+
return next;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
643
1026
|
return {
|
|
644
1027
|
rules,
|
|
645
1028
|
options,
|
|
@@ -653,6 +1036,9 @@ export function createGuard(ctx, config = {}) {
|
|
|
653
1036
|
removePlan,
|
|
654
1037
|
clearPlans,
|
|
655
1038
|
runPlansNow,
|
|
1039
|
+
setExempt,
|
|
1040
|
+
setModelRule,
|
|
1041
|
+
setPauseMode,
|
|
656
1042
|
describe: (now = Date.now()) => describeWindow(now, rules),
|
|
657
1043
|
formatClock: (date) => formatClock(date, rules.timezone),
|
|
658
1044
|
formatDuration,
|