@myagentroam/node 0.11.46 → 0.11.47

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.
@@ -210,7 +210,21 @@ export function agentNetworkExtension(invoke, currentExecution) {
210
210
  (Array.isArray(args.attachmentIds) && args.attachmentIds.length > 0))
211
211
  ? { ...args, deliveryIntent: 'REPLACE_CURRENT' }
212
212
  : args;
213
- const result = await invoke(executionId, tool.action, operationInput, call);
213
+ const onResolved = call.publishEventData
214
+ ? async (sessions) => {
215
+ const rows = sessions.map((session) => ({
216
+ agentId: session.id,
217
+ title: session.customTitle ?? session.runnerTitle ?? undefined
218
+ }));
219
+ const result = tool.action === 'wait'
220
+ ? { agents: rows }
221
+ : tool.action === 'view'
222
+ ? { session: rows[0] }
223
+ : (rows[0] ?? {});
224
+ await call.publishEventData(agentOperationEvent(tool.action, {}, result).eventData);
225
+ }
226
+ : undefined;
227
+ const result = await invoke(executionId, tool.action, operationInput, call, onResolved);
214
228
  const modelResult = tool.action === 'wait' ? agentNetworkWaitModelOutput(result) : result;
215
229
  return {
216
230
  data: result,
@@ -6,7 +6,7 @@ export function normalizeMarAgentUsage(usage) {
6
6
  }
7
7
  import { boundedConversationItemId, compactRunnerText, compactRunnerValue } from '../normalization.js';
8
8
  import { runnerErrorProjection } from '../runner-error.js';
9
- import { toolPresentationSchema } from '@myagentroam/protocol';
9
+ import { toolPresentationSchema, agentOperationPresentationSchema } from '@myagentroam/protocol';
10
10
  import { publicOutputSnapshot, publicToolOutput } from '../normalization.js';
11
11
  import { boundToolOutput } from '@myagentroam/protocol';
12
12
  import { agentOperationAction, agentOperationPresentation } from './agent-operation-presentation.js';
@@ -170,6 +170,20 @@ export class MarAgentConversationNormalizer {
170
170
  ...this.#codeModeToolResults(event)
171
171
  ];
172
172
  }
