@crazx/dsh-api-session-controller 0.1.6-alpha.1.zw.2 → 0.1.6-alpha.2.zw.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/README.i18n.yaml +2 -2
- package/README.md +15 -3
- package/README.zh.md +15 -3
- package/lib/client.js +428 -471
- package/lib/index.js +16 -47
- package/lib/typert.host.js +179 -161
- package/lib/typert.remote-client.js +168 -150
- package/lib/types/agent.d.ts +1 -1
- package/lib/types/agent.js +8 -1
- package/lib/types/client/contract/sessions.d.ts +55 -20
- package/lib/types/client/contract/snapshot.d.ts +0 -14
- package/lib/types/client/index.d.ts +11 -2
- package/lib/types/client/index.js +16 -5
- package/lib/types/client/scope.d.ts +13 -3
- package/lib/types/client/scope.js +15 -22
- package/lib/types/client/sessions/lineage.d.ts +1 -4
- package/lib/types/client/sessions/lineage.js +1 -3
- package/lib/types/client/sessions/manager.d.ts +28 -49
- package/lib/types/client/sessions/manager.js +75 -174
- package/lib/types/client/sessions/projection-store.d.ts +5 -11
- package/lib/types/client/sessions/projection-store.js +6 -14
- package/lib/types/client/sessions/service.d.ts +34 -119
- package/lib/types/client/sessions/service.js +259 -244
- package/lib/types/client/sessions/session.d.ts +7 -20
- package/lib/types/client/sessions/session.js +29 -33
- package/lib/types/commands.d.ts +2 -2
- package/lib/types/commands.js +10 -4
- package/lib/types/control.d.ts +1 -1
- package/lib/types/control.js +1 -41
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.js +1 -1
- package/lib/types/types.d.ts +3 -17
- package/package.json +70 -70
- package/lib/types/client/sessions/queue-mirror.d.ts +0 -26
- package/lib/types/client/sessions/queue-mirror.js +0 -61
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { SessionSeq } from '@deepseek-ai/dsh-session/types';
|
|
2
2
|
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path';
|
|
3
3
|
import { SESSION_SEARCH_RESULT_LIMIT } from "../../types.js";
|
|
4
|
-
import { createSnapshotStore, } from '@deepseek-ai/dsh-client-store';
|
|
5
|
-
import { createScope, scopeOf as scopeTagOf } from "../scope.js";
|
|
4
|
+
import { createSnapshotStore, notifySubscribers, } from '@deepseek-ai/dsh-client-store';
|
|
5
|
+
import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from "../scope.js";
|
|
6
6
|
import { SessionManager } from "./manager.js";
|
|
7
7
|
/** Structured session-create failure. */
|
|
8
8
|
export class SessionCreateError extends Error {
|
|
@@ -69,7 +69,74 @@ function increasedForkTitle(title) {
|
|
|
69
69
|
}
|
|
70
70
|
return `${title} (1)`;
|
|
71
71
|
}
|
|
72
|
-
/**
|
|
72
|
+
/** Source labels are dictionary keys, including names also present on Object.prototype. */
|
|
73
|
+
function freezeRetainedBy(counts) {
|
|
74
|
+
Object.setPrototypeOf(counts, null);
|
|
75
|
+
return Object.freeze(counts);
|
|
76
|
+
}
|
|
77
|
+
const EMPTY_RETAIN_INFO = Object.freeze({ referenceCount: 0, retainedBy: freezeRetainedBy({}) });
|
|
78
|
+
/** A cancelled waiter releases only its own reference, not the shared opening. */
|
|
79
|
+
async function waitForOpen(opening, signal) {
|
|
80
|
+
if (signal === undefined)
|
|
81
|
+
return opening;
|
|
82
|
+
const aborted = Promise.withResolvers();
|
|
83
|
+
const onAbort = () => { aborted.reject(signal.reason); };
|
|
84
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
85
|
+
try {
|
|
86
|
+
if (signal.aborted)
|
|
87
|
+
onAbort();
|
|
88
|
+
await Promise.race([opening, aborted.promise]);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
signal.removeEventListener('abort', onAbort);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
class ClientSessionReference {
|
|
95
|
+
sessionId;
|
|
96
|
+
record;
|
|
97
|
+
releaseReference;
|
|
98
|
+
released = new AbortController();
|
|
99
|
+
readiness = Promise.withResolvers();
|
|
100
|
+
ready = this.readiness.promise;
|
|
101
|
+
constructor(sessionId, record, releaseReference) {
|
|
102
|
+
this.sessionId = sessionId;
|
|
103
|
+
this.record = record;
|
|
104
|
+
this.releaseReference = releaseReference;
|
|
105
|
+
void this.ready.catch(() => { });
|
|
106
|
+
}
|
|
107
|
+
get binding() {
|
|
108
|
+
if (this.record === undefined || !this.record.live)
|
|
109
|
+
throw new Error(`Session reference "${this.sessionId}" is released`);
|
|
110
|
+
return this.record.binding;
|
|
111
|
+
}
|
|
112
|
+
attachOpening(opening, signal) {
|
|
113
|
+
const waitSignal = signal === undefined
|
|
114
|
+
? this.released.signal
|
|
115
|
+
: AbortSignal.any([this.released.signal, signal]);
|
|
116
|
+
void waitForOpen(opening, waitSignal).then(() => {
|
|
117
|
+
try {
|
|
118
|
+
waitSignal.throwIfAborted();
|
|
119
|
+
this.readiness.resolve(this.binding);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.readiness.reject(error);
|
|
123
|
+
}
|
|
124
|
+
}, (error) => { this.readiness.reject(error); });
|
|
125
|
+
}
|
|
126
|
+
release() {
|
|
127
|
+
const reason = new Error(`Session reference "${this.sessionId}" is released`);
|
|
128
|
+
const release = this.releaseReference;
|
|
129
|
+
this.released.abort(reason);
|
|
130
|
+
this.readiness.reject(reason);
|
|
131
|
+
this.record = undefined;
|
|
132
|
+
this.releaseReference = undefined;
|
|
133
|
+
release?.();
|
|
134
|
+
}
|
|
135
|
+
[Symbol.dispose]() {
|
|
136
|
+
this.release();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Host catalog and local reference allocator; view selection remains outside the Controller. */
|
|
73
140
|
export class ClientSessions {
|
|
74
141
|
rootCtx;
|
|
75
142
|
/**
|
|
@@ -79,85 +146,89 @@ export class ClientSessions {
|
|
|
79
146
|
* reports the same number.
|
|
80
147
|
*/
|
|
81
148
|
searchResultLimit = SESSION_SEARCH_RESULT_LIMIT;
|
|
82
|
-
/**
|
|
149
|
+
/** Catalog metadata and local reference-source projection. */
|
|
83
150
|
list;
|
|
84
151
|
/** The object-layer instance cluster and frame dispatch entry. */
|
|
85
152
|
manager;
|
|
86
|
-
/**
|
|
87
|
-
* Persisted selection cell (the durable half of `list.current`). Private on
|
|
88
|
-
* purpose: reads go through the list snapshot; writes through {@link
|
|
89
|
-
* ClientSessions.open} / {@link ClientSessions.clear}. Projection
|
|
90
|
-
* validates it against the live list instead of destructively pruning, so a
|
|
91
|
-
* selection survives transient list states (reconnect re-pull) and
|
|
92
|
-
* resurfaces when its session returns.
|
|
93
|
-
*/
|
|
94
|
-
selection;
|
|
95
153
|
scopes = new Map();
|
|
96
|
-
/**
|
|
154
|
+
/** Stable per-id sources retained for the Client root lifetime, including across generation replacement. */
|
|
155
|
+
retainObservers = new Map();
|
|
97
156
|
scopeDrops = new Set();
|
|
98
|
-
|
|
99
|
-
* The staged session id — follows `list.current` exactly, holding its last
|
|
100
|
-
* defined value across masked gaps (a transiently absent selection blanks
|
|
101
|
-
* `current` without moving the stage, so reconnect re-pulls and removals
|
|
102
|
-
* keep the staged scope's frozen view alive until the stage moves on).
|
|
103
|
-
*/
|
|
104
|
-
watched;
|
|
105
|
-
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
|
106
|
-
deferredRemovals = new Set();
|
|
157
|
+
closed = false;
|
|
107
158
|
/**
|
|
108
159
|
* @param ctx - client root context (scope fibers mount under it).
|
|
109
160
|
* @param remote - generated Remote namespaces shared with every Session.
|
|
110
161
|
*/
|
|
111
162
|
constructor(rootCtx, remote) {
|
|
112
163
|
this.rootCtx = rootCtx;
|
|
113
|
-
this.
|
|
114
|
-
const restored = this.selection.getSnapshot();
|
|
115
|
-
this.manager = new SessionManager(remote, restored.sessionId, restored.subagentAddress);
|
|
164
|
+
this.manager = new SessionManager(remote);
|
|
116
165
|
this.list = createSnapshotStore({
|
|
117
|
-
ids: [], byId: {},
|
|
118
|
-
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
|
|
119
|
-
});
|
|
120
|
-
// The manager owns wire truth; the store is its projection. Manager
|
|
121
|
-
// notifications are already microtask-batched.
|
|
122
|
-
const disposeManagerProjection = this.manager.subscribe(() => {
|
|
123
|
-
this.projectList();
|
|
124
|
-
});
|
|
125
|
-
// Stage follower: every current write (open() and projection alike)
|
|
126
|
-
// re-evaluates staging, so startup restore (persisted selection validated
|
|
127
|
-
// by the projection) and reconnect resurfacing open their window with no
|
|
128
|
-
// dedicated code path. Safe to run synchronously inside the store notify:
|
|
129
|
-
// the follower writes no list state — session.open()'s synchronous prefix
|
|
130
|
-
// touches only session-side state and its own microtask-batched notifier.
|
|
131
|
-
const disposeStageFollower = this.list.subscribe(() => {
|
|
132
|
-
this.followCurrent();
|
|
166
|
+
ids: [], byId: {}, phase: 'pending', subagentsByParent: {}, jobsBySession: {},
|
|
133
167
|
});
|
|
168
|
+
const disposeManagerProjection = this.manager.subscribe(() => { this.projectList(); });
|
|
134
169
|
rootCtx.effect(() => async () => {
|
|
135
|
-
|
|
170
|
+
this.closed = true;
|
|
136
171
|
disposeManagerProjection();
|
|
137
172
|
const scopes = [...this.scopes];
|
|
138
173
|
this.scopes.clear();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
174
|
+
for (const [, record] of scopes) {
|
|
175
|
+
record.live = false;
|
|
176
|
+
record.session.unbindScope();
|
|
177
|
+
}
|
|
178
|
+
const managerDisposal = this.manager.dispose();
|
|
179
|
+
for (const [id, record] of scopes) {
|
|
142
180
|
this.startScopeDrop(id, record);
|
|
181
|
+
this.publishRetention(id);
|
|
182
|
+
}
|
|
143
183
|
await this.drainScopeDrops();
|
|
144
|
-
await
|
|
184
|
+
await managerDisposal;
|
|
145
185
|
}, 'session-controller.client.sessions');
|
|
146
186
|
rootCtx.reflect.provide('sessions', this, undefined);
|
|
147
187
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this.manager.
|
|
188
|
+
retain(target, options) {
|
|
189
|
+
const { source, signal } = options;
|
|
190
|
+
signal?.throwIfAborted();
|
|
191
|
+
if (this.closed)
|
|
192
|
+
throw new Error('Session Controller is disposed');
|
|
193
|
+
const id = this.manager.resolveTarget(target);
|
|
194
|
+
const reference = this.retainScope(id, source);
|
|
195
|
+
try {
|
|
196
|
+
reference.attachOpening(this.manager.get(id).open(), signal);
|
|
197
|
+
return reference;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
reference.release();
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
154
203
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
204
|
+
async using(target, options, operation) {
|
|
205
|
+
const reference = this.retain(target, options);
|
|
206
|
+
try {
|
|
207
|
+
await reference.ready;
|
|
208
|
+
return await operation(reference);
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
reference.release();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
retainInfo(id) {
|
|
215
|
+
let observer = this.retainObservers.get(id);
|
|
216
|
+
if (observer === undefined) {
|
|
217
|
+
const listeners = new Set();
|
|
218
|
+
observer = {
|
|
219
|
+
listeners,
|
|
220
|
+
published: this.retentionSnapshot(id),
|
|
221
|
+
source: {
|
|
222
|
+
getSnapshot: () => this.retentionSnapshot(id),
|
|
223
|
+
subscribe: (listener) => {
|
|
224
|
+
listeners.add(listener);
|
|
225
|
+
return () => { listeners.delete(listener); };
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
this.retainObservers.set(id, observer);
|
|
230
|
+
}
|
|
231
|
+
return observer.source;
|
|
161
232
|
}
|
|
162
233
|
/**
|
|
163
234
|
* Resolve an already discovered direct-parent address without opening it.
|
|
@@ -183,16 +254,6 @@ export class ClientSessions {
|
|
|
183
254
|
refreshSubagents(parentSessionId) {
|
|
184
255
|
return this.manager.refreshSubagents(parentSessionId);
|
|
185
256
|
}
|
|
186
|
-
/**
|
|
187
|
-
* Clear the current selection so the layout shows the no-session empty
|
|
188
|
-
* state (new-session affordance and the workspace preselection flow).
|
|
189
|
-
* Wipes the persisted selection too — a reload stays on empty until the
|
|
190
|
-
* user opens or starts a session. The staged scope keeps its frozen view
|
|
191
|
-
* per the masked-gap contract until the next open() moves the stage.
|
|
192
|
-
*/
|
|
193
|
-
clear() {
|
|
194
|
-
this.manager.clearSelection();
|
|
195
|
-
}
|
|
196
257
|
/**
|
|
197
258
|
* Refresh the real Session baseline, reusing an in-flight pull.
|
|
198
259
|
* @returns completion of the current or newly started baseline pull.
|
|
@@ -257,12 +318,8 @@ export class ClientSessions {
|
|
|
257
318
|
this.manager.handleConnected();
|
|
258
319
|
}
|
|
259
320
|
/**
|
|
260
|
-
* Create a
|
|
261
|
-
*
|
|
262
|
-
* {@link ClientSessions.binding} resolves it — callers (New Session
|
|
263
|
-
* draft hand-off) may address the scope synchronously, without waiting a
|
|
264
|
-
* notifier flush. The synchronous projection below makes this structural
|
|
265
|
-
* rather than an accident of microtask ordering.
|
|
321
|
+
* Create a Host Session and publish its catalog row before resolving.
|
|
322
|
+
* Callers retain the returned identity before borrowing its binding.
|
|
266
323
|
* @param opts - target workspace or directory and an optional preallocated id.
|
|
267
324
|
* @returns the new session id.
|
|
268
325
|
* @throws {SessionCreateError} with the requested id.
|
|
@@ -275,9 +332,8 @@ export class ClientSessions {
|
|
|
275
332
|
return result.value.sessionId;
|
|
276
333
|
}
|
|
277
334
|
/**
|
|
278
|
-
* Fork a
|
|
279
|
-
*
|
|
280
|
-
* on resolution the child is in the list store and open() can target it).
|
|
335
|
+
* Fork a Session from a completed-turn prefix of the source and publish
|
|
336
|
+
* the child in the catalog before resolving.
|
|
281
337
|
* @param opts - source session id, the optional event seq anchoring the
|
|
282
338
|
* cut (the boundary is the first turn/end at or after it; an in-log
|
|
283
339
|
* anchor in an open turn is unavailable rather than clipped backward),
|
|
@@ -305,33 +361,36 @@ export class ClientSessions {
|
|
|
305
361
|
this.projectList();
|
|
306
362
|
const childId = result.value.sessionId;
|
|
307
363
|
if (sourceTitle !== undefined) {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
364
|
+
const reference = this.retain(childId, { source: 'controllerOperation' });
|
|
365
|
+
try {
|
|
366
|
+
await reference.ready;
|
|
367
|
+
const renamed = await reference.binding.session.rename(increasedForkTitle(sourceTitle));
|
|
368
|
+
if (!renamed.ok)
|
|
369
|
+
throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`);
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
reference.release();
|
|
373
|
+
}
|
|
314
374
|
}
|
|
315
375
|
return childId;
|
|
316
376
|
}
|
|
317
377
|
/**
|
|
318
|
-
*
|
|
378
|
+
* Borrow an already-retained Agent-scoped Context.
|
|
319
379
|
* @param id - session id (the agent identity — 1:1 same axis).
|
|
320
|
-
* @returns scoped
|
|
380
|
+
* @returns the scoped Context, or undefined without a retained generation.
|
|
321
381
|
*/
|
|
322
382
|
scope(id) {
|
|
323
|
-
return this.
|
|
383
|
+
return this.scopes.get(id)?.ctx;
|
|
324
384
|
}
|
|
325
385
|
/**
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* arrival order.
|
|
330
|
-
* @param id - Host-projected Agent identity (the matching Session id).
|
|
331
|
-
* @returns the identity-stable Agent Context.
|
|
386
|
+
* Retain a validated Gateway identity synchronously, without history or catalog I/O.
|
|
387
|
+
* @param id - Host-projected Session identity, possibly not yet catalogued.
|
|
388
|
+
* @returns a Gateway-source reference owned by the invocation.
|
|
332
389
|
*/
|
|
333
|
-
|
|
334
|
-
|
|
390
|
+
retainAgentScope(id) {
|
|
391
|
+
if (this.closed)
|
|
392
|
+
throw new Error('Session Controller is disposed');
|
|
393
|
+
return this.retainScope(id, 'gateway');
|
|
335
394
|
}
|
|
336
395
|
/**
|
|
337
396
|
* Read the Agent scope tag off a context. Service-method boundary: fetch
|
|
@@ -351,62 +410,79 @@ export class ClientSessions {
|
|
|
351
410
|
* `agent.session`). Same service-method boundary as
|
|
352
411
|
* {@link ClientSessions.scopeOf}.
|
|
353
412
|
* @param ctx - an Agent-scoped context.
|
|
354
|
-
* @returns the
|
|
413
|
+
* @returns the matching live Session, or undefined for an untagged or ended generation.
|
|
355
414
|
*/
|
|
356
415
|
sessionOf(ctx) {
|
|
357
416
|
const id = scopeTagOf(ctx);
|
|
358
417
|
if (id === undefined)
|
|
359
418
|
return undefined;
|
|
360
|
-
|
|
419
|
+
const record = this.scopes.get(id);
|
|
420
|
+
return record !== undefined && scopeIdentityOf(record.ctx) === scopeIdentityOf(ctx)
|
|
421
|
+
? record.binding.session
|
|
422
|
+
: undefined;
|
|
361
423
|
}
|
|
362
424
|
/**
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
* @
|
|
366
|
-
* @returns binding, or undefined for a session neither listed nor already scoped.
|
|
425
|
+
* Borrow an already-retained binding without extending its lifetime.
|
|
426
|
+
* @param id - Session identity.
|
|
427
|
+
* @returns the live binding, or undefined without a retained generation.
|
|
367
428
|
*/
|
|
368
429
|
binding(id) {
|
|
369
|
-
return this.
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
430
|
+
return this.scopes.get(id)?.binding;
|
|
431
|
+
}
|
|
432
|
+
retainScope(id, source) {
|
|
433
|
+
const record = this.scopes.get(id) ?? this.materializeScope(id);
|
|
434
|
+
const previous = record.retention;
|
|
435
|
+
record.retention = Object.freeze({
|
|
436
|
+
referenceCount: previous.referenceCount + 1,
|
|
437
|
+
retainedBy: freezeRetainedBy({ ...previous.retainedBy, [source]: (previous.retainedBy[source] ?? 0) + 1 }),
|
|
438
|
+
});
|
|
439
|
+
const reference = new ClientSessionReference(id, record, () => {
|
|
440
|
+
if (!record.live)
|
|
441
|
+
return;
|
|
442
|
+
const count = record.retention.referenceCount - 1;
|
|
443
|
+
const { [source]: sourceCount = 0, ...otherSources } = record.retention.retainedBy;
|
|
444
|
+
const retainedBy = sourceCount > 1 ? { ...otherSources, [source]: sourceCount - 1 } : otherSources;
|
|
445
|
+
record.retention = count === 0
|
|
446
|
+
? EMPTY_RETAIN_INFO
|
|
447
|
+
: Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) });
|
|
448
|
+
if (count === 0)
|
|
449
|
+
this.retireScope(id, record);
|
|
450
|
+
else
|
|
451
|
+
this.publishRetention(id);
|
|
452
|
+
});
|
|
453
|
+
if (this.list.getSnapshot().byId[id] === undefined)
|
|
454
|
+
this.projectList();
|
|
455
|
+
this.publishRetention(id);
|
|
456
|
+
return reference;
|
|
457
|
+
}
|
|
458
|
+
retentionSnapshot(id) {
|
|
459
|
+
return this.scopes.get(id)?.retention ?? EMPTY_RETAIN_INFO;
|
|
460
|
+
}
|
|
461
|
+
publishRetention(id) {
|
|
462
|
+
const state = this.list.getSnapshot();
|
|
463
|
+
const row = state.byId[id];
|
|
464
|
+
const retainedBy = this.retentionSnapshot(id).retainedBy;
|
|
465
|
+
if (row !== undefined && row.retainedBy !== retainedBy) {
|
|
466
|
+
this.list.set({ ...state, byId: { ...state.byId, [id]: { ...row, retainedBy } } });
|
|
395
467
|
}
|
|
468
|
+
const observer = this.retainObservers.get(id);
|
|
469
|
+
const snapshot = this.retentionSnapshot(id);
|
|
470
|
+
if (observer === undefined || observer.published === snapshot)
|
|
471
|
+
return;
|
|
472
|
+
observer.published = snapshot;
|
|
473
|
+
notifySubscribers(observer.listeners, '[session-controller] reference sources');
|
|
396
474
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return undefined;
|
|
409
|
-
return this.materializeScope(id);
|
|
475
|
+
retireScope(id, record, disposeFiber = true) {
|
|
476
|
+
if (!record.live)
|
|
477
|
+
return;
|
|
478
|
+
record.live = false;
|
|
479
|
+
if (this.scopes.get(id) === record)
|
|
480
|
+
this.scopes.delete(id);
|
|
481
|
+
record.session.unbindScope();
|
|
482
|
+
const sessionDisposal = this.manager.drop(id, record.session);
|
|
483
|
+
this.projectList();
|
|
484
|
+
this.publishRetention(id);
|
|
485
|
+
this.startScopeDrop(id, record, disposeFiber, sessionDisposal);
|
|
410
486
|
}
|
|
411
487
|
/** Materialize one scope after its caller establishes that the id may be addressed. */
|
|
412
488
|
materializeScope(id) {
|
|
@@ -421,18 +497,17 @@ export class ClientSessions {
|
|
|
421
497
|
ctx,
|
|
422
498
|
binding,
|
|
423
499
|
session,
|
|
500
|
+
retention: EMPTY_RETAIN_INFO,
|
|
501
|
+
live: true,
|
|
424
502
|
};
|
|
425
503
|
this.scopes.set(id, record);
|
|
504
|
+
ctx.effect(() => () => { this.retireScope(id, record, false); }, 'session-controller: exact generation');
|
|
426
505
|
return record;
|
|
427
506
|
}
|
|
428
|
-
/** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
|
|
429
|
-
eligible(id) {
|
|
430
|
-
const { ids, current } = this.list.getSnapshot();
|
|
431
|
-
return current === id || ids.includes(id);
|
|
432
|
-
}
|
|
433
507
|
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
|
434
508
|
projectList() {
|
|
435
|
-
const
|
|
509
|
+
const previousById = this.list.getSnapshot().byId;
|
|
510
|
+
const { items, phase, subagentsByParent, jobsBySession, } = this.manager.getListSnapshot();
|
|
436
511
|
const ids = [];
|
|
437
512
|
const byId = {};
|
|
438
513
|
for (const entry of items) {
|
|
@@ -441,7 +516,7 @@ export class ClientSessions {
|
|
|
441
516
|
id: entry.sessionId,
|
|
442
517
|
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
|
443
518
|
running: entry.running,
|
|
444
|
-
|
|
519
|
+
retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy,
|
|
445
520
|
blank: entry.blank,
|
|
446
521
|
updatedAt: entry.updatedAt,
|
|
447
522
|
...(entry.projectionValues === undefined
|
|
@@ -453,76 +528,52 @@ export class ClientSessions {
|
|
|
453
528
|
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
|
454
529
|
};
|
|
455
530
|
}
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const childId =
|
|
461
|
-
seen.add(childId);
|
|
462
|
-
const child = subagentsByParent[address.parentSessionId]?.entries
|
|
463
|
-
.find(entry => entry.kind === 'child' && entry.id === childId);
|
|
464
|
-
if (child?.kind !== 'child')
|
|
465
|
-
break;
|
|
466
|
-
const displayTitle = child.label ?? childId;
|
|
531
|
+
for (const [parentId, catalog] of Object.entries(subagentsByParent)) {
|
|
532
|
+
for (const child of catalog.entries) {
|
|
533
|
+
if (child.kind !== 'child')
|
|
534
|
+
continue;
|
|
535
|
+
const childId = child.id;
|
|
467
536
|
const summary = byId[childId];
|
|
537
|
+
const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId);
|
|
538
|
+
const projectedTitle = projectionValues?.title;
|
|
539
|
+
const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined;
|
|
540
|
+
const displayTitle = title ?? child.label ?? childId;
|
|
468
541
|
if (summary === undefined) {
|
|
469
542
|
byId[childId] = {
|
|
470
|
-
id: childId,
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
blank: false,
|
|
476
|
-
updatedAt: 0,
|
|
543
|
+
id: childId, displayTitle, parentId: parentId,
|
|
544
|
+
origin: 'subagent', running: child.activity === 'running', blank: false, updatedAt: 0,
|
|
545
|
+
retainedBy: this.retentionSnapshot(childId).retainedBy,
|
|
546
|
+
...(projectionValues === undefined ? {} : { projectionValues }),
|
|
547
|
+
...(title === undefined ? {} : { title }),
|
|
477
548
|
};
|
|
478
549
|
}
|
|
479
|
-
else if (summary.displayTitle !== displayTitle) {
|
|
480
|
-
byId[childId] = {
|
|
550
|
+
else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) {
|
|
551
|
+
byId[childId] = {
|
|
552
|
+
...summary,
|
|
553
|
+
displayTitle,
|
|
554
|
+
...(projectionValues === undefined ? {} : { projectionValues }),
|
|
555
|
+
};
|
|
481
556
|
}
|
|
482
|
-
const parent = byId[address.parentSessionId];
|
|
483
|
-
if (parent !== undefined && parent.origin !== 'subagent')
|
|
484
|
-
break;
|
|
485
|
-
address = this.manager.navigationAddress(address.parentSessionId);
|
|
486
557
|
}
|
|
487
558
|
}
|
|
488
|
-
const persisted = this.selection.getSnapshot().sessionId;
|
|
489
|
-
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
|
490
|
-
// stays on empty; the in-memory selection still resurfaces a masked id.
|
|
491
|
-
if (current === undefined) {
|
|
492
|
-
if (persisted !== undefined)
|
|
493
|
-
this.selection.set({});
|
|
494
|
-
}
|
|
495
|
-
else if (byId[current] !== undefined
|
|
496
|
-
&& (persisted !== current
|
|
497
|
-
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|
|
498
|
-
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
|
|
499
|
-
|| this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
|
|
500
|
-
this.selection.set({
|
|
501
|
-
sessionId: current,
|
|
502
|
-
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress });
|
|
506
|
-
this.pruneScopes();
|
|
507
|
-
}
|
|
508
|
-
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
|
509
|
-
pruneScopes() {
|
|
510
|
-
if (this.list.getSnapshot().phase === 'pending')
|
|
511
|
-
return;
|
|
512
559
|
for (const [id, record] of this.scopes) {
|
|
513
|
-
if (
|
|
560
|
+
if (byId[id] !== undefined)
|
|
514
561
|
continue;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
562
|
+
const previous = previousById[id];
|
|
563
|
+
const snapshot = record.session.getSnapshot();
|
|
564
|
+
const address = this.manager.subagentAddress(id);
|
|
565
|
+
byId[id] = {
|
|
566
|
+
...(previous ?? { id, displayTitle: id, updatedAt: 0 }),
|
|
567
|
+
running: snapshot.running,
|
|
568
|
+
retainedBy: record.retention.retainedBy,
|
|
569
|
+
blank: snapshot.blank,
|
|
570
|
+
...(address === undefined ? {} : { parentId: address.parentSessionId, origin: 'subagent' }),
|
|
571
|
+
};
|
|
522
572
|
}
|
|
573
|
+
this.list.set({ ids, byId, phase, subagentsByParent, jobsBySession });
|
|
523
574
|
}
|
|
524
|
-
startScopeDrop(id, record) {
|
|
525
|
-
const drop = this.dropScope(
|
|
575
|
+
startScopeDrop(id, record, disposeFiber = true, sessionDisposal = this.manager.drop(id, record.session)) {
|
|
576
|
+
const drop = this.dropScope(record, disposeFiber, sessionDisposal);
|
|
526
577
|
this.scopeDrops.add(drop);
|
|
527
578
|
void drop.then(() => { this.scopeDrops.delete(drop); }, () => { this.scopeDrops.delete(drop); });
|
|
528
579
|
}
|
|
@@ -531,45 +582,9 @@ export class ClientSessions {
|
|
|
531
582
|
await Promise.allSettled([...this.scopeDrops]);
|
|
532
583
|
}
|
|
533
584
|
}
|
|
534
|
-
/**
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
* controller, popup, plugin stores, listeners), the session-keyed slot
|
|
538
|
-
* registrations and the Session instance itself — the host session log is the
|
|
539
|
-
* durable truth, a reopen lazily rebuilds and backfills via open().
|
|
540
|
-
*/
|
|
541
|
-
async dropScope(id, record) {
|
|
542
|
-
// Release the Session's dispatch point with the scope it belongs to (a
|
|
543
|
-
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
|
544
|
-
record.session.unbindScope();
|
|
545
|
-
await Promise.allSettled([
|
|
546
|
-
record.fiber.dispose(),
|
|
547
|
-
this.manager.drop(id),
|
|
548
|
-
]);
|
|
549
|
-
}
|
|
550
|
-
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
|
551
|
-
sweepDeferred() {
|
|
552
|
-
for (const id of [...this.deferredRemovals]) {
|
|
553
|
-
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
|
554
|
-
* stage move sweeps first, so the set cannot contain the id the stage just
|
|
555
|
-
* moved to; kept as a guard against future extra sweep call sites. */
|
|
556
|
-
if (id === this.watched)
|
|
557
|
-
continue;
|
|
558
|
-
// Eligible again? (A re-added id cancels the deferred teardown.)
|
|
559
|
-
if (this.eligible(id)) {
|
|
560
|
-
this.deferredRemovals.delete(id);
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
const record = this.scopes.get(id);
|
|
564
|
-
this.deferredRemovals.delete(id);
|
|
565
|
-
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
|
566
|
-
* together, so a deferred id always still owns its record; kept so a
|
|
567
|
-
* future teardown path cannot double-dispose. */
|
|
568
|
-
if (record !== undefined) {
|
|
569
|
-
this.scopes.delete(id);
|
|
570
|
-
this.startScopeDrop(id, record);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
585
|
+
/** Await the already-withdrawn Session and scoped cleanup to quiescence. */
|
|
586
|
+
async dropScope(record, disposeFiber, sessionDisposal) {
|
|
587
|
+
await Promise.allSettled([sessionDisposal, ...disposeFiber ? [record.fiber.dispose()] : []]);
|
|
573
588
|
}
|
|
574
589
|
}
|
|
575
590
|
//# sourceMappingURL=service.js.map
|