@myagentroam/node 0.10.62 → 0.10.64

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/dist/connector.js CHANGED
@@ -898,6 +898,9 @@ export class NodeConnector {
898
898
  emitSession: (session) => this.publishWorkbenchEvent('session', {
899
899
  session: this.sessionPresentationService.present(session)
900
900
  }),
901
+ invalidateWorkspaceSessions: (workspaceId) => {
902
+ void this.workspaceWorkbenchService?.invalidateTopic(workspaceId, 'sessions');
903
+ },
901
904
  execute: (operation, data) => this.modelUsage.withGrant(isPlainRecord(data) ? data.__usageGrant : undefined, () => this.executeNodeOperation(operation, data)),
902
905
  interceptedSessionId: (clientMessageId) => this.fakeRunner === undefined ? undefined : `fake:${clientMessageId}`
903
906
  });
@@ -998,7 +1001,6 @@ export class NodeConnector {
998
1001
  discoveryPending: (workspaceId) => this.workspaceSessionService.discoveryPending(workspaceId),
999
1002
  discoverWorkspace: (workspaceId) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId),
1000
1003
  present: (session) => this.sessionPresentationService.present(session),
1001
- projectionForNative: (session) => this.nativeSessionProjectionService.projectionForNative(session),
1002
1004
  nativeIds: (session) => this.nativeSessionProjectionService.nativeIds(session),
