clay-server 4.3.1-beta.1 → 4.3.1-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/project-connection.js +1 -1
- package/lib/project-pair-lifecycle.js +9 -0
- package/lib/project-session-delete.js +101 -0
- package/lib/project-session-pair-prompt.js +5 -0
- package/lib/project-sessions.js +8 -44
- package/lib/project-worker-proposal-runner.js +2 -0
- package/lib/project-worker-proposal.js +1 -1
- package/lib/project.js +1 -0
- package/lib/public/css/pane.css +3 -5
- package/lib/public/modules/session-delete-scope.js +96 -0
- package/lib/public/modules/sidebar-sessions.js +10 -4
- package/lib/session-pair-mcp-server.js +2 -2
- package/lib/session-provenance.js +16 -0
- package/lib/session-split-group-cleanup.js +113 -0
- package/lib/session-split-group-persistence.js +15 -2
- package/lib/session-split-group-recovery.js +87 -0
- package/lib/session-split-groups.js +16 -29
- package/lib/sessions.js +29 -7
- package/package.json +1 -1
|
@@ -244,7 +244,7 @@ function attachConnection(ctx) {
|
|
|
244
244
|
// so vendor badges showed Claude until the next broadcast).
|
|
245
245
|
sendTo(ws, {
|
|
246
246
|
type: "session_list",
|
|
247
|
-
sessions: sm.mapSessionsForClient(allSessions),
|
|
247
|
+
sessions: sm.mapSessionsForClient(allSessions, undefined, undefined, wsUser ? wsUser.id : null),
|
|
248
248
|
});
|
|
249
249
|
if (_splitGroups) _splitGroups.sendConnectionState(ws);
|
|
250
250
|
|
|
@@ -162,6 +162,15 @@ function attachPairLifecycle(ctx) {
|
|
|
162
162
|
return null;
|
|
163
163
|
}
|
|
164
164
|
function partnerStatus(caller, args) {
|
|
165
|
+
var group = caller && store.groupForMember(caller.localId);
|
|
166
|
+
var roles = splitRoles.normalizePair(group && group.pair, group && group.members);
|
|
167
|
+
if ((!args || args.workerId == null) && roles.ok && roles.kind !== "adhoc" && roles.workerIds.length > 1) {
|
|
168
|
+
var workers = roles.workerIds.map(function (workerId) {
|
|
169
|
+
return partnerStatus(caller, { workerId: workerId });
|
|
170
|
+
});
|
|
171
|
+
return { driverId: caller.localId, workerIds: roles.workerIds.slice(), workers: workers,
|
|
172
|
+
instruction: "Pass the selected workerId to targeted partner tools. Call partner_status without workerId to rediscover current Workers." };
|
|
173
|
+
}
|
|
165
174
|
var resolved = resolveDriverPair(caller, args, { readOnly: true });
|
|
166
175
|
var worker = resolved.worker;
|
|
167
176
|
var blocked = turnControl.blockedReason(caller) || replaceBlockedReason(worker);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
var sessionProvenance = require("./session-provenance");
|
|
2
|
+
|
|
3
|
+
// Explicit human deletion of a Driver also removes every Split Worker
|
|
4
|
+
// generation it durably owns. Ownership comes only from the provenance
|
|
5
|
+
// hierarchy: a unique parent origin, the same owner, and a parent that is not
|
|
6
|
+
// itself a Worker. Ambiguous or cross-owner links are never expanded. Close,
|
|
7
|
+
// dissolve, and replacement do not come through here and keep history, and
|
|
8
|
+
// deleting a Worker never removes its Driver or siblings.
|
|
9
|
+
|
|
10
|
+
function resolveDeletionScope(sessions, requested, canDelete) {
|
|
11
|
+
var workersByDriver = sessionProvenance.ownedWorkersByDriver(Array.from(sessions.values()));
|
|
12
|
+
var ids = [];
|
|
13
|
+
var cascadedIds = [];
|
|
14
|
+
var seen = {};
|
|
15
|
+
function add(session, cascaded) {
|
|
16
|
+
if (seen[session.localId]) return;
|
|
17
|
+
seen[session.localId] = true;
|
|
18
|
+
ids.push(session.localId);
|
|
19
|
+
if (cascaded) cascadedIds.push(session.localId);
|
|
20
|
+
}
|
|
21
|
+
for (var j = 0; j < requested.length; j++) {
|
|
22
|
+
var target = requested[j];
|
|
23
|
+
add(target, false);
|
|
24
|
+
if (sessionProvenance.isWorker(target)) continue;
|
|
25
|
+
var owned = workersByDriver[target.localId] || [];
|
|
26
|
+
for (var k = 0; k < owned.length; k++) {
|
|
27
|
+
// The whole scope is authorized before anything is removed.
|
|
28
|
+
if (!canDelete(owned[k])) {
|
|
29
|
+
return { ok: false, error: "You cannot delete this Driver because one of its Split Worker sessions is not accessible to you" };
|
|
30
|
+
}
|
|
31
|
+
add(owned[k], true);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, ids: ids, cascadedIds: cascadedIds };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function attachSessionDelete(ctx) {
|
|
38
|
+
var sm = ctx.sm;
|
|
39
|
+
var usersModule = ctx.usersModule;
|
|
40
|
+
var getProjectAccess = ctx.getProjectAccess || function () { return { visibility: "public" }; };
|
|
41
|
+
|
|
42
|
+
function hasDeletePermission(ws) {
|
|
43
|
+
if (!ws._clayUser) return true;
|
|
44
|
+
return !!usersModule.getEffectivePermissions(ws._clayUser, ctx.osUsers).sessionDelete;
|
|
45
|
+
}
|
|
46
|
+
function accessCheck(ws, access) {
|
|
47
|
+
return function (session) {
|
|
48
|
+
if (!usersModule.isMultiUser()) return true;
|
|
49
|
+
return !!(ws._clayUser && usersModule.canAccessSession(ws._clayUser.id, session, access));
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function releaseRuntime(ids) {
|
|
53
|
+
for (var i = 0; i < ids.length; i++) {
|
|
54
|
+
var session = sm.sessions.get(ids[i]);
|
|
55
|
+
if (!session) continue;
|
|
56
|
+
// TUI sessions: reap the PTY and stop title watchers before the
|
|
57
|
+
// records are wiped so no `claude` process is left orphaned.
|
|
58
|
+
if (ctx.tm && session.mode === "tui" && typeof session.terminalId === "number") {
|
|
59
|
+
try { ctx.tm.close(session.terminalId); } catch (e) {}
|
|
60
|
+
}
|
|
61
|
+
ctx.stopTitleWatcher(session);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function refuse(ws, text) {
|
|
65
|
+
ctx.sendTo(ws, { type: "error", text: text });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function deleteSession(ws, msg) {
|
|
69
|
+
if (!hasDeletePermission(ws)) return refuse(ws, "You do not have permission to delete sessions");
|
|
70
|
+
if (!msg.id || !sm.sessions.has(msg.id)) return;
|
|
71
|
+
var target = sm.sessions.get(msg.id);
|
|
72
|
+
var canDelete = accessCheck(ws, getProjectAccess());
|
|
73
|
+
if (!canDelete(target)) return;
|
|
74
|
+
var scope = resolveDeletionScope(sm.sessions, [target], canDelete);
|
|
75
|
+
if (!scope.ok) return refuse(ws, scope.error);
|
|
76
|
+
releaseRuntime(scope.ids);
|
|
77
|
+
if (scope.ids.length === 1) sm.deleteSession(target.localId, ws);
|
|
78
|
+
else sm.deleteSessionsBulk(scope.ids, ws);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bulkDeleteSessions(ws, msg) {
|
|
82
|
+
if (!Array.isArray(msg.sessionIds) || msg.sessionIds.length === 0) return;
|
|
83
|
+
if (!hasDeletePermission(ws)) return refuse(ws, "You do not have permission to delete sessions");
|
|
84
|
+
var canDelete = accessCheck(ws, { visibility: "public" });
|
|
85
|
+
var requested = [];
|
|
86
|
+
for (var i = 0; i < msg.sessionIds.length; i++) {
|
|
87
|
+
var id = msg.sessionIds[i];
|
|
88
|
+
var target = typeof id === "number" ? sm.sessions.get(id) : null;
|
|
89
|
+
if (target && canDelete(target)) requested.push(target);
|
|
90
|
+
}
|
|
91
|
+
if (requested.length === 0) return;
|
|
92
|
+
var scope = resolveDeletionScope(sm.sessions, requested, canDelete);
|
|
93
|
+
if (!scope.ok) return refuse(ws, scope.error);
|
|
94
|
+
releaseRuntime(scope.ids);
|
|
95
|
+
sm.deleteSessionsBulk(scope.ids, ws);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { deleteSession: deleteSession, bulkDeleteSessions: bulkDeleteSessions };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { attachSessionDelete: attachSessionDelete, resolveDeletionScope: resolveDeletionScope };
|
|
@@ -13,6 +13,11 @@ function attachSessionPairPrompt(ctx) {
|
|
|
13
13
|
var result = "";
|
|
14
14
|
if (group && group.pair && group.pair.driverId === session.localId) {
|
|
15
15
|
result = prompts.DRIVER_CORE + " " + ctx.workerProposal().getSystemPrompt(session);
|
|
16
|
+
if (roles && roles.driver === session) {
|
|
17
|
+
result += " Current configured Worker handles: " + roles.workers.map(function (worker) {
|
|
18
|
+
return "workerId=" + worker.localId + " (" + (worker.title || "Worker") + ")";
|
|
19
|
+
}).join(", ") + ". Call partner_status without workerId to rediscover current Workers; pass workerId for targeted operations.";
|
|
20
|
+
}
|
|
16
21
|
if (driverOrchestration.isHighTierDriverSession(session, ctx.sm)) result += " " + prompts.DRIVER_DELEGATION;
|
|
17
22
|
} else if (!group && !ctx.isMate && driverEligibility.isEligibleDriverSession(session, ctx.sm) &&
|
|
18
23
|
driverOrchestration.isHighTierDriverSession(session, ctx.sm)) {
|
package/lib/project-sessions.js
CHANGED
|
@@ -7,6 +7,7 @@ var updateSnooze = require("./update-snooze");
|
|
|
7
7
|
var yoke = require("./yoke");
|
|
8
8
|
var sessionVisibility = require("./session-visibility");
|
|
9
9
|
var contextUsageModule = require("./context-usage");
|
|
10
|
+
var sessionDeleteModule = require("./project-session-delete");
|
|
10
11
|
|
|
11
12
|
function vendorSupportsTui(vendor) {
|
|
12
13
|
var info = yoke.getVendorInfo(vendor);
|
|
@@ -337,6 +338,11 @@ function attachSessions(ctx) {
|
|
|
337
338
|
}
|
|
338
339
|
}
|
|
339
340
|
|
|
341
|
+
var sessionDelete = sessionDeleteModule.attachSessionDelete({
|
|
342
|
+
sm: sm, usersModule: usersModule, osUsers: osUsers, tm: tm, sendTo: sendTo,
|
|
343
|
+
getProjectAccess: getProjectAccess, stopTitleWatcher: stopTitleWatcher,
|
|
344
|
+
});
|
|
345
|
+
|
|
340
346
|
// Kick off watchers for any TUI sessions already loaded from disk so
|
|
341
347
|
// their titles refresh as soon as Claude Code rewrites the jsonl, even
|
|
342
348
|
// before the user clicks them.
|
|
@@ -779,31 +785,7 @@ function attachSessions(ctx) {
|
|
|
779
785
|
}
|
|
780
786
|
|
|
781
787
|
if (msg.type === "bulk_delete_sessions") {
|
|
782
|
-
|
|
783
|
-
var deletableIds = [];
|
|
784
|
-
for (var di = 0; di < msg.sessionIds.length; di++) {
|
|
785
|
-
var bulkId = msg.sessionIds[di];
|
|
786
|
-
if (typeof bulkId !== "number") continue;
|
|
787
|
-
var bulkTarget = sm.sessions.get(bulkId);
|
|
788
|
-
if (!bulkTarget) continue;
|
|
789
|
-
if (usersModule.isMultiUser() && ws._clayUser) {
|
|
790
|
-
if (!usersModule.canAccessSession(ws._clayUser.id, bulkTarget, { visibility: "public" })) continue;
|
|
791
|
-
}
|
|
792
|
-
deletableIds.push(bulkId);
|
|
793
|
-
}
|
|
794
|
-
if (deletableIds.length > 0) {
|
|
795
|
-
// TUI sessions: kill their PTYs and stop title watchers before the
|
|
796
|
-
// records are wiped.
|
|
797
|
-
for (var bdi = 0; bdi < deletableIds.length; bdi++) {
|
|
798
|
-
var bdTarget = sm.sessions.get(deletableIds[bdi]);
|
|
799
|
-
if (!bdTarget) continue;
|
|
800
|
-
if (tm && bdTarget.mode === "tui" && typeof bdTarget.terminalId === "number") {
|
|
801
|
-
try { tm.close(bdTarget.terminalId); } catch (e) {}
|
|
802
|
-
}
|
|
803
|
-
stopTitleWatcher(bdTarget);
|
|
804
|
-
}
|
|
805
|
-
sm.deleteSessionsBulk(deletableIds, ws);
|
|
806
|
-
}
|
|
788
|
+
sessionDelete.bulkDeleteSessions(ws, msg);
|
|
807
789
|
return true;
|
|
808
790
|
}
|
|
809
791
|
|
|
@@ -978,25 +960,7 @@ function attachSessions(ctx) {
|
|
|
978
960
|
}
|
|
979
961
|
|
|
980
962
|
if (msg.type === "delete_session") {
|
|
981
|
-
|
|
982
|
-
var sdPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
|
|
983
|
-
if (!sdPerms.sessionDelete) {
|
|
984
|
-
sendTo(ws, { type: "error", text: "You do not have permission to delete sessions" });
|
|
985
|
-
return true;
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
if (msg.id && sm.sessions.has(msg.id)) {
|
|
989
|
-
// TUI session: kill the underlying PTY before deleting the session
|
|
990
|
-
// record so the `claude` process is reaped and not left orphaned.
|
|
991
|
-
var dsTarget = sm.sessions.get(msg.id);
|
|
992
|
-
if (usersModule.isMultiUser() && (!ws._clayUser ||
|
|
993
|
-
!usersModule.canAccessSession(ws._clayUser.id, dsTarget, getProjectAccess()))) return true;
|
|
994
|
-
if (dsTarget && dsTarget.mode === "tui" && typeof dsTarget.terminalId === "number" && tm) {
|
|
995
|
-
try { tm.close(dsTarget.terminalId); } catch (e) {}
|
|
996
|
-
}
|
|
997
|
-
if (dsTarget) stopTitleWatcher(dsTarget);
|
|
998
|
-
sm.deleteSession(msg.id, ws);
|
|
999
|
-
}
|
|
963
|
+
sessionDelete.deleteSession(ws, msg);
|
|
1000
964
|
return true;
|
|
1001
965
|
}
|
|
1002
966
|
|
|
@@ -36,6 +36,8 @@ function attachProposalRunner(ctx) {
|
|
|
36
36
|
} else {
|
|
37
37
|
followup = "[Split Worker execution failed]\nThe existing Split Worker may still be reusable. Inspect partner_status and send a narrower follow-up with send_to_partner when the Worker and pair still exist; do not replace it merely because its task or transport failed. Retry replacement only when the replacement transaction reports that no new Worker was created and the failure is explicitly safe to retry.\n\n" + (result.error || result.response || "Unknown Split Worker error.");
|
|
38
38
|
}
|
|
39
|
+
followup += "\n\nWorker handle for this execution: workerId=" + proposal.workerId +
|
|
40
|
+
". Use this workerId for follow-up operations while it remains configured. Call partner_status without workerId to discover current Worker handles after replacement or reconnect.";
|
|
39
41
|
await resumeDriver(session, followup);
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -463,7 +463,7 @@ function attachWorkerProposal(ctx) {
|
|
|
463
463
|
|
|
464
464
|
function getSystemPrompt(session) {
|
|
465
465
|
if (creationProposal.canMountProposal(session) && !isEligible(session)) {
|
|
466
|
-
return "This Driver may configure one additional Split Worker through propose_worker. With two Workers, pass workerId to every targeted partner, queue, interrupt, replacement, close, permission, and status operation. Give each Worker independent file ownership and bounded work. Do not assign simultaneous overlapping file edits unless the work is explicitly separated, such as by distinct paths or worktrees.";
|
|
466
|
+
return "This Driver may configure one additional Split Worker through propose_worker. With two Workers, pass workerId to every targeted partner, queue, interrupt, replacement, close, permission, and targeted status operation. Call partner_status without workerId to discover current Worker handles. Give each Worker independent file ownership and bounded work. Do not assign simultaneous overlapping file edits unless the work is explicitly separated, such as by distinct paths or worktrees.";
|
|
467
467
|
}
|
|
468
468
|
if (!isEligible(session) || !driverOrchestration.isHighTierDriverSession(session, sm)) return "";
|
|
469
469
|
return "For implementation-heavy work, use propose_worker before substantial execution. Work is implementation-heavy when it spans multiple modules, crosses client/server/data boundaries, requires a migration, or contains independent investigation and execution. Do not skip delegation merely because you can implement it yourself; preserve the Driver for decomposition, runtime selection, judgment, review, and integration. The user's explicit request to work directly takes precedence, and work that must remain sequential in this session stays here. Recommend an exact available vendor, model, and effort, and give a concise recommendationRationale explaining why all three fit the task. Clay always records and shows the runtime configuration card. In full-access mode Clay may auto-accept that exact validated recommendation; otherwise it waits for the user's explicit choice. After posting, end the turn. If accepted, Clay creates the exact Driver/Split Worker pair and delegates the proposed task once. If declined, continue in this Driver session. Do not call send_to_partner while unpaired.";
|
package/lib/project.js
CHANGED
|
@@ -2568,6 +2568,7 @@ function createProjectContext(opts) {
|
|
|
2568
2568
|
function destroy() {
|
|
2569
2569
|
if (_scheduledMessages) _scheduledMessages.shutdown();
|
|
2570
2570
|
if (_autonomousRun) _autonomousRun.shutdown();
|
|
2571
|
+
_splitGroups.shutdown();
|
|
2571
2572
|
_loop.stopTimer();
|
|
2572
2573
|
_email.destroy();
|
|
2573
2574
|
stopFileWatch();
|
package/lib/public/css/pane.css
CHANGED
|
@@ -73,13 +73,11 @@ body.pane-mode #input-area {
|
|
|
73
73
|
display: flex;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/*
|
|
77
|
-
|
|
78
|
-
equal-height execution surfaces on the right. Legacy/ad-hoc two-pane
|
|
79
|
-
splits keep the ordinary equal columns below. */
|
|
76
|
+
/* Keep the same equal columns when adding a second Worker; only the
|
|
77
|
+
Worker column divides into two equal-height execution surfaces. */
|
|
80
78
|
#split-host:has(.split-pane-role-worker:nth-of-type(3)) {
|
|
81
79
|
display: grid;
|
|
82
|
-
grid-template-columns:
|
|
80
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
83
81
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
|
84
82
|
gap: 1px;
|
|
85
83
|
background: var(--border-subtle);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Describes the server deletion scope so confirmations never understate it.
|
|
2
|
+
// Deleting a Driver also deletes every Split Worker session it owns,
|
|
3
|
+
// including hidden sessions and previous generations. The server sends that
|
|
4
|
+
// full total as `ownedWorkerCount`; the visible list alone can miss hidden
|
|
5
|
+
// Workers, so it never stands in for the total. When the total is unknown,
|
|
6
|
+
// the wording says so and a dialog is required. Deleting a Worker affects only
|
|
7
|
+
// that Worker.
|
|
8
|
+
|
|
9
|
+
var FULL_SCOPE = "including hidden sessions and previous generations";
|
|
10
|
+
|
|
11
|
+
export function ownedWorkerIds(driverId, sessions) {
|
|
12
|
+
var list = Array.isArray(sessions) ? sessions : [];
|
|
13
|
+
var ids = [];
|
|
14
|
+
for (var i = 0; i < list.length; i++) {
|
|
15
|
+
var session = list[i];
|
|
16
|
+
if (session && session.sessionRole === "worker" && session.parentAvailable && session.parentSessionId === driverId) {
|
|
17
|
+
ids.push(session.id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return ids;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findSession(sessionId, sessions) {
|
|
24
|
+
var list = Array.isArray(sessions) ? sessions : [];
|
|
25
|
+
for (var i = 0; i < list.length; i++) if (list[i] && list[i].id === sessionId) return list[i];
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// { worker: true } | { known: true, visible, total } | { known: false, visible }
|
|
30
|
+
function ownership(session, sessions) {
|
|
31
|
+
if (!session) return { known: false, visible: [] };
|
|
32
|
+
if (session.sessionRole === "worker") return { worker: true, visible: [] };
|
|
33
|
+
var visible = ownedWorkerIds(session.id, sessions);
|
|
34
|
+
if (typeof session.ownedWorkerCount !== "number" || session.ownedWorkerCount < 0) return { known: false, visible: visible };
|
|
35
|
+
return { known: true, visible: visible, total: Math.max(session.ownedWorkerCount, visible.length) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function workerPhrase(count) {
|
|
39
|
+
return count + " Split Worker " + (count === 1 ? "session" : "sessions");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function describeSessionDelete(session, sessions) {
|
|
43
|
+
var cached = session ? findSession(session.id, sessions) : null;
|
|
44
|
+
var resolved = cached || (session && session.sessionRole ? session : null);
|
|
45
|
+
var title = (session && session.title) || (cached && cached.title) || "New Session";
|
|
46
|
+
var base = 'Delete "' + title + '"? ';
|
|
47
|
+
var owner = ownership(resolved, sessions);
|
|
48
|
+
if (owner.worker || (owner.known && owner.total === 0)) {
|
|
49
|
+
return { workerCount: 0, requiresDialog: false, message: base + "This session and its history will be permanently removed." };
|
|
50
|
+
}
|
|
51
|
+
if (owner.known) {
|
|
52
|
+
return { workerCount: owner.total, requiresDialog: true,
|
|
53
|
+
message: base + "This Driver and all " + workerPhrase(owner.total) + " it owns, " + FULL_SCOPE +
|
|
54
|
+
", will be permanently removed with their history." };
|
|
55
|
+
}
|
|
56
|
+
return { workerCount: null, requiresDialog: true,
|
|
57
|
+
message: base + "This session and its history will be permanently removed. If it is a Driver, every Split Worker session it owns, " +
|
|
58
|
+
FULL_SCOPE + ", will also be permanently removed." };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function describeGroupDelete(groupLabel, sessionIds, sessions) {
|
|
62
|
+
var requested = [];
|
|
63
|
+
var seen = new Set();
|
|
64
|
+
var list = Array.isArray(sessionIds) ? sessionIds : [];
|
|
65
|
+
for (var r = 0; r < list.length; r++) {
|
|
66
|
+
if (seen.has(list[r])) continue;
|
|
67
|
+
seen.add(list[r]);
|
|
68
|
+
requested.push(list[r]);
|
|
69
|
+
}
|
|
70
|
+
var ids = requested.slice();
|
|
71
|
+
var hidden = 0;
|
|
72
|
+
var unknown = 0;
|
|
73
|
+
for (var i = 0; i < requested.length; i++) {
|
|
74
|
+
var owner = ownership(findSession(requested[i], sessions), sessions);
|
|
75
|
+
if (owner.worker) continue;
|
|
76
|
+
for (var j = 0; j < owner.visible.length; j++) {
|
|
77
|
+
if (seen.has(owner.visible[j])) continue;
|
|
78
|
+
seen.add(owner.visible[j]);
|
|
79
|
+
ids.push(owner.visible[j]);
|
|
80
|
+
}
|
|
81
|
+
if (owner.known) hidden += owner.total - owner.visible.length;
|
|
82
|
+
else unknown++;
|
|
83
|
+
}
|
|
84
|
+
var cascaded = ids.length - requested.length + hidden;
|
|
85
|
+
var total = ids.length + hidden;
|
|
86
|
+
var noun = total === 1 ? "session" : "sessions";
|
|
87
|
+
var message = 'Clear "' + groupLabel + '"? ';
|
|
88
|
+
if (unknown > 0) {
|
|
89
|
+
message += "At least " + total + " " + noun + " will be permanently removed, plus every Split Worker session owned by a Driver here, " + FULL_SCOPE + ".";
|
|
90
|
+
} else {
|
|
91
|
+
message += total + " " + noun + " will be permanently removed";
|
|
92
|
+
if (cascaded > 0) message += ", including " + workerPhrase(cascaded) + " owned by these Drivers, " + FULL_SCOPE;
|
|
93
|
+
message += ".";
|
|
94
|
+
}
|
|
95
|
+
return { ids: ids, cascadedCount: cascaded, exact: unknown === 0, message: message };
|
|
96
|
+
}
|
|
@@ -18,6 +18,7 @@ import { groupedSessionIds } from './split-group-helpers.js';
|
|
|
18
18
|
import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js';
|
|
19
19
|
import { openPairDialog } from './split-pair-ui.js';
|
|
20
20
|
import { renderSessionCreationCta } from './session-creation-cta.js';
|
|
21
|
+
import { describeSessionDelete, describeGroupDelete } from './session-delete-scope.js';
|
|
21
22
|
import {
|
|
22
23
|
hierarchyItemMatches,
|
|
23
24
|
prepareSidebarHierarchy,
|
|
@@ -192,7 +193,7 @@ function spawnSessionDeleteParticles(sessionId) {
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
function confirmDeleteSession(session) {
|
|
195
|
-
showConfirm(
|
|
196
|
+
showConfirm(describeSessionDelete(session, cachedSessions).message, function () {
|
|
196
197
|
var ws = getWs();
|
|
197
198
|
if (ws && store.get('connected')) {
|
|
198
199
|
ws.send(JSON.stringify({ type: "delete_session", id: session.id }));
|
|
@@ -271,9 +272,7 @@ function collectItemSessionIds(item) {
|
|
|
271
272
|
|
|
272
273
|
function confirmDeleteSessionGroup(groupLabel, sessionIds) {
|
|
273
274
|
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return;
|
|
274
|
-
|
|
275
|
-
var noun = count === 1 ? "session" : "sessions";
|
|
276
|
-
showConfirm('Clear "' + groupLabel + '"? ' + count + " " + noun + ' will be permanently removed.', function () {
|
|
275
|
+
showConfirm(describeGroupDelete(groupLabel, sessionIds, cachedSessions).message, function () {
|
|
277
276
|
var ws = getWs();
|
|
278
277
|
if (ws && store.get('connected')) {
|
|
279
278
|
ws.send(JSON.stringify({ type: "bulk_delete_sessions", sessionIds: sessionIds }));
|
|
@@ -319,6 +318,13 @@ function appendSessionCloseButton(el, session) {
|
|
|
319
318
|
closeBtn.addEventListener("click", function (e) {
|
|
320
319
|
e.preventDefault();
|
|
321
320
|
e.stopPropagation();
|
|
321
|
+
// A deletion that removes or may remove Split Workers always gets the
|
|
322
|
+
// explicit dialog instead of the two-click armed shortcut.
|
|
323
|
+
if (describeSessionDelete(session, cachedSessions).requiresDialog) {
|
|
324
|
+
clearArmedSessionDelete();
|
|
325
|
+
confirmDeleteSession(session);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
322
328
|
if (armedDeleteSessionId === session.id) {
|
|
323
329
|
clearArmedSessionDelete();
|
|
324
330
|
deleteSessionImmediately(session);
|
|
@@ -63,8 +63,8 @@ function getToolDefs(handlers, options) {
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
name: "partner_status",
|
|
66
|
-
description: "Bounded capacity and continuity report for your Split Worker, for deciding reuse against replacement: context tokens used and the ratio of its window, current task and activity, vendor/model/effort, history size and idle time, whether it is safe to replace right now, and your own recorded results for earlier Worker generations. Returns no transcript. Prefer this over reading turns when you only need to decide.",
|
|
67
|
-
inputSchema: buildShape({ workerId: { type: "number", description: "Optional exact Worker session id
|
|
66
|
+
description: "Bounded capacity and continuity report for your Split Worker, for deciding reuse against replacement: context tokens used and the ratio of its window, current task and activity, vendor/model/effort, history size and idle time, whether it is safe to replace right now, and your own recorded results for earlier Worker generations. Returns no transcript. Prefer this over reading turns when you only need to decide. Omit workerId to discover all configured Workers and their current numeric handles; pass workerId for one Worker.",
|
|
67
|
+
inputSchema: buildShape({ workerId: { type: "number", description: "Optional exact Worker session id. Omit to list all configured Workers and their current handles." } }),
|
|
68
68
|
handler: function (args) { return handlers.status(args || {}); },
|
|
69
69
|
},
|
|
70
70
|
{
|
|
@@ -110,9 +110,25 @@ function hierarchyFor(sessions) {
|
|
|
110
110
|
return result;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Every Worker the hierarchy links to each Driver, keyed by Driver local id.
|
|
114
|
+
// This is the exact scope a Driver deletion cascades to, including hidden
|
|
115
|
+
// sessions and replaced generations.
|
|
116
|
+
function ownedWorkersByDriver(sessions) {
|
|
117
|
+
var hierarchy = hierarchyFor(sessions);
|
|
118
|
+
var result = {};
|
|
119
|
+
for (var i = 0; i < sessions.length; i++) {
|
|
120
|
+
var node = hierarchy[sessions[i].localId];
|
|
121
|
+
if (!node || node.role !== "worker" || node.parentSessionId == null) continue;
|
|
122
|
+
if (!result[node.parentSessionId]) result[node.parentSessionId] = [];
|
|
123
|
+
result[node.parentSessionId].push(sessions[i]);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
113
128
|
module.exports = {
|
|
114
129
|
ensureOrigin: ensureOrigin,
|
|
115
130
|
hierarchyFor: hierarchyFor,
|
|
131
|
+
ownedWorkersByDriver: ownedWorkersByDriver,
|
|
116
132
|
isWorker: isWorker,
|
|
117
133
|
markWorker: markWorker,
|
|
118
134
|
metadata: metadata,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
var splitRoles = require("./session-split-group-roles");
|
|
2
|
+
var durableAnchors = require("./session-split-group-anchors");
|
|
3
|
+
|
|
4
|
+
// Session deletion is irreversible, so its split-group effect is applied to
|
|
5
|
+
// live membership before persistence and is never rolled back. A failed
|
|
6
|
+
// write leaves the durable record pending: a bounded-backoff retry, or the
|
|
7
|
+
// next successful save of any group, writes the authoritative live state.
|
|
8
|
+
var DEFAULT_RETRY_DELAYS_MS = [1000, 5000, 15000, 60000];
|
|
9
|
+
|
|
10
|
+
function refreshLiveAnchors(group, sessions) {
|
|
11
|
+
var member = durableAnchors.versionedMemberAnchors(group, sessions);
|
|
12
|
+
var pair = durableAnchors.versionedPairAnchors(group, sessions);
|
|
13
|
+
if (member) group.memberAnchors = member; else delete group.memberAnchors;
|
|
14
|
+
if (pair) group.pairAnchors = pair; else delete group.pairAnchors;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function removeDeletedMember(groups, localId, sessions) {
|
|
18
|
+
var index = -1;
|
|
19
|
+
for (var i = 0; i < groups.length; i++) {
|
|
20
|
+
if (groups[i].members.indexOf(localId) !== -1) { index = i; break; }
|
|
21
|
+
}
|
|
22
|
+
if (index === -1) return null;
|
|
23
|
+
var group = groups[index];
|
|
24
|
+
var normalized = splitRoles.normalizePair(group.pair, group.members);
|
|
25
|
+
if (normalized.ok && normalized.kind === "versioned" && normalized.driverId !== localId &&
|
|
26
|
+
normalized.workerIds.length > 1) {
|
|
27
|
+
var remaining = normalized.workerIds.filter(function (id) { return id !== localId; });
|
|
28
|
+
group.members = [normalized.driverId].concat(remaining);
|
|
29
|
+
group.pair = { version: 2, driverId: normalized.driverId, workerIds: remaining };
|
|
30
|
+
// Clients address Workers through these anchors by index, so they must
|
|
31
|
+
// match the reduced roles even while the durable write is pending.
|
|
32
|
+
refreshLiveAnchors(group, sessions);
|
|
33
|
+
return { group: group, dissolved: false };
|
|
34
|
+
}
|
|
35
|
+
groups.splice(index, 1);
|
|
36
|
+
return { group: group, dissolved: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function attachDeletionCleanup(opts) {
|
|
40
|
+
var delays = Array.isArray(opts.retryDelaysMs) && opts.retryDelaysMs.length ?
|
|
41
|
+
opts.retryDelaysMs.slice() : DEFAULT_RETRY_DELAYS_MS;
|
|
42
|
+
var setTimer = opts.setTimer || setTimeout;
|
|
43
|
+
var clearTimer = opts.clearTimer || clearTimeout;
|
|
44
|
+
var logger = opts.logger || console;
|
|
45
|
+
var pending = null;
|
|
46
|
+
var timer = null;
|
|
47
|
+
var stopped = false;
|
|
48
|
+
|
|
49
|
+
function cancelTimer() {
|
|
50
|
+
if (timer) clearTimer(timer);
|
|
51
|
+
timer = null;
|
|
52
|
+
}
|
|
53
|
+
function schedule() {
|
|
54
|
+
cancelTimer();
|
|
55
|
+
if (stopped || !pending) return;
|
|
56
|
+
timer = setTimer(retry, delays[Math.min(pending.attempts - 1, delays.length - 1)]);
|
|
57
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
58
|
+
}
|
|
59
|
+
function recordFailure(reason, error) {
|
|
60
|
+
var message = error && error.message || String(error);
|
|
61
|
+
if (!pending) pending = { reason: reason, attempts: 0, since: Date.now(), lastError: null };
|
|
62
|
+
pending.attempts++;
|
|
63
|
+
pending.lastError = message;
|
|
64
|
+
logger.error("[split-groups] Durable split group state is pending after " + pending.reason +
|
|
65
|
+
" (attempt " + pending.attempts + "): " + message);
|
|
66
|
+
schedule();
|
|
67
|
+
}
|
|
68
|
+
function saved() {
|
|
69
|
+
if (!pending) return;
|
|
70
|
+
logger.log("[split-groups] Durable split group state recovered after " + pending.reason);
|
|
71
|
+
pending = null;
|
|
72
|
+
cancelTimer();
|
|
73
|
+
}
|
|
74
|
+
function persist(reason) {
|
|
75
|
+
try { opts.save(); return true; }
|
|
76
|
+
catch (error) { recordFailure(reason, error); return false; }
|
|
77
|
+
}
|
|
78
|
+
function retry() {
|
|
79
|
+
timer = null;
|
|
80
|
+
if (stopped || !pending) return;
|
|
81
|
+
persist(pending.reason);
|
|
82
|
+
}
|
|
83
|
+
function sessionDeleted(localId) {
|
|
84
|
+
var change = removeDeletedMember(opts.groups(), localId, opts.sessions);
|
|
85
|
+
if (!change) return false;
|
|
86
|
+
persist("session deletion");
|
|
87
|
+
opts.broadcast();
|
|
88
|
+
opts.onPairChanged(change.group);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
function retryNow() {
|
|
92
|
+
if (!pending) return true;
|
|
93
|
+
return persist(pending.reason);
|
|
94
|
+
}
|
|
95
|
+
function status() {
|
|
96
|
+
if (!pending) return { pending: false };
|
|
97
|
+
return { pending: true, reason: pending.reason, attempts: pending.attempts,
|
|
98
|
+
since: pending.since, lastError: pending.lastError, retryScheduled: !!timer };
|
|
99
|
+
}
|
|
100
|
+
function shutdown() {
|
|
101
|
+
stopped = true;
|
|
102
|
+
cancelTimer();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { sessionDeleted: sessionDeleted, recordFailure: recordFailure, saved: saved,
|
|
106
|
+
retryNow: retryNow, status: status, shutdown: shutdown };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
attachDeletionCleanup: attachDeletionCleanup,
|
|
111
|
+
removeDeletedMember: removeDeletedMember,
|
|
112
|
+
DEFAULT_RETRY_DELAYS_MS: DEFAULT_RETRY_DELAYS_MS,
|
|
113
|
+
};
|
|
@@ -2,6 +2,7 @@ var fs = require("fs");
|
|
|
2
2
|
var path = require("path");
|
|
3
3
|
var anchors = require("./session-split-group-anchors");
|
|
4
4
|
var roles = require("./session-split-group-roles");
|
|
5
|
+
var recovery = require("./session-split-group-recovery");
|
|
5
6
|
|
|
6
7
|
var ANCHOR_KEYS = ["memberCliIds", "memberOriginIds", "pairCliIds", "pairOriginIds", "memberAnchors", "pairAnchors"];
|
|
7
8
|
|
|
@@ -9,6 +10,7 @@ function attachPersistence(opts) {
|
|
|
9
10
|
var sessions = opts.sessions;
|
|
10
11
|
var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
|
|
11
12
|
var preserved = [];
|
|
13
|
+
var loadRewriteError = null;
|
|
12
14
|
|
|
13
15
|
function anchorPair(group) {
|
|
14
16
|
if (!group.pair) return;
|
|
@@ -116,6 +118,12 @@ function attachPersistence(opts) {
|
|
|
116
118
|
var versioned = !!(normalized && normalized.ok && normalized.kind === "versioned");
|
|
117
119
|
if (versioned && !opts.versionedEnabled) { preserved.push(group); return false; }
|
|
118
120
|
var resolved = versioned ? resolveVersioned(group, normalized) : resolveLegacy(group);
|
|
121
|
+
if (!resolved && versioned) {
|
|
122
|
+
resolved = recovery.repairDeletedMembers(group, normalized, sessions, {
|
|
123
|
+
loadUncertain: typeof opts.sessionLoadUncertain === "function" && opts.sessionLoadUncertain() === true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (resolved && resolved.drop) return false;
|
|
119
127
|
if (!resolved) {
|
|
120
128
|
if (versioned || group && roles.isUnsupportedPair(group.pair)) preserved.push(group);
|
|
121
129
|
return false;
|
|
@@ -126,11 +134,16 @@ function attachPersistence(opts) {
|
|
|
126
134
|
return true;
|
|
127
135
|
});
|
|
128
136
|
if (groups.length + preserved.length !== loaded.length) rewrite = true;
|
|
129
|
-
|
|
137
|
+
loadRewriteError = null;
|
|
138
|
+
// A storage outage must not discard groups that resolved or were
|
|
139
|
+
// repaired in memory; the store retries the rewrite.
|
|
140
|
+
if (rewrite) {
|
|
141
|
+
try { save(groups); } catch (error) { loadRewriteError = error; }
|
|
142
|
+
}
|
|
130
143
|
return groups;
|
|
131
144
|
}
|
|
132
145
|
|
|
133
|
-
return { load: load, save: save };
|
|
146
|
+
return { load: load, save: save, loadRewriteError: function () { return loadRewriteError; } };
|
|
134
147
|
}
|
|
135
148
|
|
|
136
149
|
module.exports = { attachPersistence: attachPersistence };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
var roles = require("./session-split-group-roles");
|
|
2
|
+
|
|
3
|
+
// Restart repair for a Version 2 record whose deletion cleanup never reached
|
|
4
|
+
// disk. Only anchors that no live session carries at all are treated as
|
|
5
|
+
// deleted. Any ambiguous or partially matching identity, owner mismatch, or
|
|
6
|
+
// disagreement between member and pair anchors leaves the record untouched so
|
|
7
|
+
// the ordinary preserve-on-failure path still applies.
|
|
8
|
+
|
|
9
|
+
function value(input) {
|
|
10
|
+
return typeof input === "string" && input ? input : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sameAnchor(left, right) {
|
|
14
|
+
return !!(left && right && value(left.cli) === value(right.cli) && value(left.origin) === value(right.origin));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function classify(sessions, anchor) {
|
|
18
|
+
var cli = value(anchor && anchor.cli);
|
|
19
|
+
var origin = value(anchor && anchor.origin);
|
|
20
|
+
if (!cli && !origin) return { state: "invalid" };
|
|
21
|
+
var matches = [];
|
|
22
|
+
sessions.forEach(function (session) {
|
|
23
|
+
if ((cli && session.cliSessionId === cli) || (origin && session.sessionOriginId === origin)) matches.push(session);
|
|
24
|
+
});
|
|
25
|
+
if (matches.length === 0) return { state: "absent" };
|
|
26
|
+
var only = matches[0];
|
|
27
|
+
if (matches.length === 1 && (!cli || only.cliSessionId === cli) && (!origin || only.sessionOriginId === origin)) {
|
|
28
|
+
return { state: "exact", session: only };
|
|
29
|
+
}
|
|
30
|
+
return { state: "conflict" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function agreedAnchors(group, normalized) {
|
|
34
|
+
var members = group.memberAnchors;
|
|
35
|
+
var pair = group.pairAnchors;
|
|
36
|
+
if (!members || members.version !== 2 || !Array.isArray(members.members)) return null;
|
|
37
|
+
if (!pair || pair.version !== 2 || !pair.driver || !Array.isArray(pair.workers)) return null;
|
|
38
|
+
if (pair.workers.length !== normalized.workerIds.length || members.members.length !== group.members.length) return null;
|
|
39
|
+
if (members.members.length !== pair.workers.length + 1 || !sameAnchor(members.members[0], pair.driver)) return null;
|
|
40
|
+
for (var i = 0; i < pair.workers.length; i++) {
|
|
41
|
+
if (!sameAnchor(members.members[i + 1], pair.workers[i])) return null;
|
|
42
|
+
}
|
|
43
|
+
return { driver: pair.driver, workers: pair.workers };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Returns null when no safe repair applies, { drop: true } when the stored
|
|
47
|
+
// Driver or every Worker was deleted, or { changed: true } after reducing the
|
|
48
|
+
// record to its exact surviving Driver and Workers. An absent anchor is
|
|
49
|
+
// evidence of deletion only when every session record was read; after any
|
|
50
|
+
// unreadable or unparseable record the stored group is preserved unchanged.
|
|
51
|
+
function repairDeletedMembers(group, normalized, sessions, options) {
|
|
52
|
+
if (typeof group.id !== "string" || !group.id || !normalized.ok || normalized.kind !== "versioned") return null;
|
|
53
|
+
var agreed = agreedAnchors(group, normalized);
|
|
54
|
+
if (!agreed) return null;
|
|
55
|
+
var ownerId = group.ownerId || null;
|
|
56
|
+
var driver = classify(sessions, agreed.driver);
|
|
57
|
+
var workers = agreed.workers.map(function (anchor) { return classify(sessions, anchor); });
|
|
58
|
+
var all = [driver].concat(workers);
|
|
59
|
+
var absent = 0;
|
|
60
|
+
var seen = [];
|
|
61
|
+
for (var i = 0; i < all.length; i++) {
|
|
62
|
+
if (all[i].state === "absent") { absent++; continue; }
|
|
63
|
+
if (all[i].state !== "exact") return null;
|
|
64
|
+
if ((all[i].session.ownerId || null) !== ownerId || seen.indexOf(all[i].session) !== -1) return null;
|
|
65
|
+
seen.push(all[i].session);
|
|
66
|
+
}
|
|
67
|
+
if (absent === 0 || (options && options.loadUncertain)) return null;
|
|
68
|
+
var survivors = [];
|
|
69
|
+
var survivorAnchors = [];
|
|
70
|
+
for (var j = 0; j < workers.length; j++) {
|
|
71
|
+
if (workers[j].state !== "exact") continue;
|
|
72
|
+
survivors.push(workers[j].session);
|
|
73
|
+
survivorAnchors.push(agreed.workers[j]);
|
|
74
|
+
}
|
|
75
|
+
if (driver.state !== "exact" || survivors.length === 0) return { drop: true };
|
|
76
|
+
var memberIds = [driver.session.localId].concat(survivors.map(function (session) { return session.localId; }));
|
|
77
|
+
var nextPair = { version: 2, driverId: driver.session.localId, workerIds: memberIds.slice(1) };
|
|
78
|
+
var checked = roles.normalizePair(nextPair, memberIds);
|
|
79
|
+
if (!checked.ok || checked.kind !== "versioned") return null;
|
|
80
|
+
group.members = memberIds;
|
|
81
|
+
group.pair = nextPair;
|
|
82
|
+
group.memberAnchors = { version: 2, members: [agreed.driver].concat(survivorAnchors) };
|
|
83
|
+
group.pairAnchors = { version: 2, driver: agreed.driver, workers: survivorAnchors };
|
|
84
|
+
return { changed: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { repairDeletedMembers: repairDeletedMembers, classifyAnchor: classify };
|
|
@@ -4,6 +4,7 @@ var splitRoles = require("./session-split-group-roles");
|
|
|
4
4
|
var versionedStore = require("./session-split-group-v2-store");
|
|
5
5
|
var multiWorkerFeature = require("./multi-worker-feature");
|
|
6
6
|
var groupPersistence = require("./session-split-group-persistence");
|
|
7
|
+
var deletionCleanup = require("./session-split-group-cleanup");
|
|
7
8
|
function truncateTitle(title) {
|
|
8
9
|
var value = String(title || "New Session");
|
|
9
10
|
return value.length > 20 ? value.slice(0, 19) + "…" : value;
|
|
@@ -20,7 +21,15 @@ function createSplitGroupStore(opts) {
|
|
|
20
21
|
var stagedTransfers = [];
|
|
21
22
|
var versionedEnabled = multiWorkerFeature.isEnabled(opts.multiWorkerFeature);
|
|
22
23
|
var persistence = groupPersistence.attachPersistence(Object.assign({}, opts, { versionedEnabled: versionedEnabled }));
|
|
23
|
-
|
|
24
|
+
var cleanup = deletionCleanup.attachDeletionCleanup({
|
|
25
|
+
sessions: sessions, groups: function () { return groups; }, save: save,
|
|
26
|
+
broadcast: function () { broadcast(); }, onPairChanged: function (group) { onPairChanged(group); },
|
|
27
|
+
retryDelaysMs: opts.persistRetryDelaysMs, setTimer: opts.setTimer, clearTimer: opts.clearTimer, logger: opts.logger,
|
|
28
|
+
});
|
|
29
|
+
function save() {
|
|
30
|
+
persistence.save(groups);
|
|
31
|
+
cleanup.saved();
|
|
32
|
+
}
|
|
24
33
|
function isMultiUser() {
|
|
25
34
|
return !!(usersModule && usersModule.isMultiUser && usersModule.isMultiUser());
|
|
26
35
|
}
|
|
@@ -354,33 +363,8 @@ function createSplitGroupStore(opts) {
|
|
|
354
363
|
return { ok: true, group: removed };
|
|
355
364
|
}
|
|
356
365
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (index === -1) return false;
|
|
360
|
-
var group = groups[index];
|
|
361
|
-
var normalized = splitRoles.normalizePair(group.pair, group.members);
|
|
362
|
-
var beforeMembers = group.members.slice();
|
|
363
|
-
var beforePair = group.pair && Object.assign({}, group.pair);
|
|
364
|
-
if (beforePair && Array.isArray(beforePair.workerIds)) beforePair.workerIds = beforePair.workerIds.slice();
|
|
365
|
-
var removed = null;
|
|
366
|
-
if (normalized.ok && normalized.kind === "versioned" && normalized.driverId !== localId &&
|
|
367
|
-
normalized.workerIds.length > 1) {
|
|
368
|
-
var remaining = normalized.workerIds.filter(function (id) { return id !== localId; });
|
|
369
|
-
group.members = [normalized.driverId].concat(remaining);
|
|
370
|
-
group.pair = { version: 2, driverId: normalized.driverId, workerIds: remaining };
|
|
371
|
-
} else {
|
|
372
|
-
removed = groups.splice(index, 1)[0];
|
|
373
|
-
}
|
|
374
|
-
try { save(); }
|
|
375
|
-
catch (error) {
|
|
376
|
-
if (removed) groups.splice(index, 0, removed);
|
|
377
|
-
else { group.members = beforeMembers; group.pair = beforePair; }
|
|
378
|
-
return false;
|
|
379
|
-
}
|
|
380
|
-
broadcast();
|
|
381
|
-
onPairChanged(group);
|
|
382
|
-
return true;
|
|
383
|
-
}
|
|
366
|
+
// Deletion cleanup is irreversible; see session-split-group-cleanup.js.
|
|
367
|
+
function dissolveBySession(localId) { return cleanup.sessionDeleted(localId); }
|
|
384
368
|
|
|
385
369
|
function refreshAnchors(localId) {
|
|
386
370
|
var group = groupForMember(localId);
|
|
@@ -457,6 +441,7 @@ function createSplitGroupStore(opts) {
|
|
|
457
441
|
changed: versionedChanged,
|
|
458
442
|
});
|
|
459
443
|
groups = persistence.load();
|
|
444
|
+
if (persistence.loadRewriteError()) cleanup.recordFailure("startup rewrite", persistence.loadRewriteError());
|
|
460
445
|
return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned,
|
|
461
446
|
beginOwnedDriverTransfer: beginOwnedDriverTransfer, commitOwnedDriverTransfer: commitOwnedDriverTransfer,
|
|
462
447
|
rollbackOwnedDriverTransfer: rollbackOwnedDriverTransfer,
|
|
@@ -464,6 +449,7 @@ function createSplitGroupStore(opts) {
|
|
|
464
449
|
addWorker: versionedMutations.addWorker, replaceWorker: versionedMutations.replaceWorker,
|
|
465
450
|
removeWorker: versionedMutations.removeWorker,
|
|
466
451
|
refreshAutoName: refreshAutoName, refreshAnchors: refreshAnchors,
|
|
452
|
+
persistenceStatus: cleanup.status, retryPersistence: cleanup.retryNow, shutdown: cleanup.shutdown,
|
|
467
453
|
listFor: listFor, groupForMember: groupForMember, get groups() { return groups; } };
|
|
468
454
|
}
|
|
469
455
|
|
|
@@ -475,6 +461,7 @@ function attachSplitGroups(ctx) {
|
|
|
475
461
|
broadcast: broadcast,
|
|
476
462
|
onPairChanged: ctx.onPairChanged,
|
|
477
463
|
multiWorkerFeature: ctx.multiWorkerFeature,
|
|
464
|
+
sessionLoadUncertain: ctx.sm.sessionLoadUncertain,
|
|
478
465
|
});
|
|
479
466
|
|
|
480
467
|
function sendState(ws) {
|
|
@@ -503,7 +490,7 @@ function attachSplitGroups(ctx) {
|
|
|
503
490
|
ctx.sm.setOnSessionDeleted(store.dissolveBySession);
|
|
504
491
|
ctx.sm.setOnSessionRenamed(store.refreshAutoName);
|
|
505
492
|
ctx.sm.setOnSessionIdentityAssigned(store.refreshAnchors);
|
|
506
|
-
return { handleMessage: handleMessage, sendConnectionState: sendState, store: store };
|
|
493
|
+
return { handleMessage: handleMessage, sendConnectionState: sendState, store: store, shutdown: store.shutdown };
|
|
507
494
|
}
|
|
508
495
|
|
|
509
496
|
module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore, autoGroupName: autoGroupName };
|
package/lib/sessions.js
CHANGED
|
@@ -271,9 +271,16 @@ function createSessionManager(opts) {
|
|
|
271
271
|
return null;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// Records that could not be read or parsed may still be live sessions.
|
|
275
|
+
// Split-group recovery reads this so it never treats them as deleted.
|
|
276
|
+
var sessionLoadUncertain = false;
|
|
277
|
+
|
|
274
278
|
function loadSessions() {
|
|
275
279
|
var files;
|
|
276
|
-
try { files = fs.readdirSync(sessionsDir); } catch {
|
|
280
|
+
try { files = fs.readdirSync(sessionsDir); } catch (e) {
|
|
281
|
+
if (!e || e.code !== "ENOENT") sessionLoadUncertain = true;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
277
284
|
|
|
278
285
|
// Clean up stale temp files from interrupted atomic writes
|
|
279
286
|
for (var ti = 0; ti < files.length; ti++) {
|
|
@@ -286,14 +293,14 @@ function createSessionManager(opts) {
|
|
|
286
293
|
for (var i = 0; i < files.length; i++) {
|
|
287
294
|
if (!files[i].endsWith(".jsonl")) continue;
|
|
288
295
|
var content;
|
|
289
|
-
try { content = fs.readFileSync(path.join(sessionsDir, files[i]), "utf8"); } catch { continue; }
|
|
296
|
+
try { content = fs.readFileSync(path.join(sessionsDir, files[i]), "utf8"); } catch { sessionLoadUncertain = true; continue; }
|
|
290
297
|
var lines = content.trim().split("\n");
|
|
291
298
|
if (lines.length === 0) continue;
|
|
292
299
|
|
|
293
300
|
var meta;
|
|
294
|
-
try { meta = JSON.parse(lines[0]); } catch { continue; }
|
|
295
|
-
var pendingOrigin = typeof meta.sessionOriginId === "string" && meta.sessionOriginId.trim() && meta.sessionOriginId.length <= 128;
|
|
296
|
-
if (meta.type !== "meta" || (!meta.cliSessionId && !pendingOrigin)) continue;
|
|
301
|
+
try { meta = JSON.parse(lines[0]); } catch { sessionLoadUncertain = true; continue; }
|
|
302
|
+
var pendingOrigin = meta && typeof meta.sessionOriginId === "string" && meta.sessionOriginId.trim() && meta.sessionOriginId.length <= 128;
|
|
303
|
+
if (!meta || meta.type !== "meta" || (!meta.cliSessionId && !pendingOrigin)) { sessionLoadUncertain = true; continue; }
|
|
297
304
|
|
|
298
305
|
var history = [];
|
|
299
306
|
for (var j = 1; j < lines.length; j++) {
|
|
@@ -632,11 +639,25 @@ function createSessionManager(opts) {
|
|
|
632
639
|
};
|
|
633
640
|
}
|
|
634
641
|
|
|
635
|
-
|
|
642
|
+
// Counted over every session, including hidden ones, so delete
|
|
643
|
+
// confirmations can state the full cascade. In multi-user mode only the
|
|
644
|
+
// Driver's own owner receives it: another viewer could otherwise infer
|
|
645
|
+
// hidden or private Workers, and a missing count means "unknown" to the
|
|
646
|
+
// client, never zero. viewerId must come from the authenticated socket.
|
|
647
|
+
function ownedWorkerCountFor(session, node, owned, viewerId) {
|
|
648
|
+
if (node.role !== "driver") return 0;
|
|
649
|
+
if (users.isMultiUser() && (!viewerId || (session.ownerId || null) !== viewerId)) return undefined;
|
|
650
|
+
return owned[session.localId] ? owned[session.localId].length : 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function mapSessionsForClient(source, clientActiveId, wsUnread, viewerId) {
|
|
636
654
|
var hierarchy = sessionProvenance.hierarchyFor(source);
|
|
655
|
+
var owned = sessionProvenance.ownedWorkersByDriver([...sessions.values()]);
|
|
637
656
|
return source.map(function (session) {
|
|
638
657
|
var mapped = mapSessionForClient(session, clientActiveId, wsUnread);
|
|
639
658
|
var node = hierarchy[session.localId];
|
|
659
|
+
var ownedCount = ownedWorkerCountFor(session, node, owned, viewerId);
|
|
660
|
+
if (ownedCount !== undefined) mapped.ownedWorkerCount = ownedCount;
|
|
640
661
|
mapped.sessionRole = node.role;
|
|
641
662
|
mapped.parentSessionId = node.parentSessionId;
|
|
642
663
|
mapped.parentAvailable = node.parentAvailable;
|
|
@@ -667,7 +688,7 @@ function createSessionManager(opts) {
|
|
|
667
688
|
if (ws.readyState === 1) {
|
|
668
689
|
ws.send(JSON.stringify({
|
|
669
690
|
type: "session_list",
|
|
670
|
-
sessions: mapSessionsForClient(filtered, clientActiveId, wsUnread),
|
|
691
|
+
sessions: mapSessionsForClient(filtered, clientActiveId, wsUnread, ws._clayUser ? ws._clayUser.id : null),
|
|
671
692
|
}));
|
|
672
693
|
}
|
|
673
694
|
});
|
|
@@ -1389,6 +1410,7 @@ function createSessionManager(opts) {
|
|
|
1389
1410
|
set codexWebSearch(v) { codexWebSearch = v; },
|
|
1390
1411
|
sessions: sessions,
|
|
1391
1412
|
sessionsDir: sessionsDir,
|
|
1413
|
+
sessionLoadUncertain: function () { return sessionLoadUncertain; },
|
|
1392
1414
|
setOnSessionDeleted: function (fn) { onSessionDeleted = fn || function () {}; },
|
|
1393
1415
|
addOnSessionDeleted: function (fn) { if (typeof fn === "function") sessionDeletedListeners.push(fn); },
|
|
1394
1416
|
addOnSessionViewed: function (fn) { if (typeof fn === "function") sessionViewedListeners.push(fn); },
|
package/package.json
CHANGED