dsh-session-manager 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +563 -103
- package/lib/index.js +675 -190
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
* The compatibility adapter normalizes service access and list snapshots.
|
|
23
23
|
* Optional live-writer/coordinator internals are guarded before mutation.
|
|
24
24
|
*/
|
|
25
|
-
import { statSync } from "node:fs";
|
|
26
25
|
import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
27
26
|
import { basename, dirname, join } from "node:path";
|
|
28
27
|
import { randomBytes } from "node:crypto";
|
|
@@ -171,7 +170,7 @@ const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHea
|
|
|
171
170
|
|
|
172
171
|
export function apply(ctx) {
|
|
173
172
|
try {
|
|
174
|
-
const buildId = "dsh-session-manager v0.7.
|
|
173
|
+
const buildId = "dsh-session-manager v0.7.1 (workspace-archive)";
|
|
175
174
|
if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
|
|
176
175
|
else if (typeof console !== "undefined" && console?.info) console.info(buildId);
|
|
177
176
|
} catch (_) { /* ignore logging failures */ }
|
|
@@ -200,6 +199,9 @@ export function apply(ctx) {
|
|
|
200
199
|
}
|
|
201
200
|
return workspaceArchiveStore;
|
|
202
201
|
};
|
|
202
|
+
const safeGetAgent = (sessionId) => {
|
|
203
|
+
try { return ctx.agents?.get?.(sessionId); } catch { return undefined; }
|
|
204
|
+
};
|
|
203
205
|
const assertAnnotatable = id => {
|
|
204
206
|
const registry = adapter.workspaceRegistry;
|
|
205
207
|
if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
|
|
@@ -308,7 +310,7 @@ export function apply(ctx) {
|
|
|
308
310
|
|
|
309
311
|
if (!stopActivity) {
|
|
310
312
|
const liveSession = ctx.sessions?.get?.(sessionId);
|
|
311
|
-
const liveAgent =
|
|
313
|
+
const liveAgent = safeGetAgent(sessionId);
|
|
312
314
|
const activeTurn = liveSession?.activeTurn;
|
|
313
315
|
const agentRunning = liveAgent?.status === "running" || liveAgent?.busy === true;
|
|
314
316
|
if (activeTurn !== undefined || agentRunning) {
|
|
@@ -380,13 +382,7 @@ export function apply(ctx) {
|
|
|
380
382
|
const rawIds = Array.isArray(entity.record?.sessionIds)
|
|
381
383
|
? [...entity.record.sessionIds]
|
|
382
384
|
: [...entity.sessionIds];
|
|
383
|
-
|
|
384
|
-
if (selfUpdatedAt === null && entity.path) {
|
|
385
|
-
try {
|
|
386
|
-
const st = statSync(entity.path);
|
|
387
|
-
selfUpdatedAt = st.mtimeMs;
|
|
388
|
-
} catch (_) {}
|
|
389
|
-
}
|
|
385
|
+
const selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
|
|
390
386
|
return {
|
|
391
387
|
id: entity.id,
|
|
392
388
|
name: entity.title || entity.id,
|
|
@@ -469,6 +465,178 @@ ${rest}`, "utf8");
|
|
|
469
465
|
return undefined;
|
|
470
466
|
};
|
|
471
467
|
|
|
468
|
+
const MAX_BOOT_REFOLD_COUNT = 50;
|
|
469
|
+
const MAX_BOOT_REFOLD_MS = 2000;
|
|
470
|
+
const MAX_SINGLE_ARTIFACT_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
471
|
+
|
|
472
|
+
const getSessionSizeBytes = async (sessionId, fallbackPath) => {
|
|
473
|
+
if (typeof adapter.statSession === "function") {
|
|
474
|
+
try {
|
|
475
|
+
const stat = await adapter.statSession(sessionId);
|
|
476
|
+
const size = stat?.sizeBytes ?? stat?.size;
|
|
477
|
+
if (typeof size === "number") return size;
|
|
478
|
+
} catch { /* proceed */ }
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
const resolvedPath = fallbackPath ?? (await adapter.resolveSessionPath?.(sessionId));
|
|
482
|
+
if (typeof resolvedPath === "string") {
|
|
483
|
+
const fileStat = await lstat(resolvedPath);
|
|
484
|
+
return fileStat.size;
|
|
485
|
+
}
|
|
486
|
+
} catch { /* proceed */ }
|
|
487
|
+
return undefined;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Refold the projection cache for a session using properly decoded/migrated logical events.
|
|
492
|
+
* Prioritizes the official persistence interface (openSessionRead) to obtain logical events
|
|
493
|
+
* (expanding packed rows and handling generational migrations). Falls back to disk reading
|
|
494
|
+
* only when the official interface is unavailable, strictly verifying continuous logical seq
|
|
495
|
+
* and safe JSON parsing without swallowing corruption. (Issue #32)
|
|
496
|
+
*/
|
|
497
|
+
const refoldSessionProjection = async ({
|
|
498
|
+
sessionId,
|
|
499
|
+
header,
|
|
500
|
+
movedArtifactRest,
|
|
501
|
+
isThreeArgCache,
|
|
502
|
+
cache: cacheParam,
|
|
503
|
+
liveSession: liveSessionParam,
|
|
504
|
+
caller = "boot"
|
|
505
|
+
}) => {
|
|
506
|
+
const cache = cacheParam ?? (ctx.get("sessionProjectionCache") ?? adapter.projectionCache);
|
|
507
|
+
if (typeof cache?.coldSnapshot !== "function") return false;
|
|
508
|
+
const isThreeArg = isThreeArgCache ?? (typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3);
|
|
509
|
+
|
|
510
|
+
// Check file size budget across all reconstruction paths before any decoding/reading
|
|
511
|
+
let artifactSize = await getSessionSizeBytes(sessionId);
|
|
512
|
+
if (typeof movedArtifactRest === "string") {
|
|
513
|
+
const restBytes = Buffer.byteLength(movedArtifactRest);
|
|
514
|
+
artifactSize = artifactSize !== undefined ? Math.max(artifactSize, restBytes) : restBytes;
|
|
515
|
+
}
|
|
516
|
+
if (artifactSize !== undefined && artifactSize > MAX_SINGLE_ARTIFACT_BYTES) {
|
|
517
|
+
ctx.logger.info(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for large artifact "${sessionId}" (${artifactSize} bytes)`);
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let events = null;
|
|
522
|
+
let inheritedEventCount = undefined;
|
|
523
|
+
let effectiveHeader = header;
|
|
524
|
+
|
|
525
|
+
// Priority 1: Official persistence handle via adapter.openSessionRead
|
|
526
|
+
const hasOfficialReader = typeof adapter.persistence?.open === "function";
|
|
527
|
+
|
|
528
|
+
if (hasOfficialReader) {
|
|
529
|
+
let handle = null;
|
|
530
|
+
try {
|
|
531
|
+
handle = await adapter.openSessionRead(sessionId);
|
|
532
|
+
if (handle && typeof handle.read === "function") {
|
|
533
|
+
try {
|
|
534
|
+
const readResult = await handle.read(0);
|
|
535
|
+
const handleEvents = Array.isArray(readResult) ? readResult : readResult?.events;
|
|
536
|
+
if (Array.isArray(handleEvents)) {
|
|
537
|
+
// Verify all events are contiguous logical events
|
|
538
|
+
if (handleEvents.length === 0 || handleEvents.every((e, idx) => e && typeof e === "object" && e.seq === idx)) {
|
|
539
|
+
events = handleEvents;
|
|
540
|
+
inheritedEventCount = handle.inheritedEventCount;
|
|
541
|
+
if (handle.header && typeof handle.header === "object") {
|
|
542
|
+
effectiveHeader = { ...effectiveHeader, ...handle.header };
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official handle returned non-contiguous logical events`);
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official handle returned empty or invalid events`);
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
} catch (readErr) {
|
|
553
|
+
ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official read/migration failed: ${safeErrorMessage(readErr)}`);
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch (err) {
|
|
558
|
+
const msg = safeErrorMessage(err);
|
|
559
|
+
if (!msg.includes("unavailable")) {
|
|
560
|
+
ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official read/migration failed: ${msg}`);
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
} finally {
|
|
564
|
+
if (typeof handle?.close === "function") {
|
|
565
|
+
try { await handle.close(); } catch { /* best-effort */ }
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Priority 2: Fallback to reading disk artifact or moved rest ONLY when official API is unavailable or handle lacks read
|
|
571
|
+
if (events === null) {
|
|
572
|
+
let rest = movedArtifactRest;
|
|
573
|
+
if (typeof rest !== "string") {
|
|
574
|
+
const raw = await readSessionArtifact(sessionId, undefined, { storedHeader: effectiveHeader });
|
|
575
|
+
if (!raw || typeof raw.content !== "string") return false;
|
|
576
|
+
const newlineAt = raw.content.indexOf("\n");
|
|
577
|
+
rest = newlineAt === -1 ? "" : raw.content.slice(newlineAt + 1);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (typeof rest === "string" && rest.length > 0) {
|
|
581
|
+
const rawEvents = [];
|
|
582
|
+
let parseFailed = false;
|
|
583
|
+
for (const line of rest.split("\n")) {
|
|
584
|
+
if (!line.trim()) continue;
|
|
585
|
+
try {
|
|
586
|
+
rawEvents.push(JSON.parse(line));
|
|
587
|
+
} catch {
|
|
588
|
+
parseFailed = true;
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (parseFailed) {
|
|
594
|
+
ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": JSON parse error in artifact events`);
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Strictly check that all rows are contiguous logical events with valid seq starting at 0.
|
|
599
|
+
// Physical rows without seq (like packed tool-call-chunks) MUST NOT be passed to coldSnapshot.
|
|
600
|
+
const isContiguous = rawEvents.length === 0 || rawEvents.every((e, idx) => e && typeof e === "object" && e.seq === idx);
|
|
601
|
+
if (!isContiguous) {
|
|
602
|
+
ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": physical rows cannot be restored as logical events without official migration`);
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
events = rawEvents;
|
|
607
|
+
inheritedEventCount = await resolveInheritedEventCount({
|
|
608
|
+
sessionId,
|
|
609
|
+
header: effectiveHeader,
|
|
610
|
+
events,
|
|
611
|
+
persistence: adapter.persistence,
|
|
612
|
+
liveSession: liveSessionParam ?? adapter.getSession(sessionId)
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (events !== null) {
|
|
618
|
+
if (effectiveHeader?.isSeeded && inheritedEventCount === undefined) {
|
|
619
|
+
ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for seeded session "${sessionId}": cannot determine inheritedEventCount`);
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
if (isThreeArg) {
|
|
623
|
+
await cache.coldSnapshot(effectiveHeader, inheritedEventCount, events);
|
|
624
|
+
} else {
|
|
625
|
+
try {
|
|
626
|
+
await cache.coldSnapshot(effectiveHeader.id);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (String(error).includes("SessionLogOffset") && effectiveHeader) {
|
|
629
|
+
await cache.coldSnapshot(effectiveHeader, inheritedEventCount, events);
|
|
630
|
+
} else {
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
return false;
|
|
638
|
+
};
|
|
639
|
+
|
|
472
640
|
/**
|
|
473
641
|
* TRUE cross-workspace move. See the module docstring for the full design:
|
|
474
642
|
* re-home the stored cwd, migrate the artifact, then swap accounting.
|
|
@@ -587,17 +755,13 @@ ${rest}`, "utf8");
|
|
|
587
755
|
if (typeof oldPath !== "string" || newLocation === void 0) {
|
|
588
756
|
throw new Error("持久化后端无法定位会话工件路径");
|
|
589
757
|
}
|
|
590
|
-
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
// startup (`session generation filename identifies v4, but its
|
|
598
|
-
// header identifies v0`), breaking persistence.list() and crashing
|
|
599
|
-
// the workspace registry while it boots. (issue #18)
|
|
600
|
-
const expectedVersion = parseSessionGenerationFromFilename(basename(newPath));
|
|
758
|
+
// For live sessions, the active writer targets the latest generation returned by persistence.locate().
|
|
759
|
+
// For cold sessions, preserve the source artifact's filename and generation to avoid producing an
|
|
760
|
+
// artifact claiming a newer format version without its event contents being migrated. (Issue #32)
|
|
761
|
+
const targetFilename = liveSession !== void 0 ? basename(newLocation.path) : (raw.filename ?? basename(oldPath));
|
|
762
|
+
const targetDir = dirname(newLocation.path);
|
|
763
|
+
const newPath = join(targetDir, targetFilename);
|
|
764
|
+
const expectedVersion = parseSessionGenerationFromFilename(targetFilename);
|
|
601
765
|
if (typeof expectedVersion === "number") newHeader.version = expectedVersion;
|
|
602
766
|
const root = typeof dshHomePath === "function" ? dshHomePath("sessions") : undefined;
|
|
603
767
|
await assertSessionDirectory(root, dirname(oldPath), sessionId);
|
|
@@ -727,36 +891,15 @@ ${rest}`, "utf8");
|
|
|
727
891
|
if (cache !== void 0 && typeof cache.coldSnapshot === "function") {
|
|
728
892
|
try {
|
|
729
893
|
const isThreeArg = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
|
|
730
|
-
|
|
731
|
-
const events = [];
|
|
732
|
-
if (typeof movedArtifact?.rest === "string") {
|
|
733
|
-
for (const line of movedArtifact.rest.split("\n")) {
|
|
734
|
-
if (!line.trim()) continue;
|
|
735
|
-
try { events.push(JSON.parse(line)); } catch { /* ignore torn line */ }
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
const inheritedEventCount = await resolveInheritedEventCount({
|
|
894
|
+
await refoldSessionProjection({
|
|
739
895
|
sessionId,
|
|
740
|
-
header,
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
896
|
+
header: movedArtifact?.header,
|
|
897
|
+
movedArtifactRest: movedArtifact?.rest,
|
|
898
|
+
isThreeArgCache: isThreeArg,
|
|
899
|
+
cache,
|
|
900
|
+
liveSession,
|
|
901
|
+
caller: "post-move"
|
|
744
902
|
});
|
|
745
|
-
if (header?.isSeeded && inheritedEventCount === undefined) {
|
|
746
|
-
ctx.logger.warn(`session-manager: skipping post-move cache refresh for seeded session "${sessionId}": cannot determine inheritedEventCount`);
|
|
747
|
-
} else if (isThreeArg) {
|
|
748
|
-
await cache.coldSnapshot(header, inheritedEventCount, events);
|
|
749
|
-
} else {
|
|
750
|
-
try {
|
|
751
|
-
await cache.coldSnapshot(sessionId);
|
|
752
|
-
} catch (error) {
|
|
753
|
-
if (String(error).includes("SessionLogOffset") && header) {
|
|
754
|
-
await cache.coldSnapshot(header, inheritedEventCount, events);
|
|
755
|
-
} else {
|
|
756
|
-
throw error;
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
903
|
} catch (error) {
|
|
761
904
|
ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`);
|
|
762
905
|
}
|
|
@@ -1125,98 +1268,326 @@ ${rest}`, "utf8");
|
|
|
1125
1268
|
return undefined;
|
|
1126
1269
|
};
|
|
1127
1270
|
|
|
1128
|
-
const sessionDirOf = async (sessionId) => {
|
|
1271
|
+
const sessionDirOf = async (sessionId, storedHeader) => {
|
|
1129
1272
|
// Require a matching artifact header instead of deleting an arbitrary
|
|
1130
1273
|
// directory merely because its name matches user input.
|
|
1131
|
-
const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true });
|
|
1274
|
+
const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true, storedHeader });
|
|
1132
1275
|
return artifact === undefined ? undefined : dirname(artifact.path);
|
|
1133
1276
|
};
|
|
1134
1277
|
|
|
1135
1278
|
/**
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1279
|
+
* Enumerate all descendant subagent session headers for one or more root session IDs.
|
|
1280
|
+
* Recursively traverses subagent relations (origin === "subagent" && parentSession === parentId).
|
|
1281
|
+
* Normal fork sessions (origin !== "subagent") are strictly ignored.
|
|
1282
|
+
* Only returns verified local sessions with valid directories or live runtime state.
|
|
1138
1283
|
*/
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1284
|
+
const findDescendantSubagents = async (rootSessionIds) => {
|
|
1285
|
+
const roots = Array.from(new Set((rootSessionIds || []).filter(id => typeof id === "string" && id.trim() !== "")));
|
|
1286
|
+
if (roots.length === 0) return [];
|
|
1287
|
+
for (const id of roots) assertSessionId(id);
|
|
1288
|
+
|
|
1289
|
+
const headerMap = new Map();
|
|
1290
|
+
try {
|
|
1291
|
+
const cached = adapter.cachedSessionHeaders?.();
|
|
1292
|
+
if (Array.isArray(cached)) {
|
|
1293
|
+
for (const h of cached) if (h?.id) headerMap.set(h.id, h);
|
|
1294
|
+
}
|
|
1295
|
+
} catch { /* proceed */ }
|
|
1296
|
+
|
|
1297
|
+
try {
|
|
1298
|
+
const live = adapter.sessions?.list?.();
|
|
1299
|
+
if (Array.isArray(live)) {
|
|
1300
|
+
for (const s of live) if (s?.header?.id) headerMap.set(s.header.id, s.header);
|
|
1301
|
+
}
|
|
1302
|
+
} catch { /* proceed */ }
|
|
1303
|
+
|
|
1304
|
+
const scanResult = await scanSessionHeaders(adapter.persistence, adapter.homePath);
|
|
1305
|
+
if (!scanResult || !scanResult.complete || (Array.isArray(scanResult.errors) && scanResult.errors.length > 0)) {
|
|
1306
|
+
const errSummary = (scanResult?.errors || []).map(safeErrorMessage).join("; ") || "扫描未完成";
|
|
1307
|
+
throw new Error("扫描子智能体会话失败 (元数据不完整): " + errSummary);
|
|
1308
|
+
}
|
|
1309
|
+
if (Array.isArray(scanResult.headers)) {
|
|
1310
|
+
for (const h of scanResult.headers) {
|
|
1311
|
+
if (h?.id) {
|
|
1312
|
+
headerMap.set(h.id, { ...(headerMap.get(h.id) || {}), ...h });
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1159
1315
|
}
|
|
1160
1316
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1317
|
+
const allHeaders = [...headerMap.values()];
|
|
1318
|
+
const visited = new Set(roots);
|
|
1319
|
+
const descendants = [];
|
|
1320
|
+
let currentParents = new Set(roots);
|
|
1321
|
+
|
|
1322
|
+
while (currentParents.size > 0) {
|
|
1323
|
+
const nextParents = new Set();
|
|
1324
|
+
for (const header of allHeaders) {
|
|
1325
|
+
if (!header || !header.id || !header.parentSession) continue;
|
|
1326
|
+
if (header.origin === "subagent" && currentParents.has(header.parentSession)) {
|
|
1327
|
+
if (!visited.has(header.id)) {
|
|
1328
|
+
visited.add(header.id);
|
|
1329
|
+
nextParents.add(header.id);
|
|
1330
|
+
descendants.push(header);
|
|
1331
|
+
}
|
|
1176
1332
|
}
|
|
1177
|
-
}
|
|
1333
|
+
}
|
|
1334
|
+
currentParents = nextParents;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const verifiedDescendants = [];
|
|
1338
|
+
for (const sub of descendants) {
|
|
1339
|
+
assertSessionId(sub.id);
|
|
1340
|
+
const dir = await sessionDirOf(sub.id, sub);
|
|
1341
|
+
const isLive = ctx.sessions?.get?.(sub.id) !== undefined || safeGetAgent(sub.id) !== undefined;
|
|
1342
|
+
if (dir !== undefined || isLive) {
|
|
1343
|
+
verifiedDescendants.push({ ...sub, dir });
|
|
1344
|
+
}
|
|
1178
1345
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1346
|
+
|
|
1347
|
+
return verifiedDescendants;
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Safety check and activity stop for a session before deletion.
|
|
1352
|
+
* If running, attempts safe stop via cancel, dispose, and activity stop interfaces.
|
|
1353
|
+
* If it cannot be safely stopped, rejects deletion.
|
|
1354
|
+
*/
|
|
1355
|
+
const assertAndStopActiveSession = async (sessionId, isSubagent = false) => {
|
|
1356
|
+
assertSessionId(sessionId);
|
|
1357
|
+
const liveSession = ctx.sessions?.get?.(sessionId);
|
|
1358
|
+
const liveAgent = safeGetAgent(sessionId);
|
|
1359
|
+
|
|
1360
|
+
const activeTurn = liveSession?.activeTurn;
|
|
1361
|
+
const agentRunning = liveAgent?.status === "running" || liveAgent?.busy === true;
|
|
1362
|
+
let waterfallActivities = [];
|
|
1363
|
+
if (typeof ctx.waterfall === "function") {
|
|
1188
1364
|
try {
|
|
1189
|
-
ctx.
|
|
1190
|
-
} catch (
|
|
1365
|
+
waterfallActivities = await ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
throw new Error(`无法检查会话 '${sessionId}' 的活动状态: ${safeErrorMessage(err)}`);
|
|
1368
|
+
}
|
|
1191
1369
|
}
|
|
1192
1370
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
dir = await sessionDirOf(sessionId);
|
|
1371
|
+
const isActive = activeTurn !== undefined || agentRunning || (Array.isArray(waterfallActivities) && waterfallActivities.length > 0);
|
|
1372
|
+
if (!isActive) return;
|
|
1196
1373
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1374
|
+
const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
|
|
1375
|
+
const hasAgent = liveAgent !== void 0;
|
|
1376
|
+
const hasStopFn = typeof registry?.stopSessionActivity === "function" || typeof ctx.parallel === "function" || typeof liveAgent?.cancel === "function";
|
|
1200
1377
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
await rm(dir, { recursive: true, force: true });
|
|
1378
|
+
if (!hasStopFn) {
|
|
1379
|
+
const label = isSubagent ? `子智能体 '${sessionId}'` : `会话 '${sessionId}'`;
|
|
1380
|
+
throw new Error(`无法安全停止运行中的${label}: 没有可用的停止接口`);
|
|
1205
1381
|
}
|
|
1206
1382
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1383
|
+
if (hasAgent) {
|
|
1384
|
+
if (typeof liveAgent.cancel === "function") {
|
|
1385
|
+
liveAgent.cancel({ kind: "disposed" });
|
|
1386
|
+
}
|
|
1387
|
+
if (typeof liveAgent.scope?.dispose === "function") {
|
|
1388
|
+
await Promise.race([liveAgent.scope.dispose(), sleep(3000)]);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (typeof registry?.stopSessionActivity === "function") {
|
|
1393
|
+
try { await registry.stopSessionActivity(sessionId); }
|
|
1394
|
+
catch (err) {
|
|
1395
|
+
throw new Error(`停止会话 '${sessionId}' 活动失败: ${safeErrorMessage(err)}`);
|
|
1396
|
+
}
|
|
1397
|
+
} else if (typeof ctx.parallel === "function") {
|
|
1398
|
+
try { await ctx.parallel("workspace/session-stop", { sessionId }); }
|
|
1399
|
+
catch (err) {
|
|
1400
|
+
throw new Error(`停止会话 '${sessionId}' 活动失败: ${safeErrorMessage(err)}`);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const postAgent = safeGetAgent(sessionId);
|
|
1405
|
+
if (postAgent?.status === "running" || postAgent?.busy === true) {
|
|
1406
|
+
const label = isSubagent ? `子智能体 '${sessionId}'` : `会话 '${sessionId}'`;
|
|
1407
|
+
throw new Error(`${label} 仍在运行中,无法安全停止`);
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Delete one session end to end. Returns a summary of what was torn down.
|
|
1413
|
+
* Recursively cascades to all descendant subagents (origin === "subagent").
|
|
1414
|
+
* Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
|
|
1415
|
+
*/
|
|
1416
|
+
const deleteSession = async (sessionId, options = {}) => {
|
|
1417
|
+
assertSessionId(sessionId);
|
|
1418
|
+
|
|
1419
|
+
let deletedSubagentIds = [];
|
|
1420
|
+
const cleanupWarnings = [];
|
|
1421
|
+
|
|
1422
|
+
if (!options.skipSubagents) {
|
|
1423
|
+
// 1. Discover all descendant subagents (verifying scan completeness)
|
|
1424
|
+
const descendants = await findDescendantSubagents([sessionId]);
|
|
1425
|
+
|
|
1426
|
+
// 2. Pre-delete validation for BOTH main session and all descendants BEFORE any deletion
|
|
1427
|
+
// 2.1 Identity validation
|
|
1428
|
+
assertSessionId(sessionId);
|
|
1429
|
+
for (const sub of descendants) {
|
|
1430
|
+
assertSessionId(sub.id);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// 2.2 Directory validation (prevent traversal / outside directory issues)
|
|
1434
|
+
const mainDir = await sessionDirOf(sessionId);
|
|
1435
|
+
if (mainDir !== undefined) {
|
|
1436
|
+
await assertSessionDirectory(adapter.homePath("sessions"), mainDir, sessionId);
|
|
1437
|
+
}
|
|
1438
|
+
for (const sub of descendants) {
|
|
1439
|
+
const subDir = sub.dir ?? (await sessionDirOf(sub.id, sub));
|
|
1440
|
+
if (subDir !== undefined) {
|
|
1441
|
+
await assertSessionDirectory(adapter.homePath("sessions"), subDir, sub.id);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// 2.3 Activity validation and safe stop
|
|
1446
|
+
for (const sub of descendants) {
|
|
1447
|
+
await assertAndStopActiveSession(sub.id, true);
|
|
1448
|
+
}
|
|
1449
|
+
await assertAndStopActiveSession(sessionId, false);
|
|
1450
|
+
|
|
1451
|
+
// 3. Deletion order: descendant-first (reverse), parent-last
|
|
1452
|
+
const deletePlan = [...descendants].reverse();
|
|
1453
|
+
const remainingSubagentIds = descendants.map(d => d.id);
|
|
1454
|
+
|
|
1455
|
+
for (const sub of deletePlan) {
|
|
1456
|
+
try {
|
|
1457
|
+
const subResult = await mutate(sub.id, async () => {
|
|
1458
|
+
return await deleteSession(sub.id, { skipSubagents: true });
|
|
1459
|
+
});
|
|
1460
|
+
deletedSubagentIds.push(sub.id);
|
|
1461
|
+
const rIdx = remainingSubagentIds.indexOf(sub.id);
|
|
1462
|
+
if (rIdx !== -1) remainingSubagentIds.splice(rIdx, 1);
|
|
1463
|
+
|
|
1464
|
+
if (Array.isArray(subResult?.warnings)) {
|
|
1465
|
+
cleanupWarnings.push(...subResult.warnings);
|
|
1466
|
+
} else if (subResult?.warning) {
|
|
1467
|
+
cleanupWarnings.push(subResult.warning);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
try {
|
|
1471
|
+
await workspaceArchives().removeSession(sub.id);
|
|
1472
|
+
} catch (archErr) {
|
|
1473
|
+
cleanupWarnings.push(`子智能体 '${sub.id}' 归档记录清理失败: ${safeErrorMessage(archErr)}`);
|
|
1474
|
+
}
|
|
1475
|
+
} catch (subErr) {
|
|
1476
|
+
const err = new Error(`删除子智能体 '${sub.id}' 失败: ${safeErrorMessage(subErr)}`);
|
|
1477
|
+
err.deletedSubagentIds = [...deletedSubagentIds];
|
|
1478
|
+
err.remainingSessionIds = [sessionId, ...remainingSubagentIds];
|
|
1479
|
+
err.partialFailure = deletedSubagentIds.length > 0;
|
|
1480
|
+
if (subErr?.code) err.code = subErr.code;
|
|
1481
|
+
throw err;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
} else {
|
|
1485
|
+
await assertAndStopActiveSession(sessionId, true);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
try {
|
|
1489
|
+
// Resolve/validate before cancelling the agent or changing registry state.
|
|
1490
|
+
let dir = await sessionDirOf(sessionId);
|
|
1491
|
+
const session = ctx.sessions?.get?.(sessionId);
|
|
1492
|
+
let agent = safeGetAgent(sessionId);
|
|
1493
|
+
|
|
1494
|
+
if (agent !== void 0) {
|
|
1495
|
+
// Stop any running turn (disposed-kind suppresses re-wake).
|
|
1496
|
+
agent.cancel({ kind: "disposed" });
|
|
1497
|
+
// Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
|
|
1498
|
+
if (typeof agent.scope?.dispose === "function") {
|
|
1499
|
+
await Promise.race([agent.scope.dispose(), sleep(3000)]);
|
|
1500
|
+
}
|
|
1501
|
+
// Drop the zombie from the registry so a later session.create/open with
|
|
1502
|
+
// the same id cannot resurrect it.
|
|
1503
|
+
try {
|
|
1504
|
+
ctx.agents?.store?.delete?.(sessionId);
|
|
1505
|
+
} catch { /* best-effort */ }
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
let detached = false;
|
|
1509
|
+
if (session !== void 0) {
|
|
1510
|
+
// Flush buffered events to disk first so the retirement drain is a no-op.
|
|
1511
|
+
try {
|
|
1512
|
+
await ctx.sessions?.flush?.(session);
|
|
1513
|
+
} catch { /* best-effort */ }
|
|
1514
|
+
// Detach the session store entry: emits session/disposed, which the
|
|
1515
|
+
// persistence write-path answers with a final drain, and the API proxy
|
|
1516
|
+
// relays as host/session-removed so every connected client drops the row.
|
|
1517
|
+
try {
|
|
1518
|
+
const entry = ctx.sessions?.store?.get?.(sessionId);
|
|
1519
|
+
if (entry !== void 0 && typeof entry.detach === "function") {
|
|
1520
|
+
entry.detach();
|
|
1521
|
+
await sleep(200); // let the write-behind retirement settle
|
|
1522
|
+
detached = true;
|
|
1523
|
+
}
|
|
1524
|
+
} catch { /* best-effort */ }
|
|
1525
|
+
}
|
|
1526
|
+
// Some sessions have a stored artifact but no live store row (e.g.
|
|
1527
|
+
// already-scoped scope torn down, or never attached). Without a live
|
|
1528
|
+
// entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
|
|
1529
|
+
// entry.detach() never fires — so the apiproxy never queues a
|
|
1530
|
+
// host/session-removed frame, every connected client keeps showing the
|
|
1531
|
+
// session row, and the row collapses into the ungrouped section the
|
|
1532
|
+
// moment host/workspace-changed drops its workspace membership. Emit
|
|
1533
|
+
// session/disposed explicitly so every connected client drops the row.
|
|
1534
|
+
if (!detached) {
|
|
1535
|
+
try {
|
|
1536
|
+
ctx.emit("session/disposed", { id: sessionId });
|
|
1537
|
+
} catch (_) { /* best-effort */ }
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// A blank live session may have materialized its first artifact during
|
|
1541
|
+
// flush/retirement. Re-resolve after teardown rather than leaking that file.
|
|
1542
|
+
dir = await sessionDirOf(sessionId);
|
|
1543
|
+
|
|
1544
|
+
// Workspace accounting + archive-set membership.
|
|
1545
|
+
await detachFromWorkspaces(sessionId);
|
|
1546
|
+
await unarchiveSession(sessionId);
|
|
1547
|
+
|
|
1548
|
+
// Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
|
|
1549
|
+
if (dir !== void 0) {
|
|
1550
|
+
await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
|
|
1551
|
+
await rm(dir, { recursive: true, force: true });
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
try {
|
|
1555
|
+
await annotations().remove(sessionId);
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
const warn = "会话已删除,但独立标记清理失败: " + safeErrorMessage(error);
|
|
1558
|
+
cleanupWarnings.push(warn);
|
|
1559
|
+
ctx.logger.warn("session-manager: " + warn);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
try {
|
|
1563
|
+
await workspaceArchives().removeSession(sessionId);
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
const warn = "会话已删除,但工作区归档记录清理失败: " + safeErrorMessage(error);
|
|
1566
|
+
cleanupWarnings.push(warn);
|
|
1567
|
+
ctx.logger.warn("session-manager: " + warn);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const aggregatedWarning = cleanupWarnings.length > 0 ? cleanupWarnings.join("; ") : undefined;
|
|
1571
|
+
|
|
1572
|
+
return {
|
|
1573
|
+
ok: true,
|
|
1574
|
+
...(aggregatedWarning ? { warning: aggregatedWarning, warnings: cleanupWarnings } : {}),
|
|
1575
|
+
sessionId,
|
|
1576
|
+
wasLive: session !== void 0 || agent !== void 0,
|
|
1577
|
+
filesRemoved: dir !== void 0,
|
|
1578
|
+
deletedSubagents: deletedSubagentIds,
|
|
1579
|
+
subagentCount: deletedSubagentIds.length
|
|
1580
|
+
};
|
|
1581
|
+
} catch (parentErr) {
|
|
1582
|
+
const err = deletedSubagentIds.length > 0
|
|
1583
|
+
? new Error(`删除主会话 '${sessionId}' 失败: ${safeErrorMessage(parentErr)}`)
|
|
1584
|
+
: parentErr;
|
|
1585
|
+
err.deletedSubagentIds = [...deletedSubagentIds];
|
|
1586
|
+
err.remainingSessionIds = [sessionId];
|
|
1587
|
+
err.partialFailure = deletedSubagentIds.length > 0;
|
|
1588
|
+
if (parentErr?.code && err !== parentErr) err.code = parentErr.code;
|
|
1589
|
+
throw err;
|
|
1212
1590
|
}
|
|
1213
|
-
return {
|
|
1214
|
-
ok: true,
|
|
1215
|
-
...(warning ? { warning } : {}),
|
|
1216
|
-
sessionId,
|
|
1217
|
-
wasLive: session !== void 0 || agent !== void 0,
|
|
1218
|
-
filesRemoved: dir !== void 0
|
|
1219
|
-
};
|
|
1220
1591
|
};
|
|
1221
1592
|
|
|
1222
1593
|
/**
|
|
@@ -1230,11 +1601,20 @@ ${rest}`, "utf8");
|
|
|
1230
1601
|
* pass-through refold, and a stale one triggers the unrelated-identity
|
|
1231
1602
|
* full re-read path.
|
|
1232
1603
|
*/
|
|
1604
|
+
/**
|
|
1605
|
+
* On plugin boot:
|
|
1606
|
+
* 1. Reconcile workspace session membership using header metadata only.
|
|
1607
|
+
* This step is fast, safe, and completely separated from projection cache refolding. (Issue #32)
|
|
1608
|
+
* 2. Perform a budgeted background projection cache sweep for missing cache records,
|
|
1609
|
+
* prioritizing sessions in active workspaces, yielding to the event loop, and
|
|
1610
|
+
* safely skipping unmigrated physical rows or corrupt artifacts. (Issue #32)
|
|
1611
|
+
*/
|
|
1233
1612
|
ctx.effect(async () => {
|
|
1234
1613
|
const cache = adapter.projectionCache;
|
|
1235
1614
|
const persistence = adapter.persistence;
|
|
1236
1615
|
const registry = adapter.workspaceRegistry;
|
|
1237
|
-
if (typeof
|
|
1616
|
+
if (typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
|
|
1617
|
+
|
|
1238
1618
|
try {
|
|
1239
1619
|
// Remember only ids that existed before the async scan. Concurrently
|
|
1240
1620
|
// attached ids must never be removed using this older disk snapshot.
|
|
@@ -1246,55 +1626,10 @@ ${rest}`, "utf8");
|
|
|
1246
1626
|
}
|
|
1247
1627
|
const headers = scan.headers;
|
|
1248
1628
|
const validIds = new Set(headers.map(header => header.id));
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
for (const header of headers) {
|
|
1252
|
-
try {
|
|
1253
|
-
// Fast path: cachedSnapshot performs an in-memory lifecycle check (formatVersion, cwd, createdAt, isSeeded).
|
|
1254
|
-
// If the cache record is already present and matches, skip disk I/O and projection fold.
|
|
1255
|
-
if (typeof cache.cachedSnapshot === "function" && cache.cachedSnapshot(header) !== undefined) {
|
|
1256
|
-
continue;
|
|
1257
|
-
}
|
|
1258
|
-
const raw = await readSessionArtifact(header.id, undefined, { storedHeader: header });
|
|
1259
|
-
if (!raw || typeof raw.content !== "string") continue;
|
|
1260
|
-
const newlineAt = raw.content.indexOf("\n");
|
|
1261
|
-
const rest = newlineAt === -1 ? "" : raw.content.slice(newlineAt + 1);
|
|
1262
|
-
const events = [];
|
|
1263
|
-
if (rest) {
|
|
1264
|
-
for (const line of rest.split("\n")) {
|
|
1265
|
-
if (!line.trim()) continue;
|
|
1266
|
-
try { events.push(JSON.parse(line)); } catch { /* ignore torn line */ }
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
const inheritedEventCount = await resolveInheritedEventCount({
|
|
1270
|
-
sessionId: header.id,
|
|
1271
|
-
header,
|
|
1272
|
-
events,
|
|
1273
|
-
persistence,
|
|
1274
|
-
liveSession: adapter.getSession(header.id)
|
|
1275
|
-
});
|
|
1276
|
-
if (header.isSeeded && inheritedEventCount === undefined) {
|
|
1277
|
-
ctx.logger.warn('session-manager: skipping cache refold for seeded session "' + header.id + '": cannot determine inheritedEventCount');
|
|
1278
|
-
continue;
|
|
1279
|
-
}
|
|
1280
|
-
await cache.coldSnapshot(header, inheritedEventCount, events);
|
|
1281
|
-
} catch (error) {
|
|
1282
|
-
ctx.logger.warn('session-manager: post-boot cache refold failed for "' + header.id + '": ' + safeErrorMessage(error));
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
} else {
|
|
1286
|
-
for (const header of headers) {
|
|
1287
|
-
try { await cache.coldSnapshot(header.id); }
|
|
1288
|
-
catch (error) {
|
|
1289
|
-
if (String(error).includes("SessionLogOffset")) break;
|
|
1290
|
-
ctx.logger.warn('session-manager: post-boot cache refold failed: ' + safeErrorMessage(error));
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1629
|
+
|
|
1630
|
+
// Step 1: Immediate Workspace Member Reconciliation (isolated from projection rebuild)
|
|
1294
1631
|
let removed = 0;
|
|
1295
1632
|
let added = 0;
|
|
1296
|
-
// Read current state *inside* its queue, and publish an immutable copy.
|
|
1297
|
-
// A failed setState must not mutate the registry's live state object.
|
|
1298
1633
|
await registry.enqueueOperation(async () => {
|
|
1299
1634
|
const state = registry.requireState();
|
|
1300
1635
|
const pathById = new Map(registry.list().map(ws => [ws.id, ws.path]));
|
|
@@ -1316,9 +1651,68 @@ ${rest}`, "utf8");
|
|
|
1316
1651
|
});
|
|
1317
1652
|
if (removed || added) await registry.setState({ ...state, workspaces });
|
|
1318
1653
|
});
|
|
1319
|
-
if (removed || added)
|
|
1654
|
+
if (removed || added) {
|
|
1655
|
+
ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Step 2: Budgeted Background Projection Cache Rebuild
|
|
1659
|
+
if (typeof cache?.coldSnapshot !== "function") return;
|
|
1660
|
+
const isThreeArgCache = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
|
|
1661
|
+
|
|
1662
|
+
const assignedIds = new Set(registry.list().flatMap(ws => ws.record?.sessionIds ?? []));
|
|
1663
|
+
const candidates = headers.filter(header => assignedIds.has(header.id) || headers.length <= MAX_BOOT_REFOLD_COUNT);
|
|
1664
|
+
|
|
1665
|
+
const startTime = Date.now();
|
|
1666
|
+
let attemptedCount = 0;
|
|
1667
|
+
let refoldCount = 0;
|
|
1668
|
+
|
|
1669
|
+
for (const header of candidates) {
|
|
1670
|
+
if (attemptedCount >= MAX_BOOT_REFOLD_COUNT || Date.now() - startTime > MAX_BOOT_REFOLD_MS) {
|
|
1671
|
+
ctx.logger.info(`session-manager: boot projection cache refold budget reached (${attemptedCount} attempted, ${refoldCount} refolded, ${candidates.length - attemptedCount} remaining deferred)`);
|
|
1672
|
+
break;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
try {
|
|
1676
|
+
if (typeof cache.cachedSnapshot === "function" && cache.cachedSnapshot(header) !== undefined) {
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
attemptedCount++;
|
|
1681
|
+
|
|
1682
|
+
// Check file size budget before any I/O
|
|
1683
|
+
const size = await getSessionSizeBytes(header.id);
|
|
1684
|
+
if (typeof size === "number" && size > MAX_SINGLE_ARTIFACT_BYTES) {
|
|
1685
|
+
ctx.logger.info(`session-manager: skipping boot cache refold for large artifact "${header.id}" (${size} bytes)`);
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// Yield to event loop between sessions so other tasks/HTTP requests stay responsive
|
|
1690
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
1691
|
+
|
|
1692
|
+
if (!isThreeArgCache) {
|
|
1693
|
+
try {
|
|
1694
|
+
await cache.coldSnapshot(header.id);
|
|
1695
|
+
refoldCount++;
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
if (String(error).includes("SessionLogOffset")) break;
|
|
1698
|
+
ctx.logger.warn('session-manager: post-boot cache refold failed: ' + safeErrorMessage(error));
|
|
1699
|
+
}
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const ok = await refoldSessionProjection({
|
|
1704
|
+
sessionId: header.id,
|
|
1705
|
+
header,
|
|
1706
|
+
isThreeArgCache,
|
|
1707
|
+
cache
|
|
1708
|
+
});
|
|
1709
|
+
if (ok) refoldCount++;
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
ctx.logger.warn('session-manager: post-boot cache refold failed for "' + header.id + '": ' + safeErrorMessage(error));
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1320
1714
|
} catch (error) {
|
|
1321
|
-
ctx.logger.warn(
|
|
1715
|
+
ctx.logger.warn("session-manager: post-boot reconcile failed: " + safeErrorMessage(error));
|
|
1322
1716
|
}
|
|
1323
1717
|
}, "session-manager: post-boot re-fold stale projection cache rows");
|
|
1324
1718
|
|
|
@@ -1419,8 +1813,20 @@ ${rest}`, "utf8");
|
|
|
1419
1813
|
assertSessionId(sessionId);
|
|
1420
1814
|
let result;
|
|
1421
1815
|
if (path === "/delete") {
|
|
1422
|
-
|
|
1423
|
-
|
|
1816
|
+
try {
|
|
1817
|
+
result = await mutate(sessionId, () => deleteSession(sessionId));
|
|
1818
|
+
try { await workspaceArchives().removeSession(sessionId); } catch (_) {}
|
|
1819
|
+
} catch (delErr) {
|
|
1820
|
+
const status = delErr?.code === "bad-request" ? 400 : 500;
|
|
1821
|
+
return send(res, status, {
|
|
1822
|
+
ok: false,
|
|
1823
|
+
error: safeErrorMessage(delErr),
|
|
1824
|
+
...(typeof delErr?.code === "string" ? { code: delErr.code } : {}),
|
|
1825
|
+
deletedSubagents: Array.isArray(delErr?.deletedSubagentIds) ? delErr.deletedSubagentIds : [],
|
|
1826
|
+
remainingSessions: Array.isArray(delErr?.remainingSessionIds) ? delErr.remainingSessionIds : [sessionId],
|
|
1827
|
+
partialFailure: Boolean(delErr?.partialFailure)
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1424
1830
|
} else if (path === "/unarchive") {
|
|
1425
1831
|
await mutate(sessionId, () => unarchiveSession(sessionId));
|
|
1426
1832
|
result = { sessionId };
|
|
@@ -1492,21 +1898,66 @@ ${rest}`, "utf8");
|
|
|
1492
1898
|
if (!BATCH_ACTIONS.has(rawAction)) return send(res, 400, { ok: false, error: "action 不支持: " + rawAction, code: "bad-request" });
|
|
1493
1899
|
const items = [];
|
|
1494
1900
|
let success = 0, failed = 0, skipped = 0;
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1901
|
+
if (rawAction === "delete") {
|
|
1902
|
+
const deletedSet = new Set();
|
|
1903
|
+
for (const sessionId of ids) {
|
|
1904
|
+
if (deletedSet.has(sessionId)) {
|
|
1905
|
+
items.push({ sessionId, status: "success", cascaded: true, subagentCount: 0 });
|
|
1906
|
+
success++;
|
|
1907
|
+
continue;
|
|
1908
|
+
}
|
|
1909
|
+
try {
|
|
1910
|
+
const res = await mutate(sessionId, () => deleteSession(sessionId));
|
|
1911
|
+
deletedSet.add(sessionId);
|
|
1912
|
+
if (Array.isArray(res?.deletedSubagents)) {
|
|
1913
|
+
for (const subId of res.deletedSubagents) {
|
|
1914
|
+
deletedSet.add(subId);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
items.push({
|
|
1918
|
+
sessionId,
|
|
1919
|
+
status: "success",
|
|
1920
|
+
deletedSubagents: res?.deletedSubagents || [],
|
|
1921
|
+
subagentCount: res?.subagentCount || 0,
|
|
1922
|
+
warnings: res?.warnings || (res?.warning ? [res.warning] : [])
|
|
1923
|
+
});
|
|
1924
|
+
success++;
|
|
1925
|
+
} catch (error) {
|
|
1926
|
+
const code = error && typeof error.code === "string" ? error.code : undefined;
|
|
1927
|
+
if (Array.isArray(error?.deletedSubagentIds)) {
|
|
1928
|
+
for (const subId of error.deletedSubagentIds) {
|
|
1929
|
+
deletedSet.add(subId);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
items.push({
|
|
1933
|
+
sessionId,
|
|
1934
|
+
status: "failed",
|
|
1935
|
+
code,
|
|
1936
|
+
error: safeErrorMessage(error),
|
|
1937
|
+
deletedSubagents: error?.deletedSubagentIds || [],
|
|
1938
|
+
remainingSessions: error?.remainingSessionIds || [sessionId],
|
|
1939
|
+
partialFailure: Boolean(error?.partialFailure)
|
|
1940
|
+
});
|
|
1507
1941
|
failed++;
|
|
1508
1942
|
}
|
|
1509
1943
|
}
|
|
1944
|
+
} else {
|
|
1945
|
+
for (const sessionId of ids) {
|
|
1946
|
+
try {
|
|
1947
|
+
await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
|
|
1948
|
+
items.push({ sessionId, status: "success" });
|
|
1949
|
+
success++;
|
|
1950
|
+
} catch (error) {
|
|
1951
|
+
const code = error && typeof error.code === "string" ? error.code : undefined;
|
|
1952
|
+
if (code === "subagent-skip" || code === "blank-skip") {
|
|
1953
|
+
items.push({ sessionId, status: "skipped", reason: code });
|
|
1954
|
+
skipped++;
|
|
1955
|
+
} else {
|
|
1956
|
+
items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
|
|
1957
|
+
failed++;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1510
1961
|
}
|
|
1511
1962
|
return send(res, 200, { ok: true, result: {
|
|
1512
1963
|
summary: { total: ids.length, success, failed, skipped },
|
|
@@ -1514,14 +1965,41 @@ ${rest}`, "utf8");
|
|
|
1514
1965
|
aborted: false,
|
|
1515
1966
|
} });
|
|
1516
1967
|
}
|
|
1968
|
+
if (req.method === "GET" && path === "/delete-preview") {
|
|
1969
|
+
const rawId = url.searchParams.get("sessionId") || "";
|
|
1970
|
+
const sessionId = rawId.trim();
|
|
1971
|
+
if (sessionId) assertSessionId(sessionId);
|
|
1972
|
+
const rawIds = url.searchParams.get("sessionIds");
|
|
1973
|
+
let sessionIds = sessionId ? [sessionId] : [];
|
|
1974
|
+
if (rawIds) {
|
|
1975
|
+
const split = rawIds.split(",").map(s => s.trim()).filter(Boolean);
|
|
1976
|
+
for (const id of split) assertSessionId(id);
|
|
1977
|
+
sessionIds = Array.from(new Set([...sessionIds, ...split]));
|
|
1978
|
+
}
|
|
1979
|
+
if (sessionIds.length === 0) {
|
|
1980
|
+
return send(res, 400, { ok: false, error: "sessionId 或 sessionIds 必填" });
|
|
1981
|
+
}
|
|
1982
|
+
try {
|
|
1983
|
+
const descendants = await findDescendantSubagents(sessionIds);
|
|
1984
|
+
return send(res, 200, {
|
|
1985
|
+
ok: true,
|
|
1986
|
+
result: {
|
|
1987
|
+
subagentCount: descendants.length,
|
|
1988
|
+
subagents: descendants.map(d => ({ id: d.id, parentSession: d.parentSession }))
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
} catch (err) {
|
|
1992
|
+
return send(res, 500, { ok: false, error: safeErrorMessage(err) });
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1517
1995
|
if (req.method === "GET" && path === "/workspaces") {
|
|
1518
1996
|
const cachedHeaders = adapter.cachedSessionHeaders();
|
|
1519
1997
|
const sessionCreatedAt = Object.fromEntries(cachedHeaders
|
|
1520
1998
|
.filter(header => typeof header.createdAt === "number" || typeof header.createdAt === "string")
|
|
1521
1999
|
.map(header => [header.id, header.createdAt]));
|
|
1522
2000
|
const sessionUpdatedAt = Object.fromEntries(cachedHeaders
|
|
1523
|
-
.filter(header => header && (typeof header.updatedAt === "number" || typeof header.updatedAt === "string" || typeof header.lastModified === "number"
|
|
1524
|
-
.map(header => [header.id, header.updatedAt ?? header.lastModified
|
|
2001
|
+
.filter(header => header && (typeof header.updatedAt === "number" || typeof header.updatedAt === "string" || typeof header.lastModified === "number"))
|
|
2002
|
+
.map(header => [header.id, header.updatedAt ?? header.lastModified]));
|
|
1525
2003
|
let workspaceArchivesMap = {};
|
|
1526
2004
|
try {
|
|
1527
2005
|
workspaceArchivesMap = await workspaceArchives().getAll();
|
|
@@ -1535,7 +2013,7 @@ ${rest}`, "utf8");
|
|
|
1535
2013
|
return send(res, 200, { ok: true, result });
|
|
1536
2014
|
}
|
|
1537
2015
|
if (req.method === "GET" && path === "/update/check") {
|
|
1538
|
-
const currentVersion = adapter.pluginVersion() || "0.7.
|
|
2016
|
+
const currentVersion = adapter.pluginVersion() || "0.7.1";
|
|
1539
2017
|
let latestVersion = currentVersion;
|
|
1540
2018
|
let hasUpdate = false;
|
|
1541
2019
|
try {
|
|
@@ -1620,7 +2098,14 @@ ${rest}`, "utf8");
|
|
|
1620
2098
|
const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
|
|
1621
2099
|
: error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
|
|
1622
2100
|
: error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503 : 500;
|
|
1623
|
-
return send(res, status, {
|
|
2101
|
+
return send(res, status, {
|
|
2102
|
+
ok: false,
|
|
2103
|
+
error: safeErrorMessage(error),
|
|
2104
|
+
...(typeof error?.code === "string" ? { code: error.code } : {}),
|
|
2105
|
+
...(Array.isArray(error?.deletedSubagentIds) ? { deletedSubagents: error.deletedSubagentIds } : {}),
|
|
2106
|
+
...(Array.isArray(error?.remainingSessionIds) ? { remainingSessions: error.remainingSessionIds } : {}),
|
|
2107
|
+
...(typeof error?.partialFailure === "boolean" ? { partialFailure: error.partialFailure } : {})
|
|
2108
|
+
});
|
|
1624
2109
|
}
|
|
1625
2110
|
}
|
|
1626
2111
|
}), "session-manager: http api");
|