bosun 0.36.7 → 0.36.8
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/bosun.schema.json +10 -2
- package/desktop/main.mjs +22 -12
- package/monitor.mjs +34 -13
- package/package.json +1 -1
- package/session-tracker.mjs +183 -42
- package/ui/setup.html +205 -7
- package/ui/tabs/settings.js +336 -2
- package/ui-server.mjs +157 -0
- package/voice-agents-sdk.mjs +1 -1
- package/voice-relay.mjs +20 -3
- package/workflow-migration.mjs +3 -0
- package/workflow-templates/github.mjs +228 -0
- package/workflow-templates/reliability.mjs +313 -1
- package/workflow-templates.mjs +14 -2
package/bosun.schema.json
CHANGED
|
@@ -188,7 +188,11 @@
|
|
|
188
188
|
"verse"
|
|
189
189
|
]
|
|
190
190
|
},
|
|
191
|
-
"azureDeployment": { "type": "string" }
|
|
191
|
+
"azureDeployment": { "type": "string" },
|
|
192
|
+
"endpointId": {
|
|
193
|
+
"type": "string",
|
|
194
|
+
"description": "ID of the voice endpoint to use for this provider slot"
|
|
195
|
+
}
|
|
192
196
|
},
|
|
193
197
|
"required": ["provider"]
|
|
194
198
|
}
|
|
@@ -304,13 +308,17 @@
|
|
|
304
308
|
"additionalProperties": false,
|
|
305
309
|
"required": ["provider"],
|
|
306
310
|
"properties": {
|
|
311
|
+
"id": {
|
|
312
|
+
"type": "string",
|
|
313
|
+
"description": "Unique identifier for this endpoint"
|
|
314
|
+
},
|
|
307
315
|
"name": {
|
|
308
316
|
"type": "string",
|
|
309
317
|
"description": "Friendly label for this endpoint"
|
|
310
318
|
},
|
|
311
319
|
"provider": {
|
|
312
320
|
"type": "string",
|
|
313
|
-
"enum": ["azure", "openai"],
|
|
321
|
+
"enum": ["azure", "openai", "claude", "gemini"],
|
|
314
322
|
"description": "Provider type"
|
|
315
323
|
},
|
|
316
324
|
"endpoint": {
|
package/desktop/main.mjs
CHANGED
|
@@ -1135,6 +1135,18 @@ function registerDesktopIpc() {
|
|
|
1135
1135
|
|
|
1136
1136
|
async function bootstrap() {
|
|
1137
1137
|
try {
|
|
1138
|
+
// Register cert bypass for the local UI server as the very first operation
|
|
1139
|
+
// — before any network request is made (config loading, API key probe, etc.).
|
|
1140
|
+
// allow-insecure-localhost (set pre-ready above) handles 127.0.0.1; this
|
|
1141
|
+
// setCertificateVerifyProc covers LAN IPs (192.168.x.x / 10.x etc.).
|
|
1142
|
+
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
|
1143
|
+
if (isLocalHost(request.hostname)) {
|
|
1144
|
+
callback(0); // 0 = verified OK
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
callback(-3); // -3 = use Chromium default chain verification
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1138
1150
|
if (process.env.ELECTRON_DISABLE_SANDBOX === "1") {
|
|
1139
1151
|
app.commandLine.appendSwitch("no-sandbox");
|
|
1140
1152
|
app.commandLine.appendSwitch("disable-gpu-sandbox");
|
|
@@ -1163,18 +1175,6 @@ async function bootstrap() {
|
|
|
1163
1175
|
console.warn("[desktop] could not load desktop-api-key module:", err?.message || err);
|
|
1164
1176
|
}
|
|
1165
1177
|
|
|
1166
|
-
// Bypass TLS verification for the local embedded UI server.
|
|
1167
|
-
// setCertificateVerifyProc works at the OpenSSL level — it fires before
|
|
1168
|
-
// the higher-level `certificate-error` event and stops the repeated
|
|
1169
|
-
// "handshake failed" logs from Chromium's ssl_client_socket_impl.
|
|
1170
|
-
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
|
1171
|
-
if (isLocalHost(request.hostname)) {
|
|
1172
|
-
callback(0); // 0 = verified OK
|
|
1173
|
-
return;
|
|
1174
|
-
}
|
|
1175
|
-
callback(-3); // -3 = use Chromium default chain verification
|
|
1176
|
-
});
|
|
1177
|
-
|
|
1178
1178
|
await ensureDaemonRunning();
|
|
1179
1179
|
|
|
1180
1180
|
// Initialise shortcuts (loads user config) and register globals.
|
|
@@ -1316,4 +1316,14 @@ process.on("SIGTERM", () => {
|
|
|
1316
1316
|
void shutdown("sigterm");
|
|
1317
1317
|
});
|
|
1318
1318
|
|
|
1319
|
+
// ── Pre-ready Chromium flags ──────────────────────────────────────────────────
|
|
1320
|
+
// These MUST be set before app.isReady() — Chromium reads the command line at
|
|
1321
|
+
// process startup and ignores changes made after the browser process launches.
|
|
1322
|
+
|
|
1323
|
+
// Allow HTTPS connections to localhost (127.0.0.1, ::1, "localhost") using
|
|
1324
|
+
// self-signed certificates without triggering CertVerifyProcBuiltin errors or
|
|
1325
|
+
// ssl_client_socket_impl handshake-failed spam. This only suppresses cert
|
|
1326
|
+
// errors for the loopback address; external HTTPS connections are unaffected.
|
|
1327
|
+
app.commandLine.appendSwitch("allow-insecure-localhost");
|
|
1328
|
+
|
|
1319
1329
|
app.whenReady().then(bootstrap);
|
package/monitor.mjs
CHANGED
|
@@ -6610,6 +6610,18 @@ async function checkMergedPRsAndUpdateTasks() {
|
|
|
6610
6610
|
}
|
|
6611
6611
|
|
|
6612
6612
|
if (worktreePath) {
|
|
6613
|
+
if (isWorkflowReplacingModule("sdk-conflict-resolver.mjs")) {
|
|
6614
|
+
console.log(`[monitor] SDK conflict resolution delegated to workflow for PR #${cc.prNumber}`);
|
|
6615
|
+
void queueWorkflowEvent("pr.conflict_detected", {
|
|
6616
|
+
worktreePath,
|
|
6617
|
+
branch: cc.branch,
|
|
6618
|
+
baseBranch: resolveAttemptTargetBranch(attemptInfo, task),
|
|
6619
|
+
prNumber: cc.prNumber,
|
|
6620
|
+
taskId: task.id,
|
|
6621
|
+
taskTitle: task.title,
|
|
6622
|
+
taskDescription: task.description || "",
|
|
6623
|
+
});
|
|
6624
|
+
} else {
|
|
6613
6625
|
void (async () => {
|
|
6614
6626
|
try {
|
|
6615
6627
|
const result = await resolveConflictsWithSDK({
|
|
@@ -6655,6 +6667,7 @@ async function checkMergedPRsAndUpdateTasks() {
|
|
|
6655
6667
|
);
|
|
6656
6668
|
}
|
|
6657
6669
|
})();
|
|
6670
|
+
} // end else (workflow not replacing sdk-conflict-resolver)
|
|
6658
6671
|
} else {
|
|
6659
6672
|
console.warn(
|
|
6660
6673
|
`[monitor] No worktree found for ${cc.branch} — deferring to orchestrator`,
|
|
@@ -15169,11 +15182,13 @@ try {
|
|
|
15169
15182
|
runGuarded("startup-maintenance-sweep", () =>
|
|
15170
15183
|
runMaintenanceSweep({
|
|
15171
15184
|
repoRoot,
|
|
15172
|
-
archiveCompletedTasks:
|
|
15173
|
-
|
|
15174
|
-
|
|
15175
|
-
|
|
15176
|
-
|
|
15185
|
+
archiveCompletedTasks: isWorkflowReplacingModule("task-archiver.mjs")
|
|
15186
|
+
? async () => { console.log("[monitor] task archiver delegated to workflow"); return { archived: 0 }; }
|
|
15187
|
+
: async () => {
|
|
15188
|
+
const projectId = await findVkProjectId();
|
|
15189
|
+
if (!projectId) return { archived: 0 };
|
|
15190
|
+
return await archiveCompletedTasks(fetchVk, projectId, { maxArchive: 50 });
|
|
15191
|
+
},
|
|
15177
15192
|
}),
|
|
15178
15193
|
);
|
|
15179
15194
|
|
|
@@ -15186,14 +15201,16 @@ safeSetInterval("maintenance-sweep", () => {
|
|
|
15186
15201
|
return runMaintenanceSweep({
|
|
15187
15202
|
repoRoot,
|
|
15188
15203
|
childPid,
|
|
15189
|
-
archiveCompletedTasks:
|
|
15190
|
-
|
|
15191
|
-
|
|
15192
|
-
|
|
15193
|
-
|
|
15194
|
-
|
|
15195
|
-
|
|
15196
|
-
|
|
15204
|
+
archiveCompletedTasks: isWorkflowReplacingModule("task-archiver.mjs")
|
|
15205
|
+
? async () => { console.log("[monitor] task archiver delegated to workflow"); return { archived: 0 }; }
|
|
15206
|
+
: async () => {
|
|
15207
|
+
const projectId = await findVkProjectId();
|
|
15208
|
+
if (!projectId) return { archived: 0 };
|
|
15209
|
+
return await archiveCompletedTasks(fetchVk, projectId, {
|
|
15210
|
+
maxArchive: 25,
|
|
15211
|
+
dryRun: false,
|
|
15212
|
+
});
|
|
15213
|
+
},
|
|
15197
15214
|
});
|
|
15198
15215
|
}, maintenanceIntervalMs);
|
|
15199
15216
|
|
|
@@ -16054,6 +16071,9 @@ if (isExecutorDisabled()) {
|
|
|
16054
16071
|
|
|
16055
16072
|
// ── Sync Engine ──
|
|
16056
16073
|
try {
|
|
16074
|
+
if (isWorkflowReplacingModule("sync-engine.mjs")) {
|
|
16075
|
+
console.log("[monitor] sync engine delegated to workflow — skipping legacy init");
|
|
16076
|
+
} else {
|
|
16057
16077
|
const activeKanbanBackend = getActiveKanbanBackend();
|
|
16058
16078
|
|
|
16059
16079
|
// Sync engine only makes sense when there is an external backend to sync
|
|
@@ -16097,6 +16117,7 @@ if (isExecutorDisabled()) {
|
|
|
16097
16117
|
);
|
|
16098
16118
|
}
|
|
16099
16119
|
} // end else (non-internal backend)
|
|
16120
|
+
} // end else (workflow not replacing sync-engine)
|
|
16100
16121
|
} catch (err) {
|
|
16101
16122
|
console.warn(`[monitor] sync engine failed to start: ${err.message}`);
|
|
16102
16123
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.8",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/session-tracker.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const DEFAULT_CHAT_MAX_MESSAGES = 2000;
|
|
|
30
30
|
const MAX_MESSAGE_CHARS = 2000;
|
|
31
31
|
|
|
32
32
|
/** Maximum total sessions to keep in memory. */
|
|
33
|
-
const MAX_SESSIONS =
|
|
33
|
+
const MAX_SESSIONS = 100;
|
|
34
34
|
|
|
35
35
|
function resolveSessionMaxMessages(type, metadata, explicitMax, fallbackMax) {
|
|
36
36
|
if (Number.isFinite(explicitMax)) {
|
|
@@ -124,6 +124,9 @@ export class SessionTracker {
|
|
|
124
124
|
/** @type {ReturnType<typeof setInterval>|null} */
|
|
125
125
|
#flushTimer = null;
|
|
126
126
|
|
|
127
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
128
|
+
#reaperTimer = null;
|
|
129
|
+
|
|
127
130
|
/**
|
|
128
131
|
* @param {Object} [options]
|
|
129
132
|
* @param {number} [options.maxMessages=10]
|
|
@@ -138,9 +141,15 @@ export class SessionTracker {
|
|
|
138
141
|
if (this.#persistDir) {
|
|
139
142
|
this.#ensureDir();
|
|
140
143
|
this.#loadFromDisk();
|
|
144
|
+
this.#purgeExcessFiles();
|
|
141
145
|
this.#flushTimer = setInterval(() => this.#flushDirty(), FLUSH_INTERVAL_MS);
|
|
142
146
|
if (this.#flushTimer.unref) this.#flushTimer.unref();
|
|
143
147
|
}
|
|
148
|
+
|
|
149
|
+
// Idle reaper — runs periodically to mark stale "active" sessions as "completed"
|
|
150
|
+
const reaperInterval = Math.max(60_000, this.#idleThresholdMs);
|
|
151
|
+
this.#reaperTimer = setInterval(() => this.#reapIdleSessions(), reaperInterval);
|
|
152
|
+
if (this.#reaperTimer.unref) this.#reaperTimer.unref();
|
|
144
153
|
}
|
|
145
154
|
|
|
146
155
|
/**
|
|
@@ -153,12 +162,7 @@ export class SessionTracker {
|
|
|
153
162
|
startSession(taskId, taskTitle) {
|
|
154
163
|
// Evict oldest sessions if at capacity
|
|
155
164
|
if (this.#sessions.size >= MAX_SESSIONS && !this.#sessions.has(taskId)) {
|
|
156
|
-
|
|
157
|
-
.sort((a, b) => a[1].startedAt - b[1].startedAt)
|
|
158
|
-
.slice(0, Math.ceil(MAX_SESSIONS / 4));
|
|
159
|
-
for (const [id] of oldest) {
|
|
160
|
-
this.#sessions.delete(id);
|
|
161
|
-
}
|
|
165
|
+
this.#evictOldest();
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
this.#sessions.set(taskId, {
|
|
@@ -475,6 +479,11 @@ export class SessionTracker {
|
|
|
475
479
|
* @param {{ id: string, type?: string, taskId?: string, metadata?: Object }} opts
|
|
476
480
|
*/
|
|
477
481
|
createSession({ id, type = "manual", taskId, metadata = {}, maxMessages }) {
|
|
482
|
+
// Evict oldest non-active sessions if at capacity
|
|
483
|
+
if (this.#sessions.size >= MAX_SESSIONS && !this.#sessions.has(id)) {
|
|
484
|
+
this.#evictOldest();
|
|
485
|
+
}
|
|
486
|
+
|
|
478
487
|
const now = new Date().toISOString();
|
|
479
488
|
const resolvedMax = resolveSessionMaxMessages(
|
|
480
489
|
type,
|
|
@@ -666,6 +675,7 @@ export class SessionTracker {
|
|
|
666
675
|
/**
|
|
667
676
|
* Merge any on-disk session updates into memory.
|
|
668
677
|
* Useful when another process writes session files.
|
|
678
|
+
* Respects MAX_SESSIONS and heals stale "active" status.
|
|
669
679
|
*/
|
|
670
680
|
refreshFromDisk() {
|
|
671
681
|
if (!this.#persistDir) return;
|
|
@@ -676,6 +686,10 @@ export class SessionTracker {
|
|
|
676
686
|
} catch {
|
|
677
687
|
return;
|
|
678
688
|
}
|
|
689
|
+
|
|
690
|
+
// Pre-parse for sorting
|
|
691
|
+
/** @type {Array<{file: string, data: Object, lastActive: number}>} */
|
|
692
|
+
const parsed = [];
|
|
679
693
|
for (const file of files) {
|
|
680
694
|
const filePath = resolve(this.#persistDir, file);
|
|
681
695
|
try {
|
|
@@ -687,34 +701,53 @@ export class SessionTracker {
|
|
|
687
701
|
Date.parse(data.lastActiveAt || "") ||
|
|
688
702
|
Date.parse(data.updatedAt || "") ||
|
|
689
703
|
0;
|
|
704
|
+
// Skip if already in memory and newer
|
|
690
705
|
const existing = this.#sessions.get(sessionId);
|
|
691
706
|
const existingLast =
|
|
692
707
|
existing?.lastActivityAt ||
|
|
693
708
|
Date.parse(existing?.lastActiveAt || "") ||
|
|
694
709
|
0;
|
|
695
|
-
if (existing && existingLast >= lastActiveAt)
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
this.#sessions.set(sessionId, {
|
|
699
|
-
taskId: data.taskId || sessionId,
|
|
700
|
-
taskTitle: data.title || data.taskTitle || null,
|
|
701
|
-
id: sessionId,
|
|
702
|
-
type: data.type || "task",
|
|
703
|
-
startedAt: Date.parse(data.createdAt || "") || Date.now(),
|
|
704
|
-
createdAt: data.createdAt || new Date().toISOString(),
|
|
705
|
-
lastActiveAt: data.lastActiveAt || data.updatedAt || new Date().toISOString(),
|
|
706
|
-
endedAt: data.endedAt || null,
|
|
707
|
-
messages: Array.isArray(data.messages) ? data.messages : [],
|
|
708
|
-
totalEvents: Array.isArray(data.messages) ? data.messages.length : 0,
|
|
709
|
-
turnCount: data.turnCount || 0,
|
|
710
|
-
status: data.status || "active",
|
|
711
|
-
lastActivityAt: lastActiveAt || Date.now(),
|
|
712
|
-
metadata: data.metadata || {},
|
|
713
|
-
});
|
|
710
|
+
if (existing && existingLast >= lastActiveAt) continue;
|
|
711
|
+
parsed.push({ file, data, lastActive: lastActiveAt });
|
|
714
712
|
} catch {
|
|
715
713
|
/* ignore corrupt session file */
|
|
716
714
|
}
|
|
717
715
|
}
|
|
716
|
+
|
|
717
|
+
// Sort by lastActive descending and limit to MAX_SESSIONS
|
|
718
|
+
parsed.sort((a, b) => b.lastActive - a.lastActive);
|
|
719
|
+
const available = MAX_SESSIONS - this.#sessions.size;
|
|
720
|
+
const toLoad = parsed.slice(0, Math.max(0, available));
|
|
721
|
+
|
|
722
|
+
for (const { data, lastActive } of toLoad) {
|
|
723
|
+
const sessionId = String(data.id || data.taskId || "").trim();
|
|
724
|
+
// Heal stale "active" sessions
|
|
725
|
+
let status = data.status || "completed";
|
|
726
|
+
let endedAt = data.endedAt || null;
|
|
727
|
+
if (status === "active" && lastActive > 0) {
|
|
728
|
+
const ageMs = Date.now() - lastActive;
|
|
729
|
+
if (ageMs > this.#idleThresholdMs) {
|
|
730
|
+
status = "completed";
|
|
731
|
+
endedAt = endedAt || lastActive;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
this.#sessions.set(sessionId, {
|
|
735
|
+
taskId: data.taskId || sessionId,
|
|
736
|
+
taskTitle: data.title || data.taskTitle || null,
|
|
737
|
+
id: sessionId,
|
|
738
|
+
type: data.type || "task",
|
|
739
|
+
startedAt: Date.parse(data.createdAt || "") || Date.now(),
|
|
740
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
741
|
+
lastActiveAt: data.lastActiveAt || data.updatedAt || new Date().toISOString(),
|
|
742
|
+
endedAt,
|
|
743
|
+
messages: Array.isArray(data.messages) ? data.messages : [],
|
|
744
|
+
totalEvents: Array.isArray(data.messages) ? data.messages.length : 0,
|
|
745
|
+
turnCount: data.turnCount || 0,
|
|
746
|
+
status,
|
|
747
|
+
lastActivityAt: lastActive || Date.now(),
|
|
748
|
+
metadata: data.metadata || {},
|
|
749
|
+
});
|
|
750
|
+
}
|
|
718
751
|
}
|
|
719
752
|
|
|
720
753
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
@@ -730,6 +763,49 @@ export class SessionTracker {
|
|
|
730
763
|
});
|
|
731
764
|
}
|
|
732
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Evict the oldest 25% of sessions, preferring completed/idle sessions first.
|
|
768
|
+
* Active sessions are only evicted as a last resort.
|
|
769
|
+
*/
|
|
770
|
+
#evictOldest() {
|
|
771
|
+
const evictCount = Math.max(1, Math.ceil(MAX_SESSIONS / 4));
|
|
772
|
+
// Prefer evicting completed/idle/failed sessions before active ones
|
|
773
|
+
const sorted = [...this.#sessions.entries()]
|
|
774
|
+
.sort((a, b) => {
|
|
775
|
+
const aActive = a[1].status === "active" ? 1 : 0;
|
|
776
|
+
const bActive = b[1].status === "active" ? 1 : 0;
|
|
777
|
+
if (aActive !== bActive) return aActive - bActive; // non-active first
|
|
778
|
+
return (a[1].lastActivityAt || a[1].startedAt) - (b[1].lastActivityAt || b[1].startedAt);
|
|
779
|
+
});
|
|
780
|
+
const toEvict = sorted.slice(0, evictCount);
|
|
781
|
+
for (const [id] of toEvict) {
|
|
782
|
+
this.#sessions.delete(id);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Reap idle sessions: mark sessions as "completed" if they have been
|
|
788
|
+
* inactive for longer than the idle threshold.
|
|
789
|
+
* Called periodically by the reaper interval.
|
|
790
|
+
*/
|
|
791
|
+
#reapIdleSessions() {
|
|
792
|
+
const now = Date.now();
|
|
793
|
+
let reaped = 0;
|
|
794
|
+
for (const [id, session] of this.#sessions) {
|
|
795
|
+
if (session.status !== "active") continue;
|
|
796
|
+
const idleMs = now - (session.lastActivityAt || session.startedAt || now);
|
|
797
|
+
if (idleMs > this.#idleThresholdMs) {
|
|
798
|
+
session.status = "completed";
|
|
799
|
+
session.endedAt = now;
|
|
800
|
+
this.#markDirty(id);
|
|
801
|
+
reaped++;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (reaped > 0) {
|
|
805
|
+
console.log(`${TAG} idle reaper: marked ${reaped} stale session(s) as completed`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
733
809
|
/** Get preview text from last message */
|
|
734
810
|
#lastMessagePreview(session) {
|
|
735
811
|
const last = session.messages?.at(-1);
|
|
@@ -785,10 +861,17 @@ export class SessionTracker {
|
|
|
785
861
|
this.#dirty.clear();
|
|
786
862
|
}
|
|
787
863
|
|
|
864
|
+
/** @type {Set<string>} filenames loaded during #loadFromDisk (for purge) */
|
|
865
|
+
#loadedFiles = new Set();
|
|
866
|
+
|
|
788
867
|
#loadFromDisk() {
|
|
789
868
|
if (!this.#persistDir || !existsSync(this.#persistDir)) return;
|
|
790
869
|
try {
|
|
791
870
|
const files = readdirSync(this.#persistDir).filter((f) => f.endsWith(".json"));
|
|
871
|
+
|
|
872
|
+
// Pre-parse all session files with their timestamps for sorting
|
|
873
|
+
/** @type {Array<{file: string, data: Object, lastActive: number}>} */
|
|
874
|
+
const parsed = [];
|
|
792
875
|
for (const file of files) {
|
|
793
876
|
try {
|
|
794
877
|
const raw = readFileSync(resolve(this.#persistDir, file), "utf8");
|
|
@@ -796,31 +879,89 @@ export class SessionTracker {
|
|
|
796
879
|
if (!data.id && !data.taskId) continue;
|
|
797
880
|
const id = data.id || data.taskId;
|
|
798
881
|
if (this.#sessions.has(id)) continue; // don't overwrite in-memory
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
createdAt: data.createdAt || new Date().toISOString(),
|
|
806
|
-
lastActiveAt: data.lastActiveAt || new Date().toISOString(),
|
|
807
|
-
startedAt: data.createdAt ? new Date(data.createdAt).getTime() : Date.now(),
|
|
808
|
-
endedAt: data.status !== "active" ? Date.now() : null,
|
|
809
|
-
messages: data.messages || [],
|
|
810
|
-
totalEvents: (data.messages || []).length,
|
|
811
|
-
turnCount: data.turnCount || 0,
|
|
812
|
-
lastActivityAt: data.lastActiveAt ? new Date(data.lastActiveAt).getTime() : Date.now(),
|
|
813
|
-
metadata: data.metadata || {},
|
|
814
|
-
});
|
|
882
|
+
const lastActive = data.lastActiveAt
|
|
883
|
+
? new Date(data.lastActiveAt).getTime()
|
|
884
|
+
: data.createdAt
|
|
885
|
+
? new Date(data.createdAt).getTime()
|
|
886
|
+
: 0;
|
|
887
|
+
parsed.push({ file, data, lastActive });
|
|
815
888
|
} catch {
|
|
816
889
|
// Skip corrupt files
|
|
817
890
|
}
|
|
818
891
|
}
|
|
892
|
+
|
|
893
|
+
// Sort by lastActive descending (newest first) and keep only MAX_SESSIONS
|
|
894
|
+
parsed.sort((a, b) => b.lastActive - a.lastActive);
|
|
895
|
+
const toLoad = parsed.slice(0, MAX_SESSIONS);
|
|
896
|
+
|
|
897
|
+
// Track which files were loaded so #purgeExcessFiles can remove the rest
|
|
898
|
+
this.#loadedFiles = new Set(toLoad.map((p) => p.file));
|
|
899
|
+
|
|
900
|
+
for (const { data, lastActive } of toLoad) {
|
|
901
|
+
const id = data.id || data.taskId;
|
|
902
|
+
// Heal stale "active" sessions — if restored from disk and the last
|
|
903
|
+
// activity was more than idleThresholdMs ago, mark as completed.
|
|
904
|
+
let status = data.status || "completed";
|
|
905
|
+
let endedAt = data.endedAt || null;
|
|
906
|
+
if (status === "active" && lastActive > 0) {
|
|
907
|
+
const ageMs = Date.now() - lastActive;
|
|
908
|
+
if (ageMs > this.#idleThresholdMs) {
|
|
909
|
+
status = "completed";
|
|
910
|
+
endedAt = endedAt || lastActive;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
this.#sessions.set(id, {
|
|
915
|
+
id,
|
|
916
|
+
taskId: data.taskId || id,
|
|
917
|
+
taskTitle: data.metadata?.title || id,
|
|
918
|
+
type: data.type || "task",
|
|
919
|
+
status,
|
|
920
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
921
|
+
lastActiveAt: data.lastActiveAt || new Date().toISOString(),
|
|
922
|
+
startedAt: data.createdAt ? new Date(data.createdAt).getTime() : Date.now(),
|
|
923
|
+
endedAt,
|
|
924
|
+
messages: data.messages || [],
|
|
925
|
+
totalEvents: (data.messages || []).length,
|
|
926
|
+
turnCount: data.turnCount || 0,
|
|
927
|
+
lastActivityAt: lastActive || Date.now(),
|
|
928
|
+
metadata: data.metadata || {},
|
|
929
|
+
});
|
|
930
|
+
}
|
|
819
931
|
} catch {
|
|
820
932
|
// Directory read failed — proceed without disk data
|
|
821
933
|
}
|
|
822
934
|
}
|
|
823
935
|
|
|
936
|
+
/**
|
|
937
|
+
* Remove session files that were NOT loaded into memory (excess beyond MAX_SESSIONS).
|
|
938
|
+
* This runs once at startup to clean up historical bloat.
|
|
939
|
+
*/
|
|
940
|
+
#purgeExcessFiles() {
|
|
941
|
+
if (!this.#persistDir || !existsSync(this.#persistDir)) return;
|
|
942
|
+
try {
|
|
943
|
+
const files = readdirSync(this.#persistDir).filter((f) => f.endsWith(".json"));
|
|
944
|
+
let purged = 0;
|
|
945
|
+
for (const file of files) {
|
|
946
|
+
if (!this.#loadedFiles.has(file)) {
|
|
947
|
+
try {
|
|
948
|
+
unlinkSync(resolve(this.#persistDir, file));
|
|
949
|
+
purged++;
|
|
950
|
+
} catch {
|
|
951
|
+
// best-effort cleanup
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (purged > 0) {
|
|
956
|
+
console.log(`${TAG} purged ${purged} excess session file(s) from disk`);
|
|
957
|
+
}
|
|
958
|
+
// Free the reference — only needed once at startup
|
|
959
|
+
this.#loadedFiles.clear();
|
|
960
|
+
} catch {
|
|
961
|
+
// best-effort
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
824
965
|
/**
|
|
825
966
|
* Normalize a raw SDK event into a SessionMessage.
|
|
826
967
|
* Returns null for events that shouldn't be tracked (noise).
|