@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.
- package/LICENSE +22 -0
- package/README.md +184 -0
- package/README.ru.md +86 -0
- package/README.zh.md +64 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1374 -0
- package/lib/http-guard.js +166 -0
- package/lib/index.js +950 -0
- package/lib/integrations/kanban-bridge.js +102 -0
- package/lib/pipeline/cache-prefixer.js +226 -0
- package/lib/pipeline/concurrency-gate.js +110 -0
- package/lib/pipeline/dag-engine.js +290 -0
- package/lib/pipeline/decision-trace.js +124 -0
- package/lib/pipeline/decomposer.js +102 -0
- package/lib/pipeline/delegation.js +625 -0
- package/lib/pipeline/guidance.js +94 -0
- package/lib/pipeline/intent.js +98 -0
- package/lib/pipeline/intersection.js +235 -0
- package/lib/pipeline/model-selection.js +742 -0
- package/lib/pipeline/preset-sync.js +84 -0
- package/lib/pipeline/scenarios.js +468 -0
- package/lib/pipeline/session-lifecycle.js +520 -0
- package/lib/pipeline/snapshots.js +240 -0
- package/lib/pipeline/token-watchdog.js +101 -0
- package/lib/pipeline/worker-pool.js +143 -0
- package/lib/routes.js +277 -0
- package/lib/store.js +176 -0
- package/package.json +94 -0
|
@@ -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
|
+
}
|