1003
1005
  forget: (session, nativeIds) => {
1004
1006
  this.nativeSessionProjectionService.forget(session, nativeIds);
package/dist/database.js CHANGED
@@ -363,13 +363,14 @@ export class NodeDatabase {
363
363
  if (current === undefined || current['pin_order'] === null)
364
364
  throw new Error('SESSION_NOT_PINNED');
365
365
  const order = numberValue(current, 'pin_order');
366
+ const workspaceId = stringValue(current, 'workspace_id');
366
367
  const adjacent = this.raw
367
368
  .prepare(direction === 'up'
368
369
  ? 'SELECT id, pin_order FROM agent_sessions WHERE workspace_id = ? AND pin_order < ? ORDER BY pin_order DESC LIMIT 1'
369
370
  : 'SELECT id, pin_order FROM agent_sessions WHERE workspace_id = ? AND pin_order > ? ORDER BY pin_order ASC LIMIT 1')
370
- .get(stringValue(current, 'workspace_id'), order);
371
+ .get(workspaceId, order);
371
372
  if (adjacent === undefined)
372
- return;
373
+ return workspaceId;
373
374
  this.raw.exec('BEGIN IMMEDIATE');
374
375
  try {
375
376
  this.raw
@@ -384,6 +385,7 @@ export class NodeDatabase {
384
385
  this.raw.exec('ROLLBACK');
385
386
  throw error;
386
387
  }
388
+ return workspaceId;
387
389
  }
388
390
  deleteSessionRecord(sessionId) {
389
391
  this.raw.exec('BEGIN IMMEDIATE');
@@ -157,15 +157,36 @@ export class NodeRuntimeState {
157
157
  this.emitRunChanged(run, session);
158
158
  return run;
159
159
  }
160
+ getAgentSessionById(id) {
161
+ return this.sessions.get(this.resolvePromotedSessionId(id));
162
+ }
160
163
  getAgentSession(id) {
161
- return (this.sessions.get(this.resolvePromotedSessionId(id)) ??
164
+ return (this.getAgentSessionById(id) ??
162
165
  [...this.sessions.values()].find((value) => value.externalSessionId === id));
163
166
  }
164
167
  listAgentSessions() {
165
168
  return [...this.sessions.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
166
169
  }
167
- findAgentSessionByExternalSession(runner, externalSessionId) {
168
- return [...this.sessions.values()].find((value) => value.runner === runner && value.externalSessionId === externalSessionId);
170
+ findAgentSessionByExternalSession(runner, externalSessionId, workspaceId) {
171
+ return [...this.sessions.values()].find((value) => value.runner === runner &&
172
+ value.externalSessionId === externalSessionId &&
173
+ (workspaceId === undefined || value.workspaceId === workspaceId));
174
+ }
175
+ /** Request-local identity lookups; callers must consume them synchronously. */
176
+ createSessionLookup() {
177
+ const byId = new Map(this.sessions);
178
+ const byNative = new Map();
179
+ for (const session of byId.values()) {
180
+ if (session.externalSessionId === null)
181
+ continue;
182
+ const key = JSON.stringify([session.runner, session.workspaceId, session.externalSessionId]);
183
+ if (!byNative.has(key))
184
+ byNative.set(key, session);
185
+ }
186
+ return {
187
+ getAgentSessionById: (id) => byId.get(this.resolvePromotedSessionId(id)),
188
+ findAgentSessionByExternalSession: (runner, externalId, workspaceId) => byNative.get(JSON.stringify([runner, workspaceId, externalId]))
189
+ };
169
190
  }
170
191
  updateAgentSessionConfiguration(input) {
171
192
  return this.replaceSession(input.sessionId, {
@@ -826,8 +847,13 @@ export class NodeRuntimeState {
826
847
  };
827
848
  }
828
849
  conversationTurnForRun(runId) {
850
+ const canonicalRunId = this.canonicalRunId(runId);
851
+ const run = this.runs.get(canonicalRunId);
852
+ if (run !== undefined)
853
+ return (this.turns.get(run.sessionId) ?? []).findLast((turn) => turn.runId === canonicalRunId);
854
+ // Terminal Run cleanup may retain Turns after removing the Run.
829
855
  for (const turns of this.turns.values()) {
830
- const turn = turns.findLast((candidate) => candidate.runId === this.canonicalRunId(runId));
856
+ const turn = turns.findLast((candidate) => candidate.runId === canonicalRunId);
831
857
  if (turn !== undefined)
832
858
  return turn;
833
859
  }
@@ -59,8 +59,12 @@ export class ConversationHostingLifecycleService {
59
59
  await resources.maintenance.dispose();
60
60
  }
61
61
  finally {
62
- resources.service.dispose();
63
- await resources.runtime.dispose().catch(() => undefined);
62
+ try {
63
+ await resources.service.dispose();
64
+ }
65
+ finally {
66
+ await resources.runtime.dispose().catch(() => undefined);
67
+ }
64
68
  }
65
69
  }
66
70
  async rebuild() {
@@ -159,9 +163,29 @@ export class ConversationHostingLifecycleService {
159
163
  };
160
164
  }
161
165
  catch (error) {
162
- await maintenanceService?.dispose();
163
- hostedService?.dispose();
164
- await runtime?.dispose().catch(() => undefined);
166
+ try {
167
+ await maintenanceService?.dispose();
168
+ }
169
+ catch (cleanupError) {
170
+ nodeLog('conversation.hosting.cleanup.failed', {
171
+ nodeId: config.nodeId,
172
+ code: safeErrorCode(cleanupError, 'CONVERSATION_HOSTING_CLEANUP_FAILED')
173
+ });
174
+ }
175
+ finally {
176
+ try {
177
+ await hostedService?.dispose();
178
+ }
179
+ catch (cleanupError) {
180
+ nodeLog('conversation.hosting.cleanup.failed', {
181
+ nodeId: config.nodeId,
182
+ code: safeErrorCode(cleanupError, 'CONVERSATION_HOSTING_CLEANUP_FAILED')
183
+ });
184
+ }
185
+ finally {
186
+ await runtime?.dispose().catch(() => undefined);
187
+ }
188
+ }
165
189
  if (this.closed)
166
190
  return;
167
191
  this.failureCode = safeErrorCode(error, 'CONVERSATION_HOSTING_RECOVERY_FAILED');
@@ -45,6 +45,8 @@ export class HostedConversationNodeService {
45
45
  pendingUploadCreates = new Map();
46
46
  uploadNameReservations = new Map();
47
47
  fileDeletes = new Set();
48
+ fileWrites = new Map();
49
+ closing;
48
50
  watchLeases = new Map();
49
51
  cleanupLocks = new Set();
50
52
  historyMutations = new Map();
@@ -76,13 +78,13 @@ export class HostedConversationNodeService {
76
78
  'conversation.hosted.tool-resource.read': (raw) => this.readToolResource(raw),
77
79
  'conversation.hosted.files.list': (raw) => this.listFiles(raw),
78
80
  'conversation.hosted.file.read': (raw) => this.readFile(raw),
79
- 'conversation.hosted.file.delete': (raw) => this.deleteFile(raw),
81
+ 'conversation.hosted.file.delete': (raw) => this.fileWrite(raw, hostedConversationFileDeleteInputSchema, () => this.deleteFile(raw)),
80
82
  'conversation.hosted.files.upload.preflight': (raw) => this.preflightUpload(raw),
81
- 'conversation.hosted.files.upload.create': (raw) => this.createUpload(raw),
82
- 'conversation.hosted.files.upload.chunk': (raw) => this.writeUploadChunk(raw),
83
+ 'conversation.hosted.files.upload.create': (raw) => this.fileWrite(raw, hostedConversationUploadCreateInputSchema, () => this.createUpload(raw)),
84
+ 'conversation.hosted.files.upload.chunk': (raw) => this.fileWrite(raw, hostedConversationUploadChunkInputSchema, () => this.writeUploadChunk(raw)),
83
85
  'conversation.hosted.files.upload.status': (raw) => this.uploadStatus(raw),
84
- 'conversation.hosted.files.upload.complete': (raw) => this.completeUpload(raw),
85
- 'conversation.hosted.files.upload.cancel': (raw) => this.cancelUpload(raw),
86
+ 'conversation.hosted.files.upload.complete': (raw) => this.fileWrite(raw, hostedConversationUploadControlInputSchema, () => this.completeUpload(raw)),
87
+ 'conversation.hosted.files.upload.cancel': (raw) => this.fileWrite(raw, hostedConversationUploadControlInputSchema, () => this.cancelUpload(raw)),
86
88
  'conversation.hosted.delete': (raw) => this.delete(raw)
87
89
  };
88
90
  }
@@ -293,6 +295,8 @@ export class HostedConversationNodeService {
293
295
  });
294
296
  }
295
297
  async watch(raw) {
298
+ if (this.closing)
299
+ throw new Error('CONVERSATION_HOSTING_UNAVAILABLE');
296
300
  const payload = hostedRequest(raw);
297
301
  const input = hostedConversationWatchInputSchema.parse(payload.data);
298
302
  const conversation = this.options.store.requireConversation(payload.userId, input.conversationId);
@@ -306,8 +310,10 @@ export class HostedConversationNodeService {
306
310
  !this.sessionWatchSlots.owns(ownerKey, lease.slotToken) ||
307
311
  lease.watchId !== input.watchId ||
308
312
  lease.conversationId !== input.conversationId ||
309
- lease.userId !== payload.userId ||
310
- lease.expiresAt <= Date.now()) {
313
+ lease.userId !== payload.userId) {
314
+ throw new Error('SESSION_WATCH_NOT_FOUND');
315
+ }
316
+ if (lease.expiresAt <= Date.now()) {
311
317
  this.removeWatchLease(ownerKey);
312
318
  throw new Error('SESSION_WATCH_NOT_FOUND');
313
319
  }
@@ -376,6 +382,7 @@ export class HostedConversationNodeService {
376
382
  this.historyMutations.has(conversationId) ||
377
383
  this.activeRuns.has(conversationId) ||
378
384
  this.runSettlements.has(conversationId) ||
385
+ this.fileWrites.has(conversationId) ||
379
386
  [...this.watchLeases.values()].some((lease) => lease.conversationId === conversationId) ||
380
387
  [...this.uploads.values()].some((upload) => upload.conversationId === conversationId && upload.status === 'UPLOADING') ||
381
388
  [...this.pendingMessages.keys()].some((key) => key.startsWith(`${conversationId}\0`)));
@@ -399,6 +406,9 @@ export class HostedConversationNodeService {
399
406
  .catch(() => undefined);
400
407
  }
401
408
  dispose() {
409
+ if (this.closing)
410
+ return this.closing;
411
+ this.disablePrepared = true;
402
412
  for (const [conversationId, handle] of this.activeRuns) {
403
413
  const interruptionKey = runInterruptionKey(conversationId, handle.run.id);
404
414
  this.expectedInterruptions.add(interruptionKey);
@@ -407,6 +417,50 @@ export class HostedConversationNodeService {
407
417
  }
408
418
  for (const ownerKey of [...this.watchLeases.keys()])
409
419
  this.removeWatchLease(ownerKey);
420
+ this.closing = (async () => {
421
+ await Promise.all([...this.fileWrites.keys()].map((id) => this.waitForFileWrites(id)));
422
+ await Promise.allSettled([...this.uploads.values()].map(async (upload) => {
423
+ if (upload.status === 'UPLOADING') {
424
+ upload.status = 'CANCELLED';
425
+ await rm(upload.temporaryPath, { force: true });
426
+ }
427
+ }));
428
+ this.uploads.clear();
429
+ this.uploadsByRequest.clear();
430
+ this.uploadNameReservations.clear();
431
+ })();
432
+ return this.closing;
433
+ }
434
+ async fileWrite(raw, schema, operation) {
435
+ const payload = hostedRequest(raw);
436
+ const input = schema.parse(payload.data);
437
+ if (this.closing)
438
+ throw new Error('CONVERSATION_HOSTING_UNAVAILABLE');
439
+ this.requireWritableConversation(payload.userId, input.conversationId);
440
+ const writes = this.fileWrites.get(input.conversationId) ?? new Set();
441
+ if (input.uploadId !== undefined &&
442
+ [...writes].some((write) => write.uploadId === input.uploadId))
443
+ throw new Error('HOSTED_CONVERSATION_FILE_CONFLICT');
444
+ // Register before the operation can enter its first asynchronous preparation.
445
+ const write = {
446
+ ...(input.uploadId === undefined ? {} : { uploadId: input.uploadId }),
447
+ completion: Promise.resolve().then(operation)
448
+ };
449
+ writes.add(write);
450
+ this.fileWrites.set(input.conversationId, writes);
451
+ try {
452
+ return await write.completion;
453
+ }
454
+ finally {
455
+ writes.delete(write);
456
+ if (writes.size === 0)
457
+ this.fileWrites.delete(input.conversationId);
458
+ }
459
+ }
460
+ async waitForFileWrites(conversationId) {
461
+ const writes = this.fileWrites.get(conversationId);
462
+ if (writes)
463
+ await Promise.allSettled([...writes].map((write) => write.completion));
410
464
  }
411
465
  message(raw) {
412
466
  if (this.disablePrepared)
@@ -778,17 +832,6 @@ export class HostedConversationNodeService {
778
832
  let conversation;
779
833
  try {
780
834
  this.options.store.finishRunMessages(conversationId, run.id, completion.status, completion.completedAt);
781
- this.publishHostedEvent(conversationId, {
782
- conversationId,
783
- kind: 'run',
784
- run: {
785
- id: run.id,
786
- status: completion.status,
787
- startedAt: run.startedAt,
788
- completedAt: completion.completedAt
789
- }
790
- });
791
- void this.refreshReadyWatchSnapshots(conversationId, run.id);
792
835
  try {
793
836
  const files = await this.options.store.reconcileFiles(userId, conversationId);
794
837
  this.publishHostedEvent(conversationId, {
@@ -809,6 +852,17 @@ export class HostedConversationNodeService {
809
852
  }
810
853
  });
811
854
  }
855
+ this.publishHostedEvent(conversationId, {
856
+ conversationId,
857
+ kind: 'run',
858
+ run: {
859
+ id: run.id,
860
+ status: completion.status,
861
+ startedAt: run.startedAt,
862
+ completedAt: completion.completedAt
863
+ }
864
+ });
865
+ void this.refreshReadyWatchSnapshots(conversationId, run.id);
812
866
  await this.options.store.advanceLastActivity(conversationId, completion.completedAt);
813
867
  await this.applyAutomaticTitle(conversationId, completion.title, completion.completedAt).catch(() => undefined);
814
868
  conversation = this.options.store.requireConversation(userId, conversationId);
@@ -1195,6 +1249,8 @@ export class HostedConversationNodeService {
1195
1249
  return { upload: hostedConversationUploadSchema.parse(uploadProjection(upload)) };
1196
1250
  }
1197
1251
  async delete(raw) {
1252
+ if (this.closing)
1253
+ throw new Error('CONVERSATION_HOSTING_UNAVAILABLE');
1198
1254
  const payload = hostedRequest(raw);
1199
1255
  const input = hostedConversationIdInputSchema.parse(payload.data);
1200
1256
  const conversation = this.options.store.requireConversation(payload.userId, input.conversationId);
@@ -1210,6 +1266,7 @@ export class HostedConversationNodeService {
1210
1266
  if (!handle.cancel())
1211
1267
  this.expectedInterruptions.delete(interruptionKey);
1212
1268
  }
1269
+ await this.waitForFileWrites(conversation.id);
1213
1270
  await this.cancelConversationUploads(payload.userId, conversation.id);
1214
1271
  const paths = this.options.store.pathsForConversation(payload.userId, conversation.id);
1215
1272
  await this.options.runtime
@@ -254,31 +254,42 @@ export class NativeSessionProjectionService {
254
254
  ? undefined
255
255
  : [
256
256
  'Similar Sessions (suggestions only; no action was redirected):',
257
- ...candidates.map(({ id, title }) => id + ' ' + title.replace(/[\u0000-\u001f\u007f]/gu, ' ').slice(0, 160))
257
+ ...candidates.map(({ id, title }) =>
258
+ // eslint-disable-next-line no-control-regex -- Strip ASCII controls from user titles.
259
+ id + ' ' + title.replace(/[\u0000-\u001f\u007f]/gu, ' ').slice(0, 160))
258
260
  ].join('\n');
259
261
  }
260
262
  projectionForNative(native) {
263
+ return this.resolveNativeProjection(native, this.options.runtime, false);
264
+ }
265
+ projectionsForNative(native) {
266
+ const lookup = this.options.runtime.createSessionLookup();
267
+ return new Map(native.map((session) => [session, this.resolveNativeProjection(session, lookup, false)]));
268
+ }
269
+ managedForNative(native) {
270
+ return this.resolveNativeProjection(native, this.options.runtime, true);
271
+ }
272
+ resolveNativeProjection(native, lookup, managedOnly) {
261
273
  if (native.externalSessionId === null)
262
274
  return undefined;
263
- const exact = this.options.runtime.findAgentSessionByExternalSession(native.runner, native.externalSessionId);
275
+ const exact = lookup.findAgentSessionByExternalSession(native.runner, native.externalSessionId, native.workspaceId);
264
276
  if (exact !== undefined &&
265
277
  exact.runner === native.runner &&
266
278
  exact.workspaceId === native.workspaceId &&
267
- exact.externalSessionId === native.externalSessionId)
268
- return exact;
269
- return this.managedForNative(native);
270
- }
271
- managedForNative(native) {
272
- if (native.externalSessionId === null)
273
- return undefined;
274
- const exact = this.options.runtime.findAgentSessionByExternalSession(native.runner, native.externalSessionId);
275
- if (this.isManagedProjection(exact, native, false))
279
+ exact.externalSessionId === native.externalSessionId &&
280
+ (!managedOnly || exact.nativeControl === 'MAR_MANAGED'))
276
281
  return exact;
277
282
  const aliasId = this.options.runners
278
283
  .require(native.runner)
279
284
  .managedAliasId(native.externalSessionId);
280
- const alias = aliasId === undefined ? undefined : this.options.runtime.getAgentSession(aliasId);
281
- return this.isManagedProjection(alias, native, true) ? alias : undefined;
285
+ const alias = aliasId === undefined ? undefined : lookup.getAgentSessionById(aliasId);
286
+ // The Runner supplied the verified alias; Runtime may have promoted its old ID.
287
+ return alias !== undefined &&
288
+ alias.nativeControl === 'MAR_MANAGED' &&
289
+ alias.runner === native.runner &&
290
+ alias.workspaceId === native.workspaceId
291
+ ? alias
292
+ : undefined;
282
293
  }
283
294
  nativeIds(session) {
284
295
  const ids = new Set();
@@ -331,17 +342,6 @@ export class NativeSessionProjectionService {
331
342
  this.options.state.externalActivity.delete(projectionId);
332
343
  }
333
344
  }
334
- isManagedProjection(candidate, native, allowAlias) {
335
- const exactNativeId = candidate?.externalSessionId === native.externalSessionId;
336
- const verifiedAlias = allowAlias &&
337
- this.options.runners.require(native.runner).managedAliasId(native.externalSessionId ?? '') ===
338
- candidate?.id;
339
- return (candidate !== undefined &&
340
- candidate.nativeControl === 'MAR_MANAGED' &&
341
- candidate.runner === native.runner &&
342
- candidate.workspaceId === native.workspaceId &&
343
- (exactNativeId || verifiedAlias));
344
- }
345
345
  requireDatabase() {
346
346
  const database = this.options.database();
347
347
  if (database === undefined)
@@ -84,13 +84,24 @@ export class SessionCatalogService {
84
84
  : query === undefined
85
85
  ? await this.options.cachedWorkspace(workspaceId)
86
86
  : await this.options.discoverWorkspace(workspaceId);
87
- const native = nativeSessions.map((session) => this.options.present({ ...session, externalDiscovered: true }));
87
+ const inWorkspace = (session) => workspaceId === undefined || session.workspaceId === workspaceId;
88
+ const native = nativeSessions
89
+ .filter(inWorkspace)
90
+ .map((session) => this.options.present({ ...session, externalDiscovered: true }));
91
+ const projections = this.options.projection.projectionsForNative(native);
92
+ const sources = new Map();
93
+ for (const candidate of native) {
94
+ const projection = projections.get(candidate);
95
+ if (projection !== undefined && !sources.has(projection.id))
96
+ sources.set(projection.id, candidate);
97
+ }
88
98
  const projected = this.options.runtime
89
99
  .listAgentSessions()
100
+ .filter(inWorkspace)
90
101
  .filter((session) => session.nativeControl === 'MAR_MANAGED' ||
91
102
  (session.nativeControl === 'EXTERNAL' && session.externalSessionId !== null))
92
103
  .map((session) => {
93
- const source = native.find((candidate) => this.options.projectionForNative(candidate)?.id === session.id);
104
+ const source = sources.get(session.id);
94
105
  const synchronized = session.titleSource !== 'AUTO' ||
95
106
  source?.runnerTitle === undefined ||
96
107
  source.runnerTitle === null
@@ -110,8 +121,7 @@ export class SessionCatalogService {
110
121
  });
111
122
  const merged = new Map([
112
123
  ...projected,
113
- ...native.filter((candidate) => candidate.externalSessionId === null ||
114
- this.options.projectionForNative(candidate) === undefined)
124
+ ...native.filter((candidate) => candidate.externalSessionId === null || projections.get(candidate) === undefined)
115
125
  ].map((session) => [session.id, session]));
116
126
  if (workspaceId !== undefined)
117
127
  this.mergePersisted(merged, workspaceId, !registeredOnly && query === undefined && this.options.discoveryPending(workspaceId));
@@ -268,8 +278,13 @@ export class SessionCatalogService {
268
278
  (await this.options.projection.resolve(input.sessionId));
269
279
  if (session === undefined)
270
280
  throw new Error('SESSION_NOT_FOUND');
271
- await this.removeResolved(session);
272
- return { deleted: true };
281
+ try {
282
+ await this.removeResolved(session);
283
+ return { deleted: true };
284
+ }
285
+ finally {
286
+ this.options.invalidateWorkspaceSessions(session.workspaceId);
287
+ }
273
288
  }
274
289
  async cleanupWorkspaceSessions(input) {
275
290
  const inactiveDays = input.inactiveDays;
@@ -295,54 +310,58 @@ export class SessionCatalogService {
295
310
  let skipped = 0;
296
311
  let failed = 0;
297
312
  const deletedByRunner = {};
298
- for (const session of candidates.values()) {
299
- const missingCandidate = session.externalDiscovered === false;
300
- if (session.externalSessionId === null ||
301
- (missingCandidate &&
302
- (!this.options.runnerAvailable(session.runner) ||
303
- this.options.runtime.getAgentSession(session.id) !== undefined)) ||
304
- (!missingCandidate &&
305
- Number.isFinite(session.lastActivityAt) &&
306
- session.lastActivityAt > 0 &&
307
- session.lastActivityAt >= cutoff) ||
308
- (session.pinOrder !== null && session.pinOrder !== undefined) ||
309
- this.options.isWatching(session.id) ||
310
- this.options.isRecovering?.(session.id) ||
311
- this.options.runtime.sessionQueue(session.id).itemIds.length > 0 ||
312
- this.options.runtime
313
- .listRunsForSession(session.id)
314
- .some((run) => !isTerminalRunStatus(run.status)) ||
315
- this.options.externalActivity(session.id) === 'BUSY') {
316
- skipped += 1;
317
- continue;
318
- }
319
- try {
320
- if ((await this.removeResolved(session, true)) === 'deleted') {
321
- deleted += 1;
322
- deletedByRunner[session.runner] = (deletedByRunner[session.runner] ?? 0) + 1;
323
- }
324
- else
325
- skipped += 1;
326
- }
327
- catch (error) {
328
- if (isCleanupSkip(error))
313
+ try {
314
+ for (const session of candidates.values()) {
315
+ const missingCandidate = session.externalDiscovered === false;
316
+ if (session.externalSessionId === null ||
317
+ (missingCandidate &&
318
+ (!this.options.runnerAvailable(session.runner) ||
319
+ this.options.runtime.getAgentSession(session.id) !== undefined)) ||
320
+ (!missingCandidate &&
321
+ Number.isFinite(session.lastActivityAt) &&
322
+ session.lastActivityAt > 0 &&
323
+ session.lastActivityAt >= cutoff) ||
324
+ (session.pinOrder !== null && session.pinOrder !== undefined) ||
325
+ this.options.isWatching(session.id) ||
326
+ this.options.isRecovering?.(session.id) ||
327
+ this.options.runtime.sessionQueue(session.id).itemIds.length > 0 ||
328
+ this.options.runtime
329
+ .listRunsForSession(session.id)
330
+ .some((run) => !isTerminalRunStatus(run.status)) ||
331
+ this.options.externalActivity(session.id) === 'BUSY') {
329
332
  skipped += 1;
330
- else {
331
- failed += 1;
332
- nodeLog('workspace.session.cleanup.failed', {
333
- workspaceId: input.workspaceId,
334
- sessionId: session.id,
335
- runner: session.runner,
336
- ...cleanupFailureDiagnostic(error)
337
- });
333
+ continue;
334
+ }
335
+ try {
336
+ if ((await this.removeResolved(session, true)) === 'deleted') {
337
+ deleted += 1;
338
+ deletedByRunner[session.runner] = (deletedByRunner[session.runner] ?? 0) + 1;
339
+ }
340
+ else
341
+ skipped += 1;
342
+ }
343
+ catch (error) {
344
+ if (isCleanupSkip(error))
345
+ skipped += 1;
346
+ else {
347
+ failed += 1;
348
+ nodeLog('workspace.session.cleanup.failed', {
349
+ workspaceId: input.workspaceId,
350
+ sessionId: session.id,
351
+ runner: session.runner,
352
+ ...cleanupFailureDiagnostic(error)
353
+ });
354
+ }
338
355
  }
339
356
  }
357
+ return { deleted, skipped, failed, deletedByRunner };
358
+ }
359
+ finally {
360
+ this.options.invalidateWorkspaceSessions(input.workspaceId);
340
361
  }
341
- this.options.invalidateWorkspaceSessions(input.workspaceId);
342
- return { deleted, skipped, failed, deletedByRunner };
343
362
  }
344
363
  cleanupCandidate(native) {
345
- const projected = this.options.projectionForNative(native);
364
+ const projected = this.options.projection.projectionForNative(native);
346
365
  if (projected === undefined)
347
366
  return native;
348
367
  return {
@@ -1,7 +1,10 @@
1
+ import { nodeLog } from '../operational.js';
2
+ import { isTerminalRunStatus } from '../util/node-operation-parsers.js';
1
3
  export class SessionIdentityService {
2
4
  options;
3
5
  bootstrapIds = new Set();
4
6
  waiters = new Map();
7
+ failedBootstrapIds = new Set();
5
8
  constructor(options) {
6
9
  this.options = options;
7
10
  }
@@ -9,6 +12,8 @@ export class SessionIdentityService {
9
12
  this.bootstrapIds.add(sessionId);
10
13
  }
11
14
  promote(sessionId, nativeSessionId) {
15
+ if (this.failedBootstrapIds.has(sessionId))
16
+ throw new Error('SESSION_START_FAILED');
12
17
  const current = this.options.runtime.getAgentSession(sessionId);
13
18
  this.options.runtime.promoteSessionIdentity(sessionId, nativeSessionId);
14
19
  if (current !== undefined)
@@ -45,9 +50,10 @@ export class SessionIdentityService {
45
50
  resolveIdentity = resolve;
46
51
  rejectIdentity = reject;
47
52
  });
53
+ // Startup may still be awaiting a command or the first message when identity fails.
54
+ void promise.catch(() => undefined);
48
55
  const timer = setTimeout(() => {
49
- this.waiters.delete(bootstrapSessionId);
50
- rejectIdentity(new Error('SESSION_NATIVE_ID_TIMEOUT'));
56
+ this.reject(bootstrapSessionId, 'SESSION_NATIVE_ID_TIMEOUT');
51
57
  }, 30_000);
52
58
  timer.unref();
53
59
  this.waiters.set(bootstrapSessionId, {
@@ -59,22 +65,39 @@ export class SessionIdentityService {
59
65
  return promise;
60
66
  }
61
67
  reject(sessionId, code) {
62
- if (this.bootstrapIds.has(sessionId))
68
+ const bootstrap = this.bootstrapIds.has(sessionId);
69
+ if (bootstrap) {
70
+ this.failedBootstrapIds.add(sessionId);
63
71
  this.options.releaseAttachments?.(sessionId);
72
+ }
64
73
  const waiter = this.waiters.get(sessionId);
65
- if (waiter === undefined)
74
+ if (waiter !== undefined) {
75
+ clearTimeout(waiter.timer);
76
+ this.waiters.delete(sessionId);
77
+ waiter.reject(new Error(code));
78
+ }
79
+ if (!bootstrap)
66
80
  return;
67
- clearTimeout(waiter.timer);
68
- this.waiters.delete(sessionId);
69
- waiter.reject(new Error(code));
70
81
  queueMicrotask(() => {
71
- try {
72
- this.options.runtime.removeAgentSession(sessionId);
73
- this.bootstrapIds.delete(sessionId);
74
- }
75
- catch {
76
- // Runner cleanup may still own the bootstrap Session.
82
+ const session = this.options.runtime.getAgentSession(sessionId);
83
+ if (session?.id === sessionId) {
84
+ if (this.options.runtime
85
+ .listRunsForSession(sessionId)
86
+ .some((run) => !isTerminalRunStatus(run.status)))
87
+ return;
88
+ try {
89
+ this.options.runtime.removeAgentSession(sessionId);
90
+ }
91
+ catch (error) {
92
+ nodeLog('session.bootstrap.cleanup.failed', {
93
+ sessionId,
94
+ error: error instanceof Error ? error.message.slice(0, 512) : 'Unknown cleanup error'
95
+ });
96
+ return;
97
+ }
77
98
  }
99
+ this.bootstrapIds.delete(sessionId);
100
+ this.failedBootstrapIds.delete(sessionId);
78
101
  });
79
102
  }
80
103
  dispose() {
@@ -84,5 +107,6 @@ export class SessionIdentityService {
84
107
  }
85
108
  this.waiters.clear();
86
109
  this.bootstrapIds.clear();
110
+ this.failedBootstrapIds.clear();
87
111
  }
88
112
  }
@@ -69,7 +69,9 @@ export class SessionLifecycleService {
69
69
  const next = nativeCodexRename
70
70
  ? this.options.runtime.setRunnerTitleFromNative(updated.id, input.customTitle ?? '')
71
71
  : updated;
72
- if (sessionPrompt !== undefined)
72
+ if (sessionPrompt !== undefined ||
73
+ input.customTitle !== undefined ||
74
+ input.pinned !== undefined)
73
75
  this.options.emitSession(next);
74
76
  return { session: this.options.present(next) };
75
77
  }
@@ -77,7 +79,8 @@ export class SessionLifecycleService {
77
79
  if (typeof input.sessionId !== 'string' ||
78
80
  (input.direction !== 'up' && input.direction !== 'down'))
79
81
  throw new Error('SESSION_PIN_MOVE_INVALID');
80
- this.options.database().movePinnedSession(input.sessionId, input.direction);
82
+ const workspaceId = this.options.database().movePinnedSession(input.sessionId, input.direction);
83
+ this.options.invalidateWorkspaceSessions(workspaceId);
81
84
  return { moved: true };
82
85
  }
83
86
  create(input) {
@@ -35,7 +35,9 @@ export class WorkspaceSessionService {
35
35
  return cached.sessions;
36
36
  let discovery = this.state.sessionCacheReads.get(workspaceId);
37
37
  if (discovery === undefined) {
38
- const generation = this.cacheGenerations.get(workspaceId) ?? 0;
38
+ // A retired read must not regain ownership when its Workspace state is reclaimed.
39
+ const generation = Symbol();
40
+ this.cacheGenerations.set(workspaceId, generation);
39
41
  let resolveInitial;
40
42
  let rejectInitial;
41
43
  const initial = new Promise((resolve, reject) => {
@@ -52,7 +54,7 @@ export class WorkspaceSessionService {
52
54
  let batchPublished = false;
53
55
  let notifyCompletion = false;
54
56
  const publish = (sessions, notify = true) => {
55
- if ((this.cacheGenerations.get(workspaceId) ?? 0) !== generation)
57
+ if (this.cacheGenerations.get(workspaceId) !== generation)
56
58
  return;
57
59
  const previous = this.state.sessionCache.get(workspaceId)?.sessions;
58
60
  this.state.sessionCache.set(workspaceId, {
@@ -66,7 +68,7 @@ export class WorkspaceSessionService {
66
68
  };
67
69
  const read = this.options
68
70
  .discover(workspaceId, (runner, sessions) => {
69
- if (!acceptingBatches || (this.cacheGenerations.get(workspaceId) ?? 0) !== generation)
71
+ if (!acceptingBatches || this.cacheGenerations.get(workspaceId) !== generation)
70
72
  return;
71
73
  const previous = this.state.sessionCache.get(workspaceId)?.sessions;
72
74
  const merged = [
@@ -81,7 +83,7 @@ export class WorkspaceSessionService {
81
83
  resolveInitial(merged);
82
84
  })
83
85
  .then((sessions) => {
84
- if ((this.cacheGenerations.get(workspaceId) ?? 0) === generation)
86
+ if (this.cacheGenerations.get(workspaceId) === generation)
85
87
  this.incompleteDiscoveries.delete(workspaceId);
86
88
  const previous = this.state.sessionCache.get(workspaceId)?.sessions;
87
89
  notifyCompletion =
@@ -93,7 +95,7 @@ export class WorkspaceSessionService {
93
95
  })
94
96
  .catch((error) => {
95
97
  rejectInitial(error);
96
- if ((this.cacheGenerations.get(workspaceId) ?? 0) === generation) {
98
+ if (this.cacheGenerations.get(workspaceId) === generation) {
97
99
  nodeLog('native.session.discovery.failed', { workspaceId });
98
100
  const partial = this.state.sessionCache.get(workspaceId);
99
101
  if (partial !== undefined)
@@ -111,11 +113,11 @@ export class WorkspaceSessionService {
111
113
  }
112
114
  if (this.initialReads.get(workspaceId) === initial)
113
115
  this.initialReads.delete(workspaceId);
114
- if (notifyCompletion && (this.cacheGenerations.get(workspaceId) ?? 0) === generation)
116
+ if (notifyCompletion && this.cacheGenerations.get(workspaceId) === generation)
115
117
  this.options.invalidate(workspaceId);
116
118
  if (!this.state.sessionCacheReads.has(workspaceId) &&
117
119
  !this.state.sessionCache.has(workspaceId) &&
118
- (this.cacheGenerations.get(workspaceId) ?? 0) !== generation)
120
+ this.cacheGenerations.get(workspaceId) === generation)
119
121
  this.cacheGenerations.delete(workspaceId);
120
122
  });
121
123
  discovery = read;
@@ -133,11 +135,7 @@ export class WorkspaceSessionService {
133
135
  this.state.sessionWatchTimers.clear();
134
136
  this.state.sessionWatchSignatures.clear();
135
137
  this.state.sessionWatchReads.clear();
136
- for (const workspaceId of new Set([
137
- ...this.state.sessionCache.keys(),
138
- ...this.state.sessionCacheReads.keys()
139
- ]))
140
- this.bumpCacheGeneration(workspaceId);
138
+ this.cacheGenerations.clear();
141
139
  this.state.sessionCache.clear();
142
140
  this.state.sessionCacheReads.clear();
143
141
  this.initialReads.clear();
@@ -156,16 +154,13 @@ export class WorkspaceSessionService {
156
154
  this.staleDiscoveries.add(workspaceId);
157
155
  }
158
156
  invalidateCache(workspaceId) {
159
- this.bumpCacheGeneration(workspaceId);
157
+ this.cacheGenerations.delete(workspaceId);
160
158
  this.state.sessionCache.delete(workspaceId);
161
159
  this.state.sessionCacheReads.delete(workspaceId);
162
160
  this.initialReads.delete(workspaceId);
163
161
  this.incompleteDiscoveries.delete(workspaceId);
164
162
  this.staleDiscoveries.delete(workspaceId);
165
163
  }
166
- bumpCacheGeneration(workspaceId) {
167
- this.cacheGenerations.set(workspaceId, (this.cacheGenerations.get(workspaceId) ?? 0) + 1);
168
- }
169
164
  async refreshWatch(workspaceId) {
170
165
  const current = this.state.sessionWatchReads.get(workspaceId);
171
166
  if (current !== undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.10.62",
3
+ "version": "0.10.64",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -37,10 +37,10 @@
37
37
  "playwright": "1.62.1",
38
38
  "ws": "^8.21.3",
39
39
  "zod": "4.4.3",
40
- "@myagentroam/agent": "0.10.62",
40
+ "@myagentroam/agent": "0.10.64",
41
41
  "@myagentroam/code-navigation": "0.10.1",
42
- "@myagentroam/marctl": "0.10.12",
43
- "@myagentroam/protocol": "0.10.62"
42
+ "@myagentroam/protocol": "0.10.64",
43
+ "@myagentroam/marctl": "0.10.12"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/ws": "^8.18.1"