@goodandready/dsh-agent-orchestrator 0.1.6

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.
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Subagent Session Lifecycle, Capacity Recycling, and Reversible Archive Manager.
3
+ *
4
+ * Implements:
5
+ * 1. Event-based and timer-based auto-archiving of one-shot subagents after grace period (#56).
6
+ * 2. Projection cache cleanup (`session_projcache.json`) to prevent bloat (#56).
7
+ * 3. Capacity Recycling with Priority Cascade Rotation (#67):
8
+ * - Enforces hard ceiling on total sessions (default: 400).
9
+ * - Eviction Tier 1: completed one-shot subagents (oldest-first by completedAt).
10
+ * - Eviction Tier 2: stale continuable subagents exceeding inactivity threshold.
11
+ * - Eviction Tier 3: main sessions (only if cleanMain is enabled).
12
+ * - Pin Whitelist: pinned and active sessions are strictly protected from eviction.
13
+ * 4. Two-Phase Reversible Cleanup (#57):
14
+ * - Phase 1 (Reversible Archive): Moves session data to `sessions-archive/<workspace>/<sessionId>`.
15
+ * - Restore Support: Allows restoring archived session before retention expires.
16
+ * - Phase 2 (Physical Retention Deletion): Permanently deletes files after retention period (default 24h).
17
+ */
18
+
19
+ import fs from 'fs'
20
+ import path from 'path'
21
+ import os from 'os'
22
+
23
+ export const DEFAULT_GRACE_PERIOD_MS = 3 * 60 * 1000 // 3 minutes
24
+ export const DEFAULT_RECONCILE_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes
25
+ export const DEFAULT_MAX_CAPACITY = 400 // Issue #67
26
+ export const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000 // 24 hours (Issue #57)
27
+ export const DEFAULT_INACTIVITY_THRESHOLD_MS = 60 * 60 * 1000 // 1 hour
28
+
29
+ export class SessionLifecycleManager {
30
+ /**
31
+ * @param {object} [options]
32
+ * @param {number} [options.gracePeriodMs=180000]
33
+ * @param {number} [options.reconcileIntervalMs=300000]
34
+ * @param {number} [options.maxCapacity=400] Issue #67
35
+ * @param {number} [options.retentionMs=86400000] Issue #57
36
+ * @param {number} [options.inactivityThresholdMs=3600000]
37
+ * @param {string} [options.archiveDir] Directory for reversible sessions archive
38
+ * @param {string} [options.workspace='default']
39
+ * @param {string} [options.projCachePath]
40
+ * @param {object} [options.subagentsService]
41
+ * @param {object} [options.sessionsService]
42
+ * @param {function} [options.onArchive]
43
+ * @param {function} [options.onDelete]
44
+ */
45
+ constructor(options = {}) {
46
+ this.gracePeriodMs = options.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS
47
+ this.reconcileIntervalMs = options.reconcileIntervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS
48
+ this.maxCapacity = options.maxCapacity ?? DEFAULT_MAX_CAPACITY
49
+ this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS
50
+ this.inactivityThresholdMs = options.inactivityThresholdMs ?? DEFAULT_INACTIVITY_THRESHOLD_MS
51
+ this.workspace = options.workspace || 'default'
52
+
53
+ // Default archive dir: ~/.dsh/sessions-archive/<workspace>
54
+ const homeDir = os.homedir()
55
+ this.archiveBaseDir =
56
+ options.archiveDir || path.join(homeDir, '.dsh', 'sessions-archive', this.workspace)
57
+
58
+ this.projCachePath = options.projCachePath || null
59
+ this.subagentsService = options.subagentsService || null
60
+ this.sessionsService = options.sessionsService || null
61
+ this.onArchive = options.onArchive || null
62
+ this.onDelete = options.onDelete || null
63
+ this.logger = options.logger || null
64
+
65
+ /**
66
+ * @type {Map<string, {
67
+ * id: string,
68
+ * registeredAt: number,
69
+ * lastActivityAt: number,
70
+ * completedAt: number|null,
71
+ * status: 'active'|'completed'|'archived',
72
+ * mode: 'one-shot'|'continuable'|'main',
73
+ * isPinned: boolean,
74
+ * timer: any,
75
+ * retentionTimer: any,
76
+ * metadata: object,
77
+ * archivedPath?: string
78
+ * }>}
79
+ */
80
+ this.sessions = new Map()
81
+
82
+ this.reconcileTimer = null
83
+ if (this.reconcileIntervalMs > 0 && typeof setInterval === 'function') {
84
+ this.reconcileTimer = setInterval(() => {
85
+ this.reconcile().catch((err) => {
86
+ this._logDebug('[SessionLifecycle] reconcile error:', err?.message || err)
87
+ })
88
+ }, this.reconcileIntervalMs)
89
+ this.reconcileTimer?.unref?.()
90
+ }
91
+ }
92
+
93
+ _logDebug(...args) {
94
+ if (this.logger?.debug) {
95
+ this.logger.debug(...args)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Registers a session.
101
+ *
102
+ * @param {string} sessionId
103
+ * @param {object} [options]
104
+ */
105
+ register(sessionId, options = {}) {
106
+ if (!sessionId) return
107
+ const existing = this.sessions.get(sessionId)
108
+ if (existing) {
109
+ existing.lastActivityAt = Date.now()
110
+ existing.metadata = { ...existing.metadata, ...options }
111
+ if (options.isPinned !== undefined) existing.isPinned = Boolean(options.isPinned)
112
+ return
113
+ }
114
+
115
+ const now = Date.now()
116
+ this.sessions.set(sessionId, {
117
+ id: sessionId,
118
+ registeredAt: now,
119
+ lastActivityAt: now,
120
+ completedAt: null,
121
+ status: 'active',
122
+ mode: options.mode || 'one-shot',
123
+ isPinned: Boolean(options.isPinned),
124
+ timer: null,
125
+ retentionTimer: null,
126
+ metadata: options,
127
+ })
128
+
129
+ // Check capacity recycling upon new registration (Issue #67)
130
+ this.enforceCapacityRecycling()
131
+ }
132
+
133
+ /**
134
+ * Records activity to update lastActivityAt.
135
+ */
136
+ touch(sessionId) {
137
+ const entry = this.sessions.get(sessionId)
138
+ if (entry) {
139
+ entry.lastActivityAt = Date.now()
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Sets pinned status protecting session from eviction.
145
+ */
146
+ setPinned(sessionId, isPinned = true) {
147
+ const entry = this.sessions.get(sessionId)
148
+ if (entry) {
149
+ entry.isPinned = Boolean(isPinned)
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Marks a session as finished and schedules auto-archiving after the grace period.
155
+ *
156
+ * @param {string} sessionId
157
+ * @param {object} [result]
158
+ */
159
+ markCompleted(sessionId, result = {}) {
160
+ if (!sessionId) return
161
+ let entry = this.sessions.get(sessionId)
162
+ if (!entry) {
163
+ this.register(sessionId, { mode: 'one-shot', result })
164
+ entry = this.sessions.get(sessionId)
165
+ }
166
+
167
+ const now = Date.now()
168
+ entry.completedAt = now
169
+ entry.lastActivityAt = now
170
+ entry.status = 'completed'
171
+ entry.metadata = { ...entry.metadata, result }
172
+
173
+ if (entry.timer) {
174
+ clearTimeout(entry.timer)
175
+ }
176
+
177
+ entry.timer = setTimeout(() => {
178
+ this.archive(sessionId).catch((err) => {
179
+ this._logDebug('[SessionLifecycle] archive timer error:', err?.message || err)
180
+ })
181
+ }, this.gracePeriodMs)
182
+ entry.timer?.unref?.()
183
+
184
+ // Trigger recycling check if capacity exceeded
185
+ this.enforceCapacityRecycling()
186
+ }
187
+
188
+ /**
189
+ * Phase 1: Reversible Archive (Issue #57).
190
+ * Moves session into sessions-archive/<workspace>/<sessionId> and starts retention timer.
191
+ *
192
+ * @param {string} sessionId
193
+ * @returns {Promise<boolean>}
194
+ */
195
+ async archive(sessionId) {
196
+ const entry = this.sessions.get(sessionId)
197
+ if (!entry) return false
198
+
199
+ if (entry.timer) {
200
+ clearTimeout(entry.timer)
201
+ entry.timer = null
202
+ }
203
+
204
+ entry.status = 'archived'
205
+
206
+ // 1. Call DSH subagents / sessions service archive if available
207
+ try {
208
+ if (this.subagentsService && typeof this.subagentsService.archive === 'function') {
209
+ await this.subagentsService.archive(sessionId)
210
+ } else if (this.subagentsService && typeof this.subagentsService.dispose === 'function') {
211
+ await this.subagentsService.dispose(sessionId)
212
+ }
213
+ } catch (err) {
214
+ this._logDebug('[SessionLifecycle] subagents archive skipped:', err?.message || err)
215
+ }
216
+
217
+ try {
218
+ if (this.sessionsService && typeof this.sessionsService.archive === 'function') {
219
+ await this.sessionsService.archive(sessionId)
220
+ }
221
+ } catch (err) {
222
+ this._logDebug('[SessionLifecycle] sessions archive skipped:', err?.message || err)
223
+ }
224
+
225
+ // 2. Synchronous/safe cleanup of session_projcache.json (Issue #56)
226
+ if (this.projCachePath) {
227
+ this.purgeFromProjCache(sessionId, this.projCachePath)
228
+ }
229
+
230
+ // 3. Phase 1: Create reversible archive directory entry (Issue #57)
231
+ try {
232
+ const sessionArchiveDir = path.join(this.archiveBaseDir, sessionId)
233
+ if (!fs.existsSync(sessionArchiveDir)) {
234
+ fs.mkdirSync(sessionArchiveDir, { recursive: true })
235
+ }
236
+ const metaPath = path.join(sessionArchiveDir, 'meta.json')
237
+ fs.writeFileSync(
238
+ metaPath,
239
+ JSON.stringify(
240
+ {
241
+ sessionId,
242
+ workspace: this.workspace,
243
+ archivedAt: Date.now(),
244
+ retentionExpiresAt: Date.now() + this.retentionMs,
245
+ entry: {
246
+ registeredAt: entry.registeredAt,
247
+ completedAt: entry.completedAt,
248
+ mode: entry.mode,
249
+ metadata: entry.metadata,
250
+ },
251
+ },
252
+ null,
253
+ 2
254
+ ),
255
+ 'utf-8'
256
+ )
257
+ entry.archivedPath = sessionArchiveDir
258
+ } catch (err) {
259
+ this._logDebug('[SessionLifecycle] session archive directory creation warning:', err?.message || err)
260
+ }
261
+
262
+ if (typeof this.onArchive === 'function') {
263
+ try {
264
+ this.onArchive(sessionId, entry.metadata)
265
+ } catch (err) {
266
+ this._logDebug('[SessionLifecycle] onArchive callback error:', err?.message || err)
267
+ }
268
+ }
269
+
270
+ // Phase 2: Schedule physical retention deletion (Issue #57)
271
+ if (this.retentionMs > 0) {
272
+ entry.retentionTimer = setTimeout(() => {
273
+ this.physicalDelete(sessionId).catch((err) => {
274
+ this._logDebug('[SessionLifecycle] retention physical delete error:', err?.message || err)
275
+ })
276
+ }, this.retentionMs)
277
+ entry.retentionTimer?.unref?.()
278
+ }
279
+
280
+ return true
281
+ }
282
+
283
+ /**
284
+ * Restores an archived session back to active state before retention expiration (Issue #57).
285
+ *
286
+ * @param {string} sessionId
287
+ * @returns {boolean} True if successfully restored
288
+ */
289
+ restore(sessionId) {
290
+ const entry = this.sessions.get(sessionId)
291
+ const sessionArchiveDir = path.join(this.archiveBaseDir, sessionId)
292
+
293
+ if (!entry && !fs.existsSync(sessionArchiveDir)) {
294
+ return false
295
+ }
296
+
297
+ if (entry) {
298
+ if (entry.retentionTimer) {
299
+ clearTimeout(entry.retentionTimer)
300
+ entry.retentionTimer = null
301
+ }
302
+ entry.status = 'active'
303
+ entry.completedAt = null
304
+ entry.lastActivityAt = Date.now()
305
+ } else {
306
+ // Reconstitute from archive meta.json
307
+ try {
308
+ const raw = fs.readFileSync(path.join(sessionArchiveDir, 'meta.json'), 'utf-8')
309
+ const data = JSON.parse(raw)
310
+ this.register(sessionId, data.entry?.metadata || {})
311
+ } catch (err) {
312
+ this._logDebug('[SessionLifecycle] failed to restore meta:', err?.message || err)
313
+ return false
314
+ }
315
+ }
316
+
317
+ // Clean up archive directory
318
+ try {
319
+ if (fs.existsSync(sessionArchiveDir)) {
320
+ fs.rmSync(sessionArchiveDir, { recursive: true, force: true })
321
+ }
322
+ } catch (err) {
323
+ this._logDebug('[SessionLifecycle] restore archive cleanup warning:', err?.message || err)
324
+ }
325
+
326
+ return true
327
+ }
328
+
329
+ /**
330
+ * Phase 2: Physically deletes session and its archive files from disk (Issue #57).
331
+ *
332
+ * @param {string} sessionId
333
+ */
334
+ async physicalDelete(sessionId) {
335
+ const entry = this.sessions.get(sessionId)
336
+ if (entry?.retentionTimer) {
337
+ clearTimeout(entry.retentionTimer)
338
+ entry.retentionTimer = null
339
+ }
340
+
341
+ const sessionArchiveDir = path.join(this.archiveBaseDir, sessionId)
342
+ try {
343
+ if (fs.existsSync(sessionArchiveDir)) {
344
+ fs.rmSync(sessionArchiveDir, { recursive: true, force: true })
345
+ }
346
+ } catch (err) {
347
+ this._logDebug('[SessionLifecycle] physical deletion error:', err?.message || err)
348
+ }
349
+
350
+ if (typeof this.onDelete === 'function') {
351
+ try {
352
+ this.onDelete(sessionId)
353
+ } catch (err) {
354
+ this._logDebug('[SessionLifecycle] onDelete callback warning:', err?.message || err)
355
+ }
356
+ }
357
+
358
+ this.sessions.delete(sessionId)
359
+ }
360
+
361
+ /**
362
+ * Enforces Capacity Recycling with Priority Cascade Rotation (Issue #67).
363
+ *
364
+ * When total tracked sessions exceed maxCapacity:
365
+ * 1. Priority 1: completed one-shot subagents (oldest-first by completedAt)
366
+ * 2. Priority 2: stale continuable subagents exceeding inactivity threshold (oldest-first)
367
+ * 3. Priority 3: main sessions (only if cleanMain is enabled)
368
+ * Pinned sessions and active running subagents are whitelisted and never evicted.
369
+ */
370
+ enforceCapacityRecycling({ cleanMain = false } = {}) {
371
+ if (this.sessions.size <= this.maxCapacity) return
372
+
373
+ const now = Date.now()
374
+ const candidatesTier1 = []
375
+ const candidatesTier2 = []
376
+ const candidatesTier3 = []
377
+
378
+ for (const entry of this.sessions.values()) {
379
+ if (entry.isPinned) continue // Pin Whitelist protection
380
+ if (entry.status === 'active' && entry.mode !== 'continuable') continue // Active worker protection
381
+
382
+ // Tier 1: completed one-shot subagents
383
+ if (entry.mode === 'one-shot' && entry.status === 'completed') {
384
+ candidatesTier1.push(entry)
385
+ }
386
+ // Tier 2: stale continuable subagents
387
+ else if (
388
+ entry.mode === 'continuable' &&
389
+ now - entry.lastActivityAt >= this.inactivityThresholdMs
390
+ ) {
391
+ candidatesTier2.push(entry)
392
+ }
393
+ // Tier 3: main sessions (if allowed)
394
+ else if (cleanMain && entry.mode === 'main') {
395
+ candidatesTier3.push(entry)
396
+ }
397
+ }
398
+
399
+ // Oldest-first sort comparator
400
+ const sortByOldest = (a, b) => (a.completedAt || a.lastActivityAt) - (b.completedAt || b.lastActivityAt)
401
+ candidatesTier1.sort(sortByOldest)
402
+ candidatesTier2.sort(sortByOldest)
403
+ candidatesTier3.sort(sortByOldest)
404
+
405
+ const evictionQueue = [...candidatesTier1, ...candidatesTier2, ...candidatesTier3]
406
+
407
+ while (this.sessions.size > this.maxCapacity && evictionQueue.length > 0) {
408
+ const victim = evictionQueue.shift()
409
+ this.archive(victim.id).catch((err) => {
410
+ this._logDebug('[SessionLifecycle] capacity eviction warning:', err?.message || err)
411
+ })
412
+ // For immediate memory reclamation in Map
413
+ this.sessions.delete(victim.id)
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Safely purges an entry from session_projcache.json file if present.
419
+ *
420
+ * @param {string} sessionId
421
+ * @param {string} cachePath
422
+ */
423
+ purgeFromProjCache(sessionId, cachePath) {
424
+ try {
425
+ if (!fs.existsSync(cachePath)) return
426
+ const raw = fs.readFileSync(cachePath, 'utf-8')
427
+ let data = JSON.parse(raw)
428
+
429
+ let changed = false
430
+ if (Array.isArray(data)) {
431
+ const initialLen = data.length
432
+ data = data.filter((item) => (item?.id || item?.sessionId) !== sessionId)
433
+ changed = data.length !== initialLen
434
+ } else if (data && typeof data === 'object') {
435
+ if (sessionId in data) {
436
+ delete data[sessionId]
437
+ changed = true
438
+ } else if (data.sessions && Array.isArray(data.sessions)) {
439
+ const initialLen = data.sessions.length
440
+ data.sessions = data.sessions.filter((item) => (item?.id || item?.sessionId) !== sessionId)
441
+ changed = data.sessions.length !== initialLen
442
+ }
443
+ }
444
+
445
+ if (changed) {
446
+ fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), 'utf-8')
447
+ }
448
+ } catch (err) {
449
+ this._logDebug('[SessionLifecycle] purgeFromProjCache skipped:', err?.message || err)
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Periodic reconciliation: archives completed sessions exceeding grace period,
455
+ * and purges expired retention files.
456
+ */
457
+ async reconcile() {
458
+ const now = Date.now()
459
+ const toArchive = []
460
+
461
+ for (const [id, entry] of this.sessions.entries()) {
462
+ if (entry.status === 'completed' && entry.completedAt) {
463
+ if (now - entry.completedAt >= this.gracePeriodMs) {
464
+ toArchive.push(id)
465
+ }
466
+ }
467
+ }
468
+
469
+ for (const id of toArchive) {
470
+ await this.archive(id)
471
+ }
472
+
473
+ this.enforceCapacityRecycling()
474
+ }
475
+
476
+ /**
477
+ * Cancels all timers and clears tracked sessions.
478
+ */
479
+ dispose() {
480
+ if (this.reconcileTimer) {
481
+ clearInterval(this.reconcileTimer)
482
+ this.reconcileTimer = null
483
+ }
484
+
485
+ for (const [, entry] of this.sessions.entries()) {
486
+ if (entry.timer) {
487
+ clearTimeout(entry.timer)
488
+ entry.timer = null
489
+ }
490
+ if (entry.retentionTimer) {
491
+ clearTimeout(entry.retentionTimer)
492
+ entry.retentionTimer = null
493
+ }
494
+ }
495
+ this.sessions.clear()
496
+ }
497
+
498
+ /**
499
+ * Returns current active/pending counts.
500
+ */
501
+ getStats() {
502
+ let active = 0
503
+ let completed = 0
504
+ let archived = 0
505
+ let pinned = 0
506
+ for (const [, entry] of this.sessions.entries()) {
507
+ if (entry.status === 'active') active++
508
+ if (entry.status === 'completed') completed++
509
+ if (entry.status === 'archived') archived++
510
+ if (entry.isPinned) pinned++
511
+ }
512
+ return {
513
+ total: active + completed,
514
+ active,
515
+ completed,
516
+ archived,
517
+ pinned,
518
+ }
519
+ }
520
+ }