@deepseek-ai/dsh-api-session-controller 0.1.6-alpha.2 → 0.1.7-alpha.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 +31 -8
- package/README.zh.md +31 -8
- package/lib/client.js +603 -515
- package/lib/index.js +299 -137
- package/lib/typert.host.js +373 -183
- package/lib/typert.remote-client.d.ts +10 -5
- package/lib/typert.remote-client.js +216 -124
- package/lib/types/archived-session-gate.d.ts +30 -0
- package/lib/types/archived-session-gate.js +52 -0
- package/lib/types/client/contract/session.d.ts +2 -0
- package/lib/types/client/contract/sessions.d.ts +11 -16
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/sessions/lineage.d.ts +4 -1
- package/lib/types/client/sessions/lineage.js +2 -1
- package/lib/types/client/sessions/manager.d.ts +66 -59
- package/lib/types/client/sessions/manager.js +249 -277
- package/lib/types/client/sessions/projection-store.d.ts +32 -13
- package/lib/types/client/sessions/projection-store.js +45 -13
- package/lib/types/client/sessions/remotes.d.ts +1 -2
- package/lib/types/client/sessions/service.d.ts +23 -35
- package/lib/types/client/sessions/service.js +35 -47
- package/lib/types/client/sessions/session.d.ts +18 -16
- package/lib/types/client/sessions/session.js +112 -50
- package/lib/types/commands.d.ts +4 -2
- package/lib/types/commands.js +72 -36
- package/lib/types/control.d.ts +2 -4
- package/lib/types/control.js +2 -44
- package/lib/types/history.js +1 -1
- package/lib/types/index.d.ts +32 -5
- package/lib/types/index.js +117 -13
- package/lib/types/list.js +24 -15
- package/lib/types/skill-catalog.js +60 -50
- package/lib/types/types.d.ts +37 -23
- package/lib/types/types.js +0 -1
- package/package.json +71 -76
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { SessionSeq } from '@deepseek-ai/dsh-session/types';
|
|
3
3
|
import { mergeOrderedBaseline } from "../ordered-baseline.js";
|
|
4
4
|
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client';
|
|
5
|
+
import { assertNever } from '@deepseek-ai/dsh-util-values';
|
|
5
6
|
import { flattenLineage } from "./lineage.js";
|
|
6
7
|
import { Notifier } from "./notifier.js";
|
|
7
8
|
import { ProjectionValueStore } from "./projection-store.js";
|
|
@@ -9,15 +10,21 @@ import { Session } from "./session.js";
|
|
|
9
10
|
function sessionSeqCursor(value) {
|
|
10
11
|
return value === -1 ? -1 : SessionSeq(value);
|
|
11
12
|
}
|
|
12
|
-
function catalogAvailability(parentAvailable) {
|
|
13
|
-
return parentAvailable === undefined ? {} : { parentAvailable };
|
|
14
|
-
}
|
|
15
13
|
/** Instance cluster + frame entry + the session list. */
|
|
16
14
|
export class SessionManager {
|
|
17
15
|
remote;
|
|
18
16
|
sessions = new Map();
|
|
19
17
|
/** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
|
|
20
18
|
sessionDisposals = new Set();
|
|
19
|
+
/**
|
|
20
|
+
* Accepted/running presentation must survive a later empty-history list
|
|
21
|
+
* response. Host-asserted running is recorded even before a row, instance, or
|
|
22
|
+
* address holds the identity — the listing that would hold it may not have
|
|
23
|
+
* landed yet — while the client-local acceptance callback requires a current
|
|
24
|
+
* holder, because it can arrive from a replaced or already-dropped Session.
|
|
25
|
+
*/
|
|
26
|
+
engagedSessions = new Set();
|
|
27
|
+
disposed = false;
|
|
21
28
|
/** Per-session projection value stores, retained independently of instance arrival (the
|
|
22
29
|
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
|
23
30
|
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
|
@@ -32,18 +39,8 @@ export class SessionManager {
|
|
|
32
39
|
/** Active list request's mutation log; its identity also fences completion after reconnect. */
|
|
33
40
|
listMutations = null;
|
|
34
41
|
addresses = new Map();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
|
|
38
|
-
catalogStale = new Set();
|
|
39
|
-
openCatalogs = new Set();
|
|
40
|
-
catalogDebounce = new Map();
|
|
41
|
-
/**
|
|
42
|
-
* Background jobs per session, last-wins from Session Controller's control
|
|
43
|
-
* stream. An empty set is stored as an absent key, so absence and `[]` are
|
|
44
|
-
* one representation.
|
|
45
|
-
*/
|
|
46
|
-
jobsBySession = new Map();
|
|
42
|
+
projectionLoads = new Map();
|
|
43
|
+
projectionInflight = new Map();
|
|
47
44
|
listSnapshotCache;
|
|
48
45
|
/** Entry-identity cache (reference stability): list rebuilds reuse the previous entry
|
|
49
46
|
* object when every field matches — wire refreshes mint all-new summary objects, so identity
|
|
@@ -65,7 +62,7 @@ export class SessionManager {
|
|
|
65
62
|
*/
|
|
66
63
|
resolveTarget(target) {
|
|
67
64
|
const id = typeof target === 'string' ? target : target.childSessionId;
|
|
68
|
-
const address = typeof target === 'string' ? this.
|
|
65
|
+
const address = typeof target === 'string' ? this.subagentAddress(id) : target;
|
|
69
66
|
if (typeof target === 'string'
|
|
70
67
|
&& !this.sessions.has(id)
|
|
71
68
|
&& !this.summaries.some(summary => summary.sessionId === id)
|
|
@@ -74,30 +71,29 @@ export class SessionManager {
|
|
|
74
71
|
}
|
|
75
72
|
if (address !== undefined)
|
|
76
73
|
this.addresses.set(id, address);
|
|
77
|
-
this.sessions.get(id)?.configureSubagent(address, address === undefined
|
|
74
|
+
this.sessions.get(id)?.configureSubagent(address, address === undefined
|
|
75
|
+
? undefined
|
|
76
|
+
: this.agentAvailable(address.parentSessionId));
|
|
78
77
|
return id;
|
|
79
78
|
}
|
|
80
|
-
/**
|
|
81
|
-
* Return the durable catalog address retained for one child.
|
|
82
|
-
* @param sessionId - possible addressed child id.
|
|
83
|
-
* @returns The direct-parent address, when navigation discovered one.
|
|
84
|
-
*/
|
|
85
|
-
subagentAddress(sessionId) {
|
|
86
|
-
return this.navigationAddress(sessionId);
|
|
87
|
-
}
|
|
88
79
|
/**
|
|
89
80
|
* Resolve an address for breadcrumb navigation without retaining transport authority.
|
|
90
81
|
* @param sessionId - possible child id in an already-loaded catalog.
|
|
91
82
|
* @returns A retained or catalog-derived direct-parent address.
|
|
92
83
|
*/
|
|
93
|
-
|
|
84
|
+
subagentAddress(sessionId) {
|
|
94
85
|
const retained = this.addresses.get(sessionId);
|
|
95
86
|
if (retained !== undefined)
|
|
96
87
|
return retained;
|
|
97
|
-
for (const
|
|
98
|
-
const child =
|
|
99
|
-
|
|
100
|
-
|
|
88
|
+
for (const parentSessionId of this.projectionStores.keys()) {
|
|
89
|
+
const child = this.projectionStores.get(parentSessionId)?.values().subagentCatalog
|
|
90
|
+
?.find(entry => entry.id === sessionId);
|
|
91
|
+
if (child !== undefined) {
|
|
92
|
+
return {
|
|
93
|
+
parentSessionId,
|
|
94
|
+
childSessionId: sessionId,
|
|
95
|
+
mode: child.mode,
|
|
96
|
+
};
|
|
101
97
|
}
|
|
102
98
|
}
|
|
103
99
|
return undefined;
|
|
@@ -115,6 +111,7 @@ export class SessionManager {
|
|
|
115
111
|
return Promise.resolve();
|
|
116
112
|
this.sessions.delete(sessionId);
|
|
117
113
|
this.addresses.delete(sessionId);
|
|
114
|
+
this.pruneEngagement(sessionId, this.retainedIds(this.summaries));
|
|
118
115
|
return this.startSessionDisposal(session);
|
|
119
116
|
}
|
|
120
117
|
/**
|
|
@@ -122,11 +119,15 @@ export class SessionManager {
|
|
|
122
119
|
* @returns once catalog requests and every Session stream have stopped.
|
|
123
120
|
*/
|
|
124
121
|
async dispose() {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
this.
|
|
128
|
-
this.
|
|
129
|
-
this.
|
|
122
|
+
this.disposed = true;
|
|
123
|
+
this.listMutations = null;
|
|
124
|
+
this.listInflight = null;
|
|
125
|
+
this.engagedSessions.clear();
|
|
126
|
+
const reads = [...this.projectionInflight.values()];
|
|
127
|
+
for (const { controller } of reads)
|
|
128
|
+
controller.abort();
|
|
129
|
+
this.projectionInflight.clear();
|
|
130
|
+
await Promise.all(reads.map(read => read.promise));
|
|
130
131
|
const sessions = [...this.sessions.values()];
|
|
131
132
|
this.sessions.clear();
|
|
132
133
|
this.addresses.clear();
|
|
@@ -148,6 +149,7 @@ export class SessionManager {
|
|
|
148
149
|
/**
|
|
149
150
|
* Lazy build: return the existing instance or construct one (no auto-open —
|
|
150
151
|
* the reference allocator opens history after binding the scope).
|
|
152
|
+
* New instances reconcile retained metadata before returning.
|
|
151
153
|
* @param sessionId - the session to get.
|
|
152
154
|
* @returns the resident instance.
|
|
153
155
|
*/
|
|
@@ -160,18 +162,22 @@ export class SessionManager {
|
|
|
160
162
|
// instance (consistency when the list precedes open).
|
|
161
163
|
const summary = this.summaries.find(s => s.sessionId === sessionId);
|
|
162
164
|
if (summary !== undefined) {
|
|
163
|
-
session.handleBlank(summary
|
|
165
|
+
session.handleBlank(this.effectiveBlank(summary));
|
|
164
166
|
session.handleRunning(summary.running);
|
|
165
167
|
}
|
|
166
168
|
else {
|
|
167
169
|
const address = this.addresses.get(sessionId);
|
|
168
|
-
const child = address === undefined ? undefined : this.
|
|
169
|
-
|
|
170
|
-
if (child
|
|
170
|
+
const child = address === undefined ? undefined : this.projectionStores.get(address.parentSessionId)?.values().subagentCatalog
|
|
171
|
+
?.find(entry => entry.id === sessionId);
|
|
172
|
+
if (child !== undefined) {
|
|
171
173
|
// A catalogued child exists only after its delegated session has
|
|
172
174
|
// durable history, even though child rows do not carry `blank`.
|
|
173
175
|
session.handleBlank(false);
|
|
174
|
-
session.handleRunning(
|
|
176
|
+
session.handleRunning(false);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Retained metadata may have notified before this Session existed.
|
|
180
|
+
session.handleBlank(true);
|
|
175
181
|
}
|
|
176
182
|
}
|
|
177
183
|
}
|
|
@@ -181,127 +187,131 @@ export class SessionManager {
|
|
|
181
187
|
const address = this.addresses.get(sessionId);
|
|
182
188
|
const parentAvailable = address === undefined
|
|
183
189
|
? undefined
|
|
184
|
-
: this.
|
|
190
|
+
: this.agentAvailable(address.parentSessionId);
|
|
185
191
|
return new Session(sessionId, this.remote, {
|
|
186
192
|
...(address === undefined ? {} : {
|
|
187
193
|
address,
|
|
188
|
-
...
|
|
194
|
+
...parentAvailable === undefined ? {} : { parentAvailable },
|
|
189
195
|
}),
|
|
190
196
|
// The sender's local first-send flip mirrors into the list row so the
|
|
191
197
|
// session surfaces (lists filter on blank) before any host frame lands.
|
|
192
198
|
onEngaged: (engaged) => {
|
|
193
|
-
this.
|
|
199
|
+
if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId))
|
|
200
|
+
return;
|
|
201
|
+
// An identity already on the ledger needs no second row mutation.
|
|
202
|
+
if (!this.engagedSessions.has(engaged.sessionId)) {
|
|
203
|
+
this.engagedSessions.add(engaged.sessionId);
|
|
204
|
+
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId });
|
|
205
|
+
}
|
|
206
|
+
this.sessions.get(engaged.sessionId)?.handleBlank(false);
|
|
194
207
|
},
|
|
195
208
|
projections: this.projectionStore(sessionId),
|
|
196
209
|
});
|
|
197
210
|
}
|
|
211
|
+
effectiveBlank(summary) {
|
|
212
|
+
return summary.blank && !this.engagedSessions.has(summary.sessionId);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Identities an engagement may still belong to: the given list rows, resident
|
|
216
|
+
* Session instances, and retained child addresses.
|
|
217
|
+
* @param summaries - list rows of the caller's snapshot.
|
|
218
|
+
* @returns the retained identity set.
|
|
219
|
+
*/
|
|
220
|
+
retainedIds(summaries) {
|
|
221
|
+
const retained = new Set(summaries.map(summary => summary.sessionId));
|
|
222
|
+
for (const sessionId of this.sessions.keys())
|
|
223
|
+
retained.add(sessionId);
|
|
224
|
+
for (const sessionId of this.addresses.keys())
|
|
225
|
+
retained.add(sessionId);
|
|
226
|
+
return retained;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Forget one engagement that no retained identity holds.
|
|
230
|
+
* @param sessionId - identity whose engagement may be dropped.
|
|
231
|
+
* @param retained - identities from {@link retainedIds} for the caller's snapshot.
|
|
232
|
+
*/
|
|
233
|
+
pruneEngagement(sessionId, retained) {
|
|
234
|
+
if (!retained.has(sessionId))
|
|
235
|
+
this.engagedSessions.delete(sessionId);
|
|
236
|
+
}
|
|
198
237
|
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
|
|
199
238
|
projectionStore(sessionId) {
|
|
200
239
|
let store = this.projectionStores.get(sessionId);
|
|
201
240
|
if (store === undefined) {
|
|
202
241
|
store = new ProjectionValueStore();
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
242
|
+
const projections = store;
|
|
243
|
+
store.subscribeAny(() => {
|
|
244
|
+
// Newer history or control metadata corrects a resident Session's stale list hint.
|
|
245
|
+
if (projections.values().sessionListMetadata?.blank === false) {
|
|
246
|
+
this.sessions.get(sessionId)?.handleBlank(false);
|
|
247
|
+
}
|
|
248
|
+
this.notifier.markDirty();
|
|
249
|
+
});
|
|
206
250
|
this.projectionStores.set(sessionId, store);
|
|
207
251
|
}
|
|
208
252
|
return store;
|
|
209
253
|
}
|
|
210
254
|
/**
|
|
211
|
-
*
|
|
212
|
-
* @param
|
|
255
|
+
* Load a complete projection baseline once per connection; retry unsuccessful reads.
|
|
256
|
+
* @param sessionId - Session to inspect without opening its conversation.
|
|
257
|
+
* @returns completion of the current or newly started read.
|
|
213
258
|
*/
|
|
214
|
-
|
|
215
|
-
const existing = this.
|
|
259
|
+
refreshProjections(sessionId) {
|
|
260
|
+
const existing = this.projectionInflight.get(sessionId);
|
|
216
261
|
if (existing !== undefined)
|
|
217
262
|
return existing.promise;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
this.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
? {}
|
|
225
|
-
: { parentAvailable: previous.parentAvailable }),
|
|
226
|
-
state: 'loading',
|
|
227
|
-
error: null,
|
|
228
|
-
});
|
|
263
|
+
if (this.projectionLoads.get(sessionId)?.state === 'ready')
|
|
264
|
+
return Promise.resolve();
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const store = this.projectionStore(sessionId);
|
|
267
|
+
const initialValues = store.values();
|
|
268
|
+
this.projectionLoads.set(sessionId, { state: 'loading', error: null });
|
|
229
269
|
this.notifier.markDirty();
|
|
230
270
|
const operation = (async () => {
|
|
231
271
|
try {
|
|
232
|
-
const result = await this.remote.
|
|
272
|
+
const result = await this.remote.session.projections({ sessionId }, controller.signal);
|
|
273
|
+
if (controller.signal.aborted)
|
|
274
|
+
return;
|
|
233
275
|
if (result.ok) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
state: 'ready',
|
|
241
|
-
error: null,
|
|
242
|
-
});
|
|
243
|
-
for (const [childId, address] of this.addresses) {
|
|
244
|
-
if (address.parentSessionId !== parentSessionId)
|
|
245
|
-
continue;
|
|
246
|
-
this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable);
|
|
276
|
+
if (result.value !== null) {
|
|
277
|
+
store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) });
|
|
278
|
+
}
|
|
279
|
+
else if (store.values() === initialValues) {
|
|
280
|
+
// A later frame proves existence independently of an earlier missing read.
|
|
281
|
+
store.clear();
|
|
247
282
|
}
|
|
283
|
+
this.projectionLoads.set(sessionId, { state: 'ready', error: null });
|
|
248
284
|
}
|
|
249
285
|
else {
|
|
250
|
-
this.
|
|
251
|
-
entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
|
|
252
|
-
...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
|
253
|
-
?? previous?.parentAvailable),
|
|
254
|
-
state: 'error',
|
|
255
|
-
error: result.error,
|
|
256
|
-
});
|
|
286
|
+
this.projectionLoads.set(sessionId, { state: 'error', error: result.error });
|
|
257
287
|
}
|
|
258
288
|
}
|
|
259
289
|
catch (error) {
|
|
290
|
+
if (controller.signal.aborted)
|
|
291
|
+
return;
|
|
260
292
|
if (!isRemoteFailure(error))
|
|
261
293
|
throw error;
|
|
262
|
-
this.
|
|
263
|
-
entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
|
|
264
|
-
...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
|
265
|
-
?? previous?.parentAvailable),
|
|
266
|
-
state: 'error',
|
|
267
|
-
error,
|
|
268
|
-
});
|
|
294
|
+
this.projectionLoads.set(sessionId, { state: 'error', error });
|
|
269
295
|
}
|
|
270
296
|
finally {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (this.catalogStale.delete(parentSessionId))
|
|
276
|
-
void this.refreshSubagents(parentSessionId);
|
|
277
|
-
this.notifier.markDirty();
|
|
297
|
+
if (!controller.signal.aborted) {
|
|
298
|
+
this.projectionInflight.delete(sessionId);
|
|
299
|
+
this.notifier.markDirty();
|
|
300
|
+
}
|
|
278
301
|
}
|
|
279
302
|
})();
|
|
280
|
-
this.
|
|
281
|
-
promise: operation,
|
|
282
|
-
expandableRows,
|
|
283
|
-
activityRows,
|
|
284
|
-
parentAvailableOverride: undefined,
|
|
285
|
-
});
|
|
303
|
+
this.projectionInflight.set(sessionId, { promise: operation, controller });
|
|
286
304
|
return operation;
|
|
287
305
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
else {
|
|
299
|
-
this.openCatalogs.delete(parentSessionId);
|
|
300
|
-
const timer = this.catalogDebounce.get(parentSessionId);
|
|
301
|
-
if (timer !== undefined) {
|
|
302
|
-
clearTimeout(timer);
|
|
303
|
-
this.catalogDebounce.delete(parentSessionId);
|
|
304
|
-
}
|
|
306
|
+
agentAvailable(sessionId) {
|
|
307
|
+
return this.summaries.find(summary => summary.sessionId === sessionId)?.agentAvailable
|
|
308
|
+
?? (this.listPhase === 'ready' ? false : undefined);
|
|
309
|
+
}
|
|
310
|
+
updateParentAvailability() {
|
|
311
|
+
for (const [childId, address] of this.addresses) {
|
|
312
|
+
const available = this.agentAvailable(address.parentSessionId);
|
|
313
|
+
if (available !== undefined)
|
|
314
|
+
this.sessions.get(childId)?.handleSubagentParentAvailable(available);
|
|
305
315
|
}
|
|
306
316
|
}
|
|
307
317
|
// ---- List API ----
|
|
@@ -324,31 +334,40 @@ export class SessionManager {
|
|
|
324
334
|
const baseline = this.listPhase === 'pending'
|
|
325
335
|
? [...result.value.items]
|
|
326
336
|
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId);
|
|
327
|
-
|
|
337
|
+
// A removal supersedes running observed in the pull, including after re-addition.
|
|
338
|
+
const removedSincePull = new Set();
|
|
339
|
+
for (const mutation of mutations) {
|
|
340
|
+
if (mutation.kind === 'remove')
|
|
341
|
+
removedSincePull.add(mutation.sessionId);
|
|
342
|
+
}
|
|
343
|
+
for (const s of baseline) {
|
|
344
|
+
if (s.running && !removedSincePull.has(s.sessionId))
|
|
345
|
+
this.engagedSessions.add(s.sessionId);
|
|
346
|
+
}
|
|
347
|
+
const summaries = mutations.reduce(applyMutation, baseline);
|
|
348
|
+
this.summaries = summaries;
|
|
349
|
+
// A full list can remove identities without a removal frame.
|
|
350
|
+
const retained = this.retainedIds(summaries);
|
|
351
|
+
for (const sessionId of this.engagedSessions)
|
|
352
|
+
this.pruneEngagement(sessionId, retained);
|
|
328
353
|
this.listState = 'idle';
|
|
329
354
|
this.listPhase = 'ready';
|
|
330
|
-
|
|
355
|
+
this.updateParentAvailability();
|
|
356
|
+
// Resident Sessions and list rows share one display blank, reconciled with the metadata projection.
|
|
331
357
|
for (const s of this.summaries) {
|
|
332
358
|
const session = this.sessions.get(s.sessionId);
|
|
333
359
|
if (session === undefined)
|
|
334
360
|
continue;
|
|
335
|
-
session.handleBlank(s
|
|
361
|
+
session.handleBlank(this.effectiveBlank(s));
|
|
336
362
|
session.handleRunning(s.running);
|
|
337
363
|
}
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// must not clear; higher-seq-wins still keeps a stale list block
|
|
343
|
-
// from overwriting a newer push frame or tail baseline.
|
|
364
|
+
// Land each row's projection block in the per-session value store
|
|
365
|
+
// (cold titles surface without opening the session). A list block is
|
|
366
|
+
// partial — only the keys its source holds — so an absent key never
|
|
367
|
+
// clears; how the present keys merge depends on the block's kind.
|
|
344
368
|
for (const s of result.value.items) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
continue;
|
|
348
|
-
const store = this.projectionStore(s.sessionId);
|
|
349
|
-
const values = block.values;
|
|
350
|
-
for (const key of Object.keys(values))
|
|
351
|
-
store.apply(key, values[key], sessionSeqCursor(block.asOfSeq));
|
|
369
|
+
if (s.projections !== undefined)
|
|
370
|
+
this.applyListBlock(s.sessionId, s.projections);
|
|
352
371
|
}
|
|
353
372
|
}
|
|
354
373
|
else {
|
|
@@ -407,7 +426,7 @@ export class SessionManager {
|
|
|
407
426
|
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared };
|
|
408
427
|
const result = await this.remote.session.create(payload);
|
|
409
428
|
if (result.ok) {
|
|
410
|
-
this.recordMutation({ kind: '
|
|
429
|
+
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
|
|
411
430
|
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
|
412
431
|
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
413
432
|
} });
|
|
@@ -418,7 +437,7 @@ export class SessionManager {
|
|
|
418
437
|
// so expose it immediately as Ungrouped while the caller keeps the
|
|
419
438
|
// prompt buffer and decides whether to retry attachment.
|
|
420
439
|
if (publishedSessionId !== undefined) {
|
|
421
|
-
this.recordMutation({ kind: '
|
|
440
|
+
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
|
|
422
441
|
sessionId: publishedSessionId,
|
|
423
442
|
updatedAt: Date.now(),
|
|
424
443
|
running: false,
|
|
@@ -430,11 +449,12 @@ export class SessionManager {
|
|
|
430
449
|
}
|
|
431
450
|
/**
|
|
432
451
|
* Contract session.fork; on success merge the child into summaries
|
|
433
|
-
* immediately (same synchronous-addressability guarantee as create).
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
452
|
+
* immediately (same synchronous-addressability guarantee as create).
|
|
453
|
+
* Blankness starts provisionally true so the authoritative Host summary can
|
|
454
|
+
* preserve it or lower it after an exact cut before the first `turn/start`;
|
|
455
|
+
* lineage rides parentSessionId. A child published before Workspace
|
|
456
|
+
* attachment fails is also reconciled into the list.
|
|
457
|
+
* @param opts - source session and the optional exact inclusive boundary seq.
|
|
438
458
|
* @returns the fork result (the child session id).
|
|
439
459
|
*/
|
|
440
460
|
async fork(opts) {
|
|
@@ -447,8 +467,8 @@ export class SessionManager {
|
|
|
447
467
|
? result.value.sessionId
|
|
448
468
|
: workspaceAttachSessionId(result.error);
|
|
449
469
|
if (childId !== undefined) {
|
|
450
|
-
this.recordMutation({ kind: '
|
|
451
|
-
sessionId: childId, updatedAt: Date.now(), running: false, blank:
|
|
470
|
+
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
|
|
471
|
+
sessionId: childId, updatedAt: Date.now(), running: false, blank: true,
|
|
452
472
|
parentSessionId: opts.sessionId,
|
|
453
473
|
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
|
454
474
|
} });
|
|
@@ -456,16 +476,30 @@ export class SessionManager {
|
|
|
456
476
|
return result;
|
|
457
477
|
}
|
|
458
478
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
479
|
+
* Rename a Session and update its title projection without opening its history.
|
|
480
|
+
* @param sessionId - Session to rename.
|
|
481
|
+
* @param title - raw title text for Host normalization.
|
|
482
|
+
* @returns the accepted title and event position, or the Remote failure.
|
|
483
|
+
*/
|
|
484
|
+
async rename(sessionId, title) {
|
|
485
|
+
const result = await this.remote.session.rename({ sessionId, title });
|
|
486
|
+
if (result.ok) {
|
|
487
|
+
this.projectionStore(sessionId).apply('title', result.value.title, SessionSeq(result.value.seq));
|
|
488
|
+
}
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Merge a Host summary, replacing live state and filling missing metadata.
|
|
493
|
+
* Local create/fork placeholders only fill metadata on an existing row.
|
|
463
494
|
*/
|
|
464
495
|
mergeSummary(summary) {
|
|
465
496
|
this.recordMutation({ kind: 'upsert', summary });
|
|
497
|
+
this.updateParentAvailability();
|
|
466
498
|
}
|
|
467
499
|
/** Apply immediately and retain for replay when a list response is in flight. */
|
|
468
500
|
recordMutation(mutation) {
|
|
501
|
+
if (this.disposed)
|
|
502
|
+
return;
|
|
469
503
|
this.listMutations?.push(mutation);
|
|
470
504
|
this.summaries = applyMutation(this.summaries, mutation);
|
|
471
505
|
this.notifier.markDirty();
|
|
@@ -505,23 +539,10 @@ export class SessionManager {
|
|
|
505
539
|
this.replaceControlBaseline(frame.value);
|
|
506
540
|
return;
|
|
507
541
|
}
|
|
508
|
-
|
|
509
|
-
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq));
|
|
510
|
-
this.notifier.markDirty();
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
if (frame.jobs.length === 0)
|
|
514
|
-
this.jobsBySession.delete(frame.sessionId);
|
|
515
|
-
else
|
|
516
|
-
this.jobsBySession.set(frame.sessionId, frame.jobs);
|
|
542
|
+
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq));
|
|
517
543
|
this.notifier.markDirty();
|
|
518
544
|
}
|
|
519
545
|
replaceControlBaseline(baseline) {
|
|
520
|
-
this.jobsBySession.clear();
|
|
521
|
-
for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
|
|
522
|
-
if (jobs.length > 0)
|
|
523
|
-
this.jobsBySession.set(sessionId, jobs);
|
|
524
|
-
}
|
|
525
546
|
for (const [sessionId, block] of Object.entries(baseline.projections)) {
|
|
526
547
|
const store = this.projectionStore(sessionId);
|
|
527
548
|
const asOfSeq = sessionSeqCursor(block.asOfSeq);
|
|
@@ -535,20 +556,34 @@ export class SessionManager {
|
|
|
535
556
|
*/
|
|
536
557
|
handleSessionAdded(summary) {
|
|
537
558
|
this.mergeSummary(summary);
|
|
538
|
-
this.
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
559
|
+
if (!this.disposed && summary.running)
|
|
560
|
+
this.engagedSessions.add(summary.sessionId);
|
|
561
|
+
this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary));
|
|
562
|
+
if (summary.projections !== undefined)
|
|
563
|
+
this.applyListBlock(summary.sessionId, summary.projections);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Merge one list-surface projection block by the sequence space it declares.
|
|
567
|
+
* A `sequenced` block came from the Host's live registry for an attached
|
|
568
|
+
* Session, so each key lands under higher-seq-wins against that Session's
|
|
569
|
+
* baselines and frames. A `cached` block was viewed from the persisted
|
|
570
|
+
* checkpoint by a header-only listing: its watermark is not comparable with
|
|
571
|
+
* this connection's seqs, so it only fills keys no sequenced row holds.
|
|
572
|
+
*/
|
|
573
|
+
applyListBlock(sessionId, block) {
|
|
574
|
+
const store = this.projectionStore(sessionId);
|
|
575
|
+
switch (block.kind) {
|
|
576
|
+
case 'sequenced': {
|
|
577
|
+
const seq = sessionSeqCursor(block.asOfSeq);
|
|
578
|
+
for (const [key, value] of Object.entries(block.values))
|
|
579
|
+
store.apply(key, value, seq);
|
|
580
|
+
return;
|
|
544
581
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
&& this.openCatalogs.has(summary.parentSessionId)) {
|
|
551
|
-
this.scheduleCatalogRefresh(summary.parentSessionId);
|
|
582
|
+
case 'cached':
|
|
583
|
+
store.applyCached(block.values);
|
|
584
|
+
return;
|
|
585
|
+
default:
|
|
586
|
+
assertNever(block.kind, 'session list projection block kind');
|
|
552
587
|
}
|
|
553
588
|
}
|
|
554
589
|
/**
|
|
@@ -556,28 +591,23 @@ export class SessionManager {
|
|
|
556
591
|
* @param sessionId - removed Session identity.
|
|
557
592
|
*/
|
|
558
593
|
handleSessionRemoved(sessionId) {
|
|
559
|
-
const
|
|
560
|
-
|
|
594
|
+
const durableSubagent = this.subagentAddress(sessionId) !== undefined
|
|
595
|
+
|| this.summaries.some(summary => summary.sessionId === sessionId && summary.origin === 'subagent');
|
|
561
596
|
this.recordMutation(durableSubagent
|
|
562
|
-
? { kind: 'status', sessionId, running: false }
|
|
597
|
+
? { kind: 'status', sessionId, running: false, agentAvailable: false }
|
|
563
598
|
: { kind: 'remove', sessionId });
|
|
564
|
-
this.updateCatalogActivity(sessionId, false);
|
|
565
599
|
if (durableSubagent)
|
|
566
600
|
this.sessions.get(sessionId)?.handleRunning(false);
|
|
567
601
|
else
|
|
568
602
|
this.sessions.get(sessionId)?.handleRemoved();
|
|
569
|
-
this.
|
|
570
|
-
if (!durableSubagent)
|
|
603
|
+
const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog;
|
|
604
|
+
if (!durableSubagent && (catalog === undefined || catalog.length === 0)) {
|
|
571
605
|
this.projectionStores.delete(sessionId);
|
|
572
|
-
const inflightCatalog = this.catalogInflight.get(sessionId);
|
|
573
|
-
if (inflightCatalog !== undefined) {
|
|
574
|
-
inflightCatalog.parentAvailableOverride = false;
|
|
575
|
-
this.catalogStale.add(sessionId);
|
|
576
|
-
}
|
|
577
|
-
const ownedCatalog = this.catalogs.get(sessionId);
|
|
578
|
-
if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
|
|
579
|
-
this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false });
|
|
580
606
|
}
|
|
607
|
+
this.pruneEngagement(sessionId, this.retainedIds(this.summaries));
|
|
608
|
+
this.projectionInflight.get(sessionId)?.controller.abort();
|
|
609
|
+
this.projectionInflight.delete(sessionId);
|
|
610
|
+
this.projectionLoads.delete(sessionId);
|
|
581
611
|
for (const [childId, address] of this.addresses) {
|
|
582
612
|
if (address.parentSessionId === sessionId) {
|
|
583
613
|
this.sessions.get(childId)?.handleSubagentParentAvailable(false);
|
|
@@ -590,9 +620,11 @@ export class SessionManager {
|
|
|
590
620
|
* @param running - current Agent running state.
|
|
591
621
|
*/
|
|
592
622
|
handleSessionStatus(sessionId, running) {
|
|
593
|
-
this.
|
|
623
|
+
if (!this.disposed && running)
|
|
624
|
+
this.engagedSessions.add(sessionId);
|
|
625
|
+
this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true });
|
|
626
|
+
this.updateParentAvailability();
|
|
594
627
|
this.sessions.get(sessionId)?.handleRunning(running);
|
|
595
|
-
this.updateCatalogActivity(sessionId, running);
|
|
596
628
|
}
|
|
597
629
|
/**
|
|
598
630
|
* Advance Session-list activity from one user-authored durable message.
|
|
@@ -622,90 +654,18 @@ export class SessionManager {
|
|
|
622
654
|
this.listMutations = null;
|
|
623
655
|
this.listInflight = null;
|
|
624
656
|
void this.refreshList();
|
|
625
|
-
const parents = new Set(this.
|
|
657
|
+
const parents = new Set(this.projectionLoads.keys());
|
|
626
658
|
for (const id of this.sessions.keys()) {
|
|
627
659
|
const address = this.addresses.get(id);
|
|
628
660
|
if (address !== undefined)
|
|
629
661
|
parents.add(address.parentSessionId);
|
|
630
662
|
}
|
|
663
|
+
for (const { controller } of this.projectionInflight.values())
|
|
664
|
+
controller.abort();
|
|
665
|
+
this.projectionInflight.clear();
|
|
666
|
+
this.projectionLoads.clear();
|
|
631
667
|
for (const parentSessionId of parents)
|
|
632
|
-
void this.
|
|
633
|
-
}
|
|
634
|
-
/** Debounce membership refetches for an explicitly consumed catalog. */
|
|
635
|
-
scheduleCatalogRefresh(parentSessionId) {
|
|
636
|
-
if (this.catalogDebounce.has(parentSessionId))
|
|
637
|
-
return;
|
|
638
|
-
const timer = setTimeout(() => {
|
|
639
|
-
this.catalogDebounce.delete(parentSessionId);
|
|
640
|
-
// The in-flight response predates the membership frame that scheduled
|
|
641
|
-
// this callback. Queue one post-settlement pull instead of treating an
|
|
642
|
-
// ordinary overlapping read as evidence that catalog membership changed.
|
|
643
|
-
if (this.catalogInflight.has(parentSessionId)) {
|
|
644
|
-
this.catalogStale.add(parentSessionId);
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
void this.refreshSubagents(parentSessionId);
|
|
648
|
-
}, 50);
|
|
649
|
-
this.catalogDebounce.set(parentSessionId, timer);
|
|
650
|
-
}
|
|
651
|
-
/** Apply one Agent-driver transition to loaded and in-flight catalogs. */
|
|
652
|
-
updateCatalogActivity(childSessionId, running) {
|
|
653
|
-
const activity = running ? 'running' : 'inactive';
|
|
654
|
-
for (const inflight of this.catalogInflight.values()) {
|
|
655
|
-
inflight.activityRows.set(childSessionId, activity);
|
|
656
|
-
}
|
|
657
|
-
let changed = false;
|
|
658
|
-
for (const [parentSessionId, catalog] of this.catalogs) {
|
|
659
|
-
if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity))
|
|
660
|
-
continue;
|
|
661
|
-
const entries = catalog.entries.map((entry) => {
|
|
662
|
-
if (entry.kind !== 'child' || entry.id !== childSessionId)
|
|
663
|
-
return entry;
|
|
664
|
-
return { ...entry, activity };
|
|
665
|
-
});
|
|
666
|
-
changed = true;
|
|
667
|
-
this.catalogs.set(parentSessionId, { ...catalog, entries });
|
|
668
|
-
}
|
|
669
|
-
if (changed)
|
|
670
|
-
this.notifier.markDirty();
|
|
671
|
-
}
|
|
672
|
-
/** Preserve and project a positive expandability hint after one direct subagent publishes. */
|
|
673
|
-
markCatalogParentExpandable(parentSessionId) {
|
|
674
|
-
this.applyCatalogParentExpandable(parentSessionId);
|
|
675
|
-
for (const inflight of this.catalogInflight.values())
|
|
676
|
-
inflight.expandableRows.add(parentSessionId);
|
|
677
|
-
}
|
|
678
|
-
/** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
|
|
679
|
-
applyCatalogParentExpandable(parentSessionId) {
|
|
680
|
-
let changed = false;
|
|
681
|
-
for (const [catalogParentId, catalog] of this.catalogs) {
|
|
682
|
-
if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren))
|
|
683
|
-
continue;
|
|
684
|
-
const entries = catalog.entries.map((entry) => {
|
|
685
|
-
if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren)
|
|
686
|
-
return entry;
|
|
687
|
-
return { ...entry, hasChildren: true };
|
|
688
|
-
});
|
|
689
|
-
changed = true;
|
|
690
|
-
this.catalogs.set(catalogParentId, { ...catalog, entries });
|
|
691
|
-
}
|
|
692
|
-
if (changed)
|
|
693
|
-
this.notifier.markDirty();
|
|
694
|
-
}
|
|
695
|
-
/** Fold request-local row mutations into one catalog result before publication. */
|
|
696
|
-
withCatalogMutations(entries, expandableRows, activityRows) {
|
|
697
|
-
return entries.map((entry) => {
|
|
698
|
-
if (entry.kind !== 'child')
|
|
699
|
-
return entry;
|
|
700
|
-
const activity = activityRows.get(entry.id);
|
|
701
|
-
if (!expandableRows.has(entry.id) && activity === undefined)
|
|
702
|
-
return entry;
|
|
703
|
-
return {
|
|
704
|
-
...entry,
|
|
705
|
-
...expandableRows.has(entry.id) ? { hasChildren: true } : {},
|
|
706
|
-
...activity === undefined ? {} : { activity },
|
|
707
|
-
};
|
|
708
|
-
});
|
|
668
|
+
void this.refreshProjections(parentSessionId);
|
|
709
669
|
}
|
|
710
670
|
buildListSnapshot() {
|
|
711
671
|
const merged = this.summaries.map((summary) => {
|
|
@@ -714,8 +674,12 @@ export class SessionManager {
|
|
|
714
674
|
const projectionStore = this.projectionStores.get(summary.sessionId);
|
|
715
675
|
const title = projectionStore?.get('title');
|
|
716
676
|
const projectionValues = projectionStore?.values();
|
|
677
|
+
const metadata = projectionValues?.sessionListMetadata;
|
|
717
678
|
return {
|
|
718
679
|
...summary,
|
|
680
|
+
// Cached list hints can precede a history opening or control update.
|
|
681
|
+
blank: this.effectiveBlank(summary) && metadata?.blank !== false,
|
|
682
|
+
updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0),
|
|
719
683
|
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
|
720
684
|
...(projectionValues === undefined ? {} : { projectionValues }),
|
|
721
685
|
};
|
|
@@ -745,15 +709,18 @@ export class SessionManager {
|
|
|
745
709
|
state: this.listState,
|
|
746
710
|
phase: this.listPhase,
|
|
747
711
|
error: this.listError,
|
|
748
|
-
|
|
749
|
-
|
|
712
|
+
projectionsBySession: Object.fromEntries([...this.projectionStores].map(([sessionId, store]) => [
|
|
713
|
+
sessionId,
|
|
714
|
+
{ values: store.values(), state: 'idle', error: null, ...this.projectionLoads.get(sessionId) },
|
|
715
|
+
])),
|
|
750
716
|
};
|
|
751
717
|
}
|
|
752
718
|
}
|
|
753
719
|
/** Apply one list mutation without deriving display order. */
|
|
754
720
|
function applyMutation(summaries, mutation) {
|
|
755
721
|
switch (mutation.kind) {
|
|
756
|
-
case 'upsert':
|
|
722
|
+
case 'upsert':
|
|
723
|
+
case 'placeholder': {
|
|
757
724
|
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId);
|
|
758
725
|
if (existing === undefined)
|
|
759
726
|
return [mutation.summary, ...summaries];
|
|
@@ -762,6 +729,10 @@ function applyMutation(summaries, mutation) {
|
|
|
762
729
|
// Blank only lowers: a stale true (session-added racing the local
|
|
763
730
|
// first send) never re-hides an already-surfaced session.
|
|
764
731
|
blank: existing.blank && mutation.summary.blank,
|
|
732
|
+
...(mutation.kind === 'upsert' ? {
|
|
733
|
+
agentAvailable: mutation.summary.agentAvailable,
|
|
734
|
+
running: mutation.summary.running,
|
|
735
|
+
} : {}),
|
|
765
736
|
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
|
766
737
|
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
|
767
738
|
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
|
@@ -769,18 +740,19 @@ function applyMutation(summaries, mutation) {
|
|
|
769
740
|
? { origin: mutation.summary.origin } : {}),
|
|
770
741
|
};
|
|
771
742
|
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
|
772
|
-
&& filled.origin === existing.origin && filled.blank === existing.blank
|
|
743
|
+
&& filled.origin === existing.origin && filled.blank === existing.blank
|
|
744
|
+
&& filled.agentAvailable === existing.agentAvailable && filled.running === existing.running)
|
|
773
745
|
return [...summaries];
|
|
774
746
|
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary);
|
|
775
747
|
}
|
|
776
748
|
case 'remove':
|
|
777
749
|
return summaries.filter(summary => summary.sessionId !== mutation.sessionId);
|
|
778
750
|
case 'status':
|
|
779
|
-
//
|
|
780
|
-
// never runs, so the first running frame proves a message landed).
|
|
751
|
+
// Running converts display state; it does not establish durable history.
|
|
781
752
|
return summaries.map(summary => summary.sessionId === mutation.sessionId
|
|
782
|
-
&& (summary.running !== mutation.running
|
|
783
|
-
|
|
753
|
+
&& (summary.running !== mutation.running
|
|
754
|
+
|| summary.agentAvailable !== mutation.agentAvailable || (mutation.running && summary.blank))
|
|
755
|
+
? { ...summary, running: mutation.running, agentAvailable: mutation.agentAvailable, blank: summary.blank && !mutation.running }
|
|
784
756
|
: summary);
|
|
785
757
|
case 'activity':
|
|
786
758
|
return summaries.map(summary => summary.sessionId === mutation.sessionId
|