173
+ if (event.type === 'tool.updated') {
174
+ const state = this.#tools.get(event.callId);
175
+ if (!state?.active ||
176
+ state.toolName !== event.toolName ||
177
+ !agentOperationAction(event.toolName))
178
+ return [];
179
+ const operation = agentOperationPresentationSchema.safeParse(record(event.data)?.agentOperation);
180
+ if (!operation.success || operation.data.action !== agentOperationAction(event.toolName))
181
+ return [];
182
+ this.#tools.set(event.callId, { ...state, presentationData: event.data });
183
+ return [
184
+ genericToolItem(state.itemId, state.toolName, 'IN_PROGRESS', state.input, null, null, false, event.data, state.subagentTitle, state.subagentDetails, undefined, undefined, state.inputTruncated)
185
+ ];
186
+ }
173
187
  if (event.type === 'sleep.started') {
174
188
  const state = this.#tools.get(event.callId);
175
189
  if (state?.toolName !== 'sleep')
@@ -673,7 +687,7 @@ export class MarAgentConversationNormalizer {
673
687
  }
674
688
  const item = genericToolItem(state?.itemId ?? itemId('tool', event.callId), event.toolName, 'COMPLETED', state?.input, generatedImages.length === 0
675
689
  ? event.summary
676
- : generatedImages.map((image) => image.name).join(', '), null, event.outputTruncation !== undefined, event.data, agentId === undefined ? state?.subagentTitle : this.#subagentTitles.get(agentId), details, state?.sleepStartedAt, event.outputTruncation, state?.inputTruncated, event.summaryTruncated === true);
690
+ : generatedImages.map((image) => image.name).join(', '), null, event.outputTruncation !== undefined, mergeAgentOperationData(event.toolName, state?.presentationData, event.data), agentId === undefined ? state?.subagentTitle : this.#subagentTitles.get(agentId), details, state?.sleepStartedAt, event.outputTruncation, state?.inputTruncated, event.summaryTruncated === true);
677
691
  return [
678
692
  {
679
693
  ...item,
@@ -704,7 +718,11 @@ export class MarAgentConversationNormalizer {
704
718
  return [
705
719
  genericToolItem(state?.itemId ?? itemId('tool', event.callId), event.toolName, event.code === 'MAR_AGENT_CANCELLED' || event.code === 'MAR_AGENT_EXECUTION_CANCELLED'
706
720
  ? 'CANCELLED'
707
- : 'FAILED', state?.input, event.message ?? event.code, event.code, event.truncated === true, event.data === undefined ? state?.presentationData : event.data, state?.subagentTitle, state?.subagentDetails, state?.sleepStartedAt, event.outputTruncation, state?.inputTruncated)
721
+ : 'FAILED', state?.input, event.message ?? event.code, event.code, event.truncated === true, agentOperationAction(event.toolName)
722
+ ? mergeAgentOperationData(event.toolName, state?.presentationData, event.data)
723
+ : event.data === undefined
724
+ ? state?.presentationData
725
+ : event.data, state?.subagentTitle, state?.subagentDetails, state?.sleepStartedAt, event.outputTruncation, state?.inputTruncated)
708
726
  ];
709
727
  }
710
728
  #codeModeToolResults(event) {
@@ -1027,6 +1045,28 @@ function codeExecutionError(data) {
1027
1045
  message: compactRunnerText(error.message, 1000)
1028
1046
  };
1029
1047
  }
1048
+ function mergeAgentOperationData(toolName, previous, current) {
1049
+ if (!agentOperationAction(toolName))
1050
+ return current;
1051
+ const before = agentOperationPresentationSchema.safeParse(record(previous)?.agentOperation);
1052
+ if (!before.success || before.data.action !== agentOperationAction(toolName))
1053
+ return current;
1054
+ const after = agentOperationPresentationSchema.safeParse(record(current)?.agentOperation);
1055
+ if (!after.success || after.data.action !== before.data.action) {
1056
+ return { ...record(current), agentOperation: before.data };
1057
+ }
1058
+ return {
1059
+ ...record(current),
1060
+ agentOperation: {
1061
+ ...after.data,
1062
+ targets: before.data.targets.map((target) => ({
1063
+ ...after.data.targets.find((candidate) => candidate.agentId === target.agentId),
1064
+ ...target
1065
+ })),
1066
+ ...(before.data.targetsTruncated ? { targetsTruncated: true } : {})
1067
+ }
1068
+ };
1069
+ }
1030
1070
  function genericToolItem(id, toolName, status, input, outputSummary = null, errorCode = null, truncated = false, output, subagentTitle, knownSubagentDetails, sleepStartedAt, outputTruncation, inputTruncated = false, summaryTruncated = false) {
1031
1071
  if (toolName === 'code_exec') {
1032
1072
  const code = typeof input === 'string' ? input : record(input)?.code;
@@ -383,10 +383,11 @@ export function createNodeAgentNetwork(options) {
383
383
  truncated: page.nextCursor !== null
384
384
  };
385
385
  },
386
- view: async (actor, input) => {
386
+ view: async (actor, input, onResolved) => {
387
387
  const source = terminalCalls.has(actor) ? undefined : await actorSession(actor);
388
388
  const session = input.agentId === source?.id ? source : await target(actor, input.agentId);
389
389
  assertSessionControlOwner(options.runtime, session.id, actor.requestUserId);
390
+ await onResolved?.([session]);
390
391
  return {
391
392
  session: marctlSessionDetail(options.runtime, session, options.runtime.sessionQueue(session.id).itemIds.length),
392
393
  goal: publicGoal(options.goals.get(session.id)) ?? null,
@@ -4,9 +4,9 @@ import { randomUUID } from 'node:crypto';
4
4
  import { modelUsageGrantSchema } from '@myagentroam/protocol';
5
5
  /** Bind each native invocation to the actual live Node Execution and its captured resources. */
6
6
  export function agentNetworkInvoke(input) {
7
- return async (executionId, action, payload, context) => {
7
+ return async (executionId, action, payload, context, onResolved) => {
8
8
  const actor = executionAgentActor({ ...input, executionId });
9
- return invokeAgentNetwork(input.service, actor, action, payload, context);
9
+ return invokeAgentNetwork(input.service, actor, action, payload, context, undefined, onResolved);
10
10
  };
11
11
  }
12
12
  /** Read actor resources from the current Node-owned Execution, not caller arguments. */
@@ -51,7 +51,7 @@ export function executionAgentActor(input) {
51
51
  };
52
52
  }
53
53
  /** Shared command dispatch; callers must bind actor authority at their trusted boundary. */
54
- export async function invokeAgentNetwork(service, actor, action, payload, context, requestSignal) {
54
+ export async function invokeAgentNetwork(service, actor, action, payload, context, requestSignal, onResolved) {
55
55
  const signal = context?.signal ?? requestSignal;
56
56
  switch (action) {
57
57
  case 'start':
@@ -63,16 +63,16 @@ export async function invokeAgentNetwork(service, actor, action, payload, contex
63
63
  typeof payload.title === 'string' &&
64
64
  !payload.title.trim()) {
65
65
  const { title: _title, ...withoutTitle } = payload;
66
- return service.start(actor, withoutTitle, signal);
66
+ return service.start(actor, withoutTitle, signal, onResolved);
67
67
  }
68
68
  return service.start(actor, {
69
69
  ...payload,
70
70
  ...(typeof payload.title === 'string' ? { title: payload.title.trim() } : {})
71
- }, signal);
71
+ }, signal, onResolved);
72
72
  case 'send':
73
- return service.send(actor, payload, signal);
73
+ return service.send(actor, payload, signal, onResolved);
74
74
  case 'stop':
75
- return service.stop(actor, payload);
75
+ return service.stop(actor, payload, onResolved);
76
76
  case 'wait':
77
77
  return service.wait(actor, {
78
78
  ...payload,
@@ -81,11 +81,11 @@ export async function invokeAgentNetwork(service, actor, action, payload, contex
81
81
  : requestSignal
82
82
  ? { signal: requestSignal }
83
83
  : {})
84
- });
84
+ }, onResolved);
85
85
  case 'list':
86
86
  return service.list(actor, payload);
87
87
  case 'view':
88
- return service.view(actor, payload);
88
+ return service.view(actor, payload, onResolved);
89
89
  }
90
90
  }
91
91
  /** Node broker injects the caller only after validating the Server-issued Execution credential. */
@@ -12,10 +12,10 @@ export class AgentNetworkService {
12
12
  list(actor, input = {}) {
13
13
  return this.options.list(actor, input);
14
14
  }
15
- view(actor, input) {
16
- return this.options.view(actor, input);
15
+ view(actor, input, onResolved) {
16
+ return this.options.view(actor, input, onResolved);
17
17
  }
18
- async start(actor, input, signal) {
18
+ async start(actor, input, signal, onResolved) {
19
19
  signal?.throwIfAborted();
20
20
  const goal = parseAgentGoal(input.goal);
21
21
  const content = input.content ?? '';
@@ -41,6 +41,7 @@ export class AgentNetworkService {
41
41
  clientMessageId: input.clientMessageId ?? randomUUID(),
42
42
  goal
43
43
  }, signal);
44
+ await onResolved?.([created.session]);
44
45
  try {
45
46
  if (created.session.parentSessionId === actor.sessionId)
46
47
  this.options.observe(actor, created.session);
@@ -54,7 +55,7 @@ export class AgentNetworkService {
54
55
  };
55
56
  }
56
57
  }
57
- async send(actor, input, signal) {
58
+ async send(actor, input, signal, onResolved) {
58
59
  signal?.throwIfAborted();
59
60
  const goal = parseAgentGoal(input.goal);
60
61
  const content = input.content ?? '';
@@ -92,6 +93,7 @@ export class AgentNetworkService {
92
93
  if (input.approval !== undefined)
93
94
  await this.options.validateApproval(actor, session, input.approval);
94
95
  signal?.throwIfAborted();
96
+ await onResolved?.([session]);
95
97
  if (session.parentSessionId === actor.sessionId)
96
98
  this.options.observe(actor, session);
97
99
  const approvalResult = input.approval === undefined
@@ -191,7 +193,7 @@ export class AgentNetworkService {
191
193
  };
192
194
  }
193
195
  }
194
- async stop(actor, input) {
196
+ async stop(actor, input, onResolved) {
195
197
  if (typeof input.agentId !== 'string' ||
196
198
  !input.agentId ||
197
199
  typeof input.executionId !== 'string' ||
@@ -199,6 +201,7 @@ export class AgentNetworkService {
199
201
  throw new Error('AGENT_STOP_INVALID');
200
202
  const session = await this.options.target(actor, input.agentId);
201
203
  await this.options.authorize(actor, session, 'stop');
204
+ await onResolved?.([session]);
202
205
  const goalBefore = this.options.goal(session.id);
203
206
  try {
204
207
  const result = this.options.stop(actor, session, input.executionId);
@@ -224,7 +227,7 @@ export class AgentNetworkService {
224
227
  };
225
228
  }
226
229
  }
227
- async wait(actor, input) {
230
+ async wait(actor, input, onResolved) {
228
231
  validateAgentWaitOptions(input);
229
232
  if ((input.mode !== undefined && input.agentIds === undefined) ||
230
233
  (input.agentId !== undefined && input.agentIds !== undefined) ||
@@ -268,6 +271,7 @@ export class AgentNetworkService {
268
271
  if (!targets.length)
269
272
  throw new Error('AGENT_EXECUTION_NOT_FOUND');
270
273
  const selected = new Set(targets.map((target) => target.agentId));
274
+ await onResolved?.(targets.map((target) => sessions.find((session) => session.id === target.agentId)));
271
275
  const directChildren = new Set();
272
276
  for (const session of sessions)
273
277
  if (selected.has(session.id) && session.parentSessionId === actor.sessionId) {
@@ -29,11 +29,19 @@ export class NativeSessionProjectionService {
29
29
  return accepted === undefined ? [] : [accepted];
30
30
  });
31
31
  }
32
+ const candidates = new Map(ordered.map((session) => [session.id, session]));
32
33
  return ordered.flatMap((session) => {
33
34
  const record = database.getSessionRecord(session.id);
34
- if (record === undefined && session.parentSessionId != null)
35
+ if (record === undefined && !this.validParent(session, candidates))
35
36
  return [];
36
- return [{ ...session, parentSessionId: record?.metadata.parentSessionId ?? null }];
37
+ return [
38
+ {
39
+ ...session,
40
+ parentSessionId: record === undefined
41
+ ? (session.parentSessionId ?? null)
42
+ : (record.metadata.parentSessionId ?? null)
43
+ }
44
+ ];
37
45
  });
38
46
  }
39
47
  async discoverCodex(workspaceId) {
@@ -237,29 +245,30 @@ export class NativeSessionProjectionService {
237
245
  createdAt: timestamp
238
246
  };
239
247
  }
248
+ validParent(session, candidates) {
249
+ const database = this.options.database();
250
+ const visited = new Set([session.id]);
251
+ let parentId = session.parentSessionId ?? null;
252
+ let depth = 1;
253
+ while (parentId !== null) {
254
+ if (visited.has(parentId) || ++depth > 3)
255
+ return false;
256
+ visited.add(parentId);
257
+ const stored = database?.getSessionRecord(parentId);
258
+ const candidate = stored === undefined ? candidates.get(parentId) : undefined;
259
+ if ((stored?.workspaceId ?? candidate?.workspaceId) !== session.workspaceId)
260
+ return false;
261
+ parentId =
262
+ stored === undefined
263
+ ? (candidate?.parentSessionId ?? null)
264
+ : (stored.metadata.parentSessionId ?? null);
265
+ }
266
+ return true;
267
+ }
240
268
  remember(sessions) {
241
269
  const database = this.options.database();
242
270
  const candidates = new Map(sessions.map((session) => [session.id, session]));
243
- const validParent = (session) => {
244
- const visited = new Set([session.id]);
245
- let parentId = session.parentSessionId ?? null;
246
- let depth = 1;
247
- while (parentId !== null) {
248
- if (visited.has(parentId) || ++depth > 3)
249
- return false;
250
- visited.add(parentId);
251
- const stored = database?.getSessionRecord(parentId);
252
- const candidate = stored === undefined ? candidates.get(parentId) : undefined;
253
- if ((stored?.workspaceId ?? candidate?.workspaceId) !== session.workspaceId)
254
- return false;
255
- parentId =
256
- stored === undefined
257
- ? (candidate?.parentSessionId ?? null)
258
- : (stored.metadata.parentSessionId ?? null);
259
- }
260
- return true;
261
- };
262
- const valid = new Map(sessions.map((session) => [session.id, validParent(session)]));
271
+ const valid = new Map(sessions.map((session) => [session.id, this.validParent(session, candidates)]));
263
272
  const accepted = sessions.flatMap((proposed) => {
264
273
  const metadata = database === undefined || proposed.externalSessionId === null
265
274
  ? undefined
@@ -322,7 +322,7 @@ export class SessionCatalogService {
322
322
  this.options.invalidateWorkspaceSessions(session.workspaceId);
323
323
  }
324
324
  }
325
- async removeTree(root) {
325
+ async removeTree(root, cleanup) {
326
326
  if (root.externalSessionId === null)
327
327
  throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
328
328
  const database = this.options.database();
@@ -340,13 +340,20 @@ export class SessionCatalogService {
340
340
  const ids = [root.externalSessionId];
341
341
  const seen = new Set(ids);
342
342
  const parents = new Map();
343
+ const settle = (session, deleted) => {
344
+ if (cleanup === undefined)
345
+ return;
346
+ database.deleteSessionRecord(session.id);
347
+ this.options.forget(session, this.options.nativeIds(session));
348
+ cleanup.settled(session, deleted);
349
+ };
343
350
  const releases = [];
344
351
  const locked = new Set();
345
352
  const lock = (id) => {
346
353
  const actual = this.options.runtime.getAgentSession(id)?.id ?? id;
347
354
  if (locked.has(actual))
348
355
  return;
349
- releases.push(this.options.runtime.beginSessionHistoryMutation(actual, { allowQueued: true }));
356
+ releases.push(this.options.runtime.beginSessionHistoryMutation(actual, cleanup !== undefined && actual === root.id ? undefined : { allowQueued: true }));
350
357
  locked.add(actual);
351
358
  };
352
359
  try {
@@ -355,6 +362,9 @@ export class SessionCatalogService {
355
362
  const parent = ids[index];
356
363
  const children = [
357
364
  ...database.listChildSessionRecords(root.workspaceId, parent).map((record) => record.id),
365
+ ...(cleanup?.children.get(parent) ?? [])
366
+ .filter((session) => cleanup?.candidates.has(session.id))
367
+ .map((session) => session.externalSessionId ?? session.id),
358
368
  ...(liveChildren.get(parent) ?? [])
359
369
  ];
360
370
  for (const child of children) {
@@ -371,35 +381,64 @@ export class SessionCatalogService {
371
381
  }
372
382
  const sessions = [];
373
383
  for (const id of ids) {
374
- const session = this.options.runtime.getAgentSession(id) ??
384
+ const session = cleanup?.candidates.get(id) ??
385
+ this.options.runtime.getAgentSession(id) ??
375
386
  (await this.options.projection.resolve(id, true));
376
387
  if (session === undefined || session.workspaceId !== root.workspaceId)
377
388
  throw new Error('SESSION_NOT_FOUND');
378
389
  sessions.push(session);
379
390
  }
380
391
  // Validate every member before performing any irreversible native deletion.
381
- for (const session of sessions)
382
- await this.assertRemovable(session);
383
- const batches = new Map();
392
+ for (const session of sessions) {
393
+ const missing = cleanup !== undefined && session.externalDiscovered === false;
394
+ if (missing && !this.options.runnerAvailable(session.runner))
395
+ throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
396
+ await this.assertRemovable(session, cleanup?.budget, missing);
397
+ }
398
+ if (cleanup !== undefined && this.cleanupRootProtected(root, cleanup.cutoff)) {
399
+ throw new Error('SESSION_WATCHING');
400
+ }
401
+ let batch = [];
402
+ const flushBatch = async () => {
403
+ if (batch.length === 0)
404
+ return;
405
+ cleanup?.budget.check();
406
+ await this.options.runners.require(batch[0].runner).removeNativeMembers(batch);
407
+ for (const session of batch)
408
+ settle(session, true);
409
+ batch = [];
410
+ };
384
411
  for (const session of [...sessions].reverse()) {
412
+ if (cleanup !== undefined &&
413
+ (session.externalSessionId === null || session.externalDiscovered === false)) {
414
+ await flushBatch();
415
+ cleanup.budget.check();
416
+ settle(session, true);
417
+ continue;
418
+ }
385
419
  if (session.externalSessionId === null)
386
420
  continue;
387
421
  const runner = this.options.runners.require(session.runner);
388
422
  if (runner.supportsNativeBatchRemoval?.() === true) {
389
- const members = batches.get(session.runner) ?? [];
390
- members.push(session);
391
- batches.set(session.runner, members);
423
+ if (batch.length > 0 && batch[0].runner !== session.runner)
424
+ await flushBatch();
425
+ batch.push(session);
426
+ }
427
+ else {
428
+ // Preserve descendant-first order across batch and non-batch native owners.
429
+ await flushBatch();
430
+ cleanup?.budget.check();
431
+ const deleted = await this.removeNativeHistories(session, this.options.nativeIds(session));
432
+ settle(session, deleted);
392
433
  }
393
- else
394
- await this.removeNativeHistories(session, this.options.nativeIds(session));
395
434
  }
396
- for (const [runner, members] of batches)
397
- await this.options.runners.require(runner).removeNativeMembers(members);
398
- // Preserve every relationship if any native owner fails, so retries cannot
399
- // lose sight of descendants whose native cleanup was already attempted.
400
- for (const session of sessions.reverse()) {
401
- this.options.database().deleteSessionRecord(session.id);
402
- this.options.forget(session, this.options.nativeIds(session));
435
+ await flushBatch();
436
+ // Manual deletion preserves all relationships if any native owner fails.
437
+ if (cleanup === undefined) {
438
+ for (const session of sessions.reverse()) {
439
+ this.options.database().deleteSessionRecord(session.id);
440
+ this.options.forget(session, this.options.nativeIds(session));
441
+ }
403
442
  }
404
443
  }
405
444
  finally {
@@ -443,6 +482,7 @@ export class SessionCatalogService {
443
482
  let deleted = 0;
444
483
  let skipped = 0;
445
484
  let failed = 0;
485
+ const outcomes = new Map();
446
486
  const deletedByRunner = {};
447
487
  let candidateCount;
448
488
  let timedOut = false;
@@ -458,7 +498,14 @@ export class SessionCatalogService {
458
498
  candidates.set(session.id, session);
459
499
  }
460
500
  this.mergePersisted(candidates, workspaceId, false);
461
- const candidateParents = new Set([...candidates.values()].flatMap((candidate) => candidate.parentSessionId === null ? [] : [candidate.parentSessionId]));
501
+ const children = new Map();
502
+ for (const candidate of candidates.values()) {
503
+ if (candidate.parentSessionId == null)
504
+ continue;
505
+ const siblings = children.get(candidate.parentSessionId) ?? [];
506
+ siblings.push(candidate);
507
+ children.set(candidate.parentSessionId, siblings);
508
+ }
462
509
  candidateCount = candidates.size;
463
510
  for (const session of candidates.values()) {
464
511
  budget.check();
@@ -472,14 +519,6 @@ export class SessionCatalogService {
472
519
  session.lastActivityAt > 0 &&
473
520
  session.lastActivityAt >= cutoff) ||
474
521
  (session.pinOrder !== null && session.pinOrder !== undefined) ||
475
- (session.externalSessionId !== null &&
476
- (this.options.database().listChildSessionRecords(workspaceId, session.externalSessionId)
477
- .length > 0 ||
478
- candidateParents.has(session.externalSessionId) ||
479
- this.options.runtime
480
- .listAgentSessions()
481
- .some((child) => child.workspaceId === workspaceId &&
482
- child.parentSessionId === session.externalSessionId))) ||
483
522
  this.options.isWatching(session.id) ||
484
523
  this.options.isRecovering?.(session.id) ||
485
524
  this.options.runtime.sessionQueue(session.id).itemIds.length > 0 ||
@@ -487,24 +526,31 @@ export class SessionCatalogService {
487
526
  .listRunsForSession(session.id)
488
527
  .some((run) => !isTerminalRunStatus(run.status)) ||
489
528
  this.options.externalActivity(session.id) === 'BUSY') {
490
- skipped += 1;
529
+ outcomes.set(session.id, { session, status: 'skipped' });
491
530
  continue;
492
531
  }
493
532
  try {
494
- if ((await this.removeResolved(session, true, budget)) === 'deleted') {
495
- deleted += 1;
496
- deletedByRunner[session.runner] = (deletedByRunner[session.runner] ?? 0) + 1;
497
- }
498
- else
499
- skipped += 1;
533
+ await this.removeTree(session, {
534
+ candidates,
535
+ children,
536
+ cutoff,
537
+ budget,
538
+ settled: (member, wasDeleted) => {
539
+ candidates.delete(member.id);
540
+ outcomes.set(member.id, {
541
+ session: member,
542
+ status: wasDeleted ? 'deleted' : 'skipped'
543
+ });
544
+ }
545
+ });
500
546
  }
501
547
  catch (error) {
502
548
  if (error instanceof WorkspaceSessionCleanupDeadline)
503
549
  throw error;
504
550
  if (isCleanupSkip(error))
505
- skipped += 1;
551
+ outcomes.set(session.id, { session, status: 'skipped' });
506
552
  else {
507
- failed += 1;
553
+ outcomes.set(session.id, { session, status: 'failed' });
508
554
  nodeLog('workspace.session.cleanup.failed', {
509
555
  workspaceId,
510
556
  sessionId: session.id,
@@ -514,6 +560,7 @@ export class SessionCatalogService {
514
560
  }
515
561
  }
516
562
  }
563
+ budget.check();
517
564
  }
518
565
  catch (error) {
519
566
  if (!(error instanceof WorkspaceSessionCleanupDeadline))
@@ -525,6 +572,16 @@ export class SessionCatalogService {
525
572
  finally {
526
573
  this.options.invalidateWorkspaceSessions(workspaceId);
527
574
  }
575
+ for (const { session, status } of outcomes.values()) {
576
+ if (status === 'deleted') {
577
+ deleted += 1;
578
+ deletedByRunner[session.runner] = (deletedByRunner[session.runner] ?? 0) + 1;
579
+ }
580
+ else if (status === 'skipped')
581
+ skipped += 1;
582
+ else
583
+ failed += 1;
584
+ }
528
585
  const result = {
529
586
  deleted,
530
587
  skipped,
@@ -535,7 +592,7 @@ export class SessionCatalogService {
535
592
  timedOut: true,
536
593
  ...(candidateCount === undefined
537
594
  ? {}
538
- : { remaining: candidateCount - deleted - skipped - failed })
595
+ : { remaining: Math.max(0, candidateCount - outcomes.size) })
539
596
  }
540
597
  : {})
541
598
  };
@@ -559,15 +616,16 @@ export class SessionCatalogService {
559
616
  lastActivityAt: latestSessionActivityAt(projected.lastActivityAt, native.lastActivityAt)
560
617
  };
561
618
  }
562
- async assertRemovable(session) {
619
+ async assertRemovable(session, budget, missing = false) {
563
620
  if (this.options.isRecovering?.(session.id))
564
621
  throw new Error('SESSION_RESTART_PENDING');
565
622
  if (this.options.runtime
566
623
  .listRunsForSession(session.id)
567
624
  .some((run) => !isTerminalRunStatus(run.status)))
568
625
  throw new Error('SESSION_ACTIVE');
569
- if (session.externalSessionId !== null && session.nativeControl === 'EXTERNAL') {
570
- await this.options.refreshActivity(session);
626
+ if (!missing && session.externalSessionId !== null && session.nativeControl === 'EXTERNAL') {
627
+ const refresh = () => this.options.refreshActivity(session);
628
+ await (budget ? budget.read(refresh) : refresh());
571
629
  if (this.options.externalActivity(session.id) === 'BUSY')
572
630
  throw new Error('SESSION_ACTIVE');
573
631
  }
@@ -592,55 +650,22 @@ export class SessionCatalogService {
592
650
  }
593
651
  return deleted;
594
652
  }
595
- async removeResolved(session, requireCleanupSafety = false, budget) {
596
- if (session.externalSessionId === null)
597
- throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
598
- const releaseMutation = this.options.runtime.beginSessionHistoryMutation(session.id, requireCleanupSafety ? undefined : { allowQueued: true });
599
- try {
600
- if (this.options.isRecovering?.(session.id))
601
- throw new Error('SESSION_RESTART_PENDING');
602
- const missingCandidate = requireCleanupSafety && session.externalDiscovered === false;
603
- if (this.options.runtime
653
+ cleanupRootProtected(session, cutoff) {
654
+ // Pin updates and Watch admission do not share the history mutation queue.
655
+ const pinOrder = session.externalSessionId === null
656
+ ? undefined
657
+ : this.options
658
+ .database()
659
+ .getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId)?.pinOrder;
660
+ return ((session.externalDiscovered !== false &&
661
+ latestSessionActivityAt(session.lastActivityAt, this.options.runtime.getAgentSession(session.id)?.lastActivityAt ?? 0) >= cutoff) ||
662
+ (pinOrder !== undefined && pinOrder !== null) ||
663
+ this.options.isWatching(session.id) ||
664
+ this.options.isRecovering?.(session.id) === true ||
665
+ this.options.runtime.sessionQueue(session.id).itemIds.length > 0 ||
666
+ this.options.runtime
604
667
  .listRunsForSession(session.id)
605
- .some((run) => !isTerminalRunStatus(run.status)))
606
- throw new Error('SESSION_ACTIVE');
607
- if (session.nativeControl === 'EXTERNAL' && !missingCandidate) {
608
- const refresh = () => this.options.refreshActivity(session);
609
- await (budget ? budget.read(refresh) : refresh());
610
- if (this.options.externalActivity(session.id) === 'BUSY')
611
- throw new Error('SESSION_ACTIVE');
612
- }
613
- if (requireCleanupSafety) {
614
- if (this.options.isWatching(session.id))
615
- throw new Error('SESSION_WATCHING');
616
- if (this.options.runtime.sessionQueue(session.id).itemIds.length > 0)
617
- throw new Error('SESSION_QUEUE_NOT_EMPTY');
618
- // Pin updates do not share the cleanup mutation queue.
619
- const pinOrder = this.options
620
- .database()
621
- .getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId)?.pinOrder;
622
- if (pinOrder !== undefined && pinOrder !== null)
623
- return 'skipped';
624
- if (this.options.runtime
625
- .listRunsForSession(session.id)
626
- .some((run) => !isTerminalRunStatus(run.status)))
627
- throw new Error('SESSION_ACTIVE');
628
- }
629
- budget?.check();
630
- const nativeIds = this.options.nativeIds(session);
631
- if (missingCandidate) {
632
- this.options.database().deleteSessionRecord(session.id);
633
- this.options.forget(session, nativeIds);
634
- return 'deleted';
635
- }
636
- const deleted = await this.removeNativeHistories(session, nativeIds);
637
- this.options.database().deleteSessionRecord(session.id);
638
- this.options.forget(session, nativeIds);
639
- return deleted ? 'deleted' : 'skipped';
640
- }
641
- finally {
642
- releaseMutation();
643
- }
668
+ .some((run) => !isTerminalRunStatus(run.status)));
644
669
  }
645
670
  }
646
671
  export function rewindTitle(sourceTitle, existingTitles = []) {
@@ -674,6 +699,7 @@ function deduplicateNativeSessions(sessions) {
674
699
  function isCleanupSkip(error) {
675
700
  return (error instanceof Error &&
676
701
  (error.message === 'SESSION_ACTIVE' ||
702
+ error.message === 'SESSION_RESTART_PENDING' ||
677
703
  error.message === 'SESSION_NOT_FOUND' ||
678
704
  error.message === 'SESSION_QUEUE_NOT_EMPTY' ||
679
705
  error.message === 'SESSION_WATCHING' ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.11.46",
3
+ "version": "0.11.47",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -38,10 +38,10 @@
38
38
  "sharp": "0.35.5",
39
39
  "ws": "^8.21.3",
40
40
  "zod": "4.4.3",
41
- "@myagentroam/agent": "0.11.46",
42
- "@myagentroam/marctl": "0.11.7",
43
- "@myagentroam/protocol": "0.11.46",
44
- "@myagentroam/code-navigation": "0.11.1"
41
+ "@myagentroam/code-navigation": "0.11.1",
42
+ "@myagentroam/marctl": "0.11.8",
43
+ "@myagentroam/agent": "0.11.47",
44
+ "@myagentroam/protocol": "0.11.47"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/ws": "^8.18.1"