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/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 === '' || options.exemptModels.includes(model)) return false;
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)) rulesCache.set(key, resolveRules(config, provider));
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 stateFile.read();
441
+ const state = await readState();
338
442
  const now = Date.now();
339
- const paused = [];
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((item) => item.sessionId === agent.id && item.status === 'paused');
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
- paused.push(item);
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(agent.id);
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
- log('warn', `暂停会话 ${agent.id} 失败:${messageOf(error)}`);
541
+ changed = true;
376
542
  }
543
+ } else if (agent?.status !== 'running') {
544
+ item.status = 'completed';
545
+ changed = true;
377
546
  }
378
- if (paused.length > 0 || changed) await stateFile.write(state);
379
- return paused;
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
- busy.pause = false;
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 stateFile.read();
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 stateFile.read();
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 stateFile.read();
473
- if (state.interrupted.some((item) => item.sessionId === agent.id && item.status === 'paused')) return;
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 stateFile.read();
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
- try {
568
- offStatus?.();
569
- } catch {
570
- /* ignore */
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 stateFile.read();
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 stateFile.read();
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 stateFile.read();
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 stateFile.read();
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,