nemo-relay-openclaw 0.3.0 → 0.5.0
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.md +2 -2
- package/dist/index.d.ts +10 -8
- package/dist/index.js +2 -1
- package/dist/src/hook-replay/llm.js +64 -31
- package/dist/src/hook-replay/marks.d.ts +1 -0
- package/dist/src/hook-replay/marks.js +1 -1
- package/dist/src/hook-replay/session.d.ts +27 -9
- package/dist/src/hook-replay/session.js +146 -46
- package/dist/src/hook-replay/tool.js +18 -3
- package/dist/src/hooks-backend.d.ts +27 -3
- package/dist/src/hooks-backend.js +230 -24
- package/index.ts +5 -1
- package/package.json +2 -2
- package/src/hook-replay/llm.ts +70 -31
- package/src/hook-replay/marks.ts +2 -1
- package/src/hook-replay/session.ts +178 -53
- package/src/hook-replay/tool.ts +18 -3
- package/src/hooks-backend.ts +283 -21
package/src/hooks-backend.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { NemoRelayHookBackendConfig } from './config.js';
|
|
12
12
|
import { emitMark, toJsonRecord } from './hook-replay/marks.js';
|
|
13
|
-
import { llmKey } from './hook-replay/correlation.js';
|
|
13
|
+
import { llmKey, nowMicros } from './hook-replay/correlation.js';
|
|
14
14
|
import {
|
|
15
15
|
emitUnpairedModelCallTimingMarks,
|
|
16
16
|
recordBeforeMessageWrite,
|
|
@@ -28,7 +28,9 @@ import {
|
|
|
28
28
|
closeSessionRoot,
|
|
29
29
|
deleteSession,
|
|
30
30
|
ensureSession,
|
|
31
|
-
|
|
31
|
+
materializeSessionRoot,
|
|
32
|
+
queueCapturedEmit,
|
|
33
|
+
resolveSessionOwnerKey,
|
|
32
34
|
type HookReplayBackendState,
|
|
33
35
|
type SessionLookupInput,
|
|
34
36
|
type SessionState,
|
|
@@ -66,6 +68,14 @@ export type HookReplayBackendOptions = {
|
|
|
66
68
|
agentVersion: string;
|
|
67
69
|
};
|
|
68
70
|
|
|
71
|
+
type PendingSubagentLineage = {
|
|
72
|
+
childSessionKey: string;
|
|
73
|
+
requesterSessionKey: string;
|
|
74
|
+
runId?: string;
|
|
75
|
+
agentId?: string;
|
|
76
|
+
observedAtMs: number;
|
|
77
|
+
};
|
|
78
|
+
|
|
69
79
|
/** Replays OpenClaw public hook events into NeMo Relay scopes, spans, and marks. */
|
|
70
80
|
export class HookReplayBackend {
|
|
71
81
|
private readonly nf: NemoRelayRuntimeModule;
|
|
@@ -74,6 +84,8 @@ export class HookReplayBackend {
|
|
|
74
84
|
private readonly agentVersion: string;
|
|
75
85
|
private readonly stateValue = createHookReplayState();
|
|
76
86
|
private readonly warningCounts = new Map<string, number>();
|
|
87
|
+
private readonly pendingSubagentLineageByChildSessionKey = new Map<string, PendingSubagentLineage>();
|
|
88
|
+
private readonly pendingSubagentChildKeyByRunId = new Map<string, string>();
|
|
77
89
|
|
|
78
90
|
constructor(options: HookReplayBackendOptions) {
|
|
79
91
|
this.nf = options.nf;
|
|
@@ -95,14 +107,18 @@ export class HookReplayBackend {
|
|
|
95
107
|
|
|
96
108
|
/** Open or alias an explicit OpenClaw session root. */
|
|
97
109
|
onSessionStart(event: PluginHookSessionStartEvent, ctx: PluginHookSessionContext): void {
|
|
98
|
-
|
|
110
|
+
const observedAtMicros = nowMicros();
|
|
111
|
+
const session = this.ensureSession({
|
|
99
112
|
sessionId: event.sessionId,
|
|
100
113
|
sessionKey: event.sessionKey ?? ctx.sessionKey,
|
|
101
114
|
agentId: ctx.agentId,
|
|
102
115
|
source: 'session_start',
|
|
103
116
|
resumedFrom: event.resumedFrom,
|
|
117
|
+
timestamp: observedAtMicros,
|
|
104
118
|
});
|
|
105
119
|
|
|
120
|
+
this.promoteDeferredSubagentSessionsForRequester(session?.sessionKey ?? event.sessionKey ?? ctx.sessionKey);
|
|
121
|
+
|
|
106
122
|
// ensureSession opens the root scope and emits openclaw.session_start for both explicit and lazy sessions.
|
|
107
123
|
}
|
|
108
124
|
|
|
@@ -119,7 +135,17 @@ export class HookReplayBackend {
|
|
|
119
135
|
return;
|
|
120
136
|
}
|
|
121
137
|
|
|
122
|
-
await this.closeSession(
|
|
138
|
+
await this.closeSession(
|
|
139
|
+
session,
|
|
140
|
+
sessionEndSummary(event),
|
|
141
|
+
toJsonRecord({
|
|
142
|
+
source: 'openclaw.session_end',
|
|
143
|
+
hook_event_name: 'session_end',
|
|
144
|
+
sessionId: session.sessionId,
|
|
145
|
+
sessionKey: event.sessionKey ?? ctx.sessionKey,
|
|
146
|
+
agentId: ctx.agentId,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
/** Buffer an LLM request snapshot until a matching response or trajectory replay arrives. */
|
|
@@ -159,12 +185,14 @@ export class HookReplayBackend {
|
|
|
159
185
|
|
|
160
186
|
/** Finalize one agent run, replaying message-write trajectory when needed. */
|
|
161
187
|
onAgentEnd(event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext): void {
|
|
188
|
+
const observedAtMicros = nowMicros();
|
|
162
189
|
const session = this.ensureSession({
|
|
163
190
|
sessionId: ctx.sessionId,
|
|
164
191
|
sessionKey: ctx.sessionKey,
|
|
165
192
|
runId: event.runId ?? ctx.runId,
|
|
166
193
|
agentId: ctx.agentId,
|
|
167
194
|
source: 'lazy_session',
|
|
195
|
+
timestamp: observedAtMicros,
|
|
168
196
|
});
|
|
169
197
|
|
|
170
198
|
if (!session) {
|
|
@@ -186,17 +214,20 @@ export class HookReplayBackend {
|
|
|
186
214
|
durationMs: event.durationMs,
|
|
187
215
|
messageCount: event.messages.length,
|
|
188
216
|
}),
|
|
217
|
+
observedAtMicros,
|
|
189
218
|
);
|
|
190
219
|
}
|
|
191
220
|
|
|
192
221
|
/** Remember the last assistant text before OpenClaw finalizes the response. */
|
|
193
222
|
onBeforeAgentFinalize(event: PluginHookBeforeAgentFinalizeEvent, ctx: PluginHookAgentContext): void {
|
|
223
|
+
const observedAtMicros = nowMicros();
|
|
194
224
|
const session = this.ensureSession({
|
|
195
225
|
sessionId: event.sessionId,
|
|
196
226
|
sessionKey: event.sessionKey ?? ctx.sessionKey,
|
|
197
227
|
runId: event.runId ?? ctx.runId,
|
|
198
228
|
agentId: ctx.agentId,
|
|
199
229
|
source: 'lazy_session',
|
|
230
|
+
timestamp: observedAtMicros,
|
|
200
231
|
});
|
|
201
232
|
|
|
202
233
|
if (!session) {
|
|
@@ -224,21 +255,24 @@ export class HookReplayBackend {
|
|
|
224
255
|
stopHookActive: event.stopHookActive,
|
|
225
256
|
messageCount: event.messages?.length,
|
|
226
257
|
}),
|
|
258
|
+
observedAtMicros,
|
|
227
259
|
);
|
|
228
260
|
}
|
|
229
261
|
|
|
230
262
|
/** Attach subagent spawn metadata to the requester session when possible. */
|
|
231
263
|
onSubagentSpawned(event: PluginHookSubagentSpawnedEvent, ctx: PluginHookSubagentContext): void {
|
|
264
|
+
const observedAtMicros = nowMicros();
|
|
265
|
+
this.trackPendingSubagentLineage(event, ctx, Math.trunc(observedAtMicros / 1000));
|
|
266
|
+
const requesterSession = this.ensureRequesterSessionAnchor(ctx.requesterSessionKey, observedAtMicros);
|
|
267
|
+
const childSessionKey = ctx.childSessionKey ?? event.childSessionKey;
|
|
232
268
|
const session =
|
|
269
|
+
requesterSession ??
|
|
233
270
|
this.ensureSession({
|
|
234
|
-
|
|
235
|
-
source: 'lazy_session',
|
|
236
|
-
}) ??
|
|
237
|
-
this.ensureSession({
|
|
238
|
-
childSessionKey: ctx.childSessionKey ?? event.childSessionKey,
|
|
271
|
+
childSessionKey,
|
|
239
272
|
runId: ctx.runId ?? event.runId,
|
|
240
273
|
agentId: event.agentId,
|
|
241
274
|
source: 'lazy_session',
|
|
275
|
+
timestamp: observedAtMicros,
|
|
242
276
|
});
|
|
243
277
|
|
|
244
278
|
if (!session) {
|
|
@@ -256,26 +290,36 @@ export class HookReplayBackend {
|
|
|
256
290
|
mode: event.mode,
|
|
257
291
|
threadRequested: event.threadRequested,
|
|
258
292
|
}),
|
|
293
|
+
observedAtMicros,
|
|
259
294
|
);
|
|
295
|
+
|
|
296
|
+
if (!ctx.requesterSessionKey || requesterSession?.rootHandle) {
|
|
297
|
+
this.promoteDeferredSubagentSession(event.childSessionKey);
|
|
298
|
+
}
|
|
260
299
|
}
|
|
261
300
|
|
|
262
301
|
/** Attach subagent completion metadata to the requester or child session. */
|
|
263
302
|
onSubagentEnded(event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext): void {
|
|
303
|
+
const observedAtMicros = nowMicros();
|
|
264
304
|
const session =
|
|
265
305
|
this.ensureSession({
|
|
266
306
|
requesterSessionKey: ctx.requesterSessionKey,
|
|
267
307
|
source: 'lazy_session',
|
|
308
|
+
timestamp: observedAtMicros,
|
|
268
309
|
}) ??
|
|
269
310
|
this.ensureSession({
|
|
270
311
|
childSessionKey: ctx.childSessionKey ?? event.targetSessionKey,
|
|
271
312
|
runId: ctx.runId ?? event.runId,
|
|
272
313
|
source: 'lazy_session',
|
|
314
|
+
timestamp: observedAtMicros,
|
|
273
315
|
});
|
|
274
316
|
|
|
275
317
|
if (!session) {
|
|
276
318
|
return;
|
|
277
319
|
}
|
|
278
320
|
|
|
321
|
+
this.materializeDeferredSessionRoot(session);
|
|
322
|
+
|
|
279
323
|
this.emitSessionMark(
|
|
280
324
|
'openclaw.subagent_ended',
|
|
281
325
|
session,
|
|
@@ -290,6 +334,7 @@ export class HookReplayBackend {
|
|
|
290
334
|
sendFarewell: event.sendFarewell,
|
|
291
335
|
accountId: event.accountId,
|
|
292
336
|
}),
|
|
337
|
+
observedAtMicros,
|
|
293
338
|
);
|
|
294
339
|
}
|
|
295
340
|
|
|
@@ -300,12 +345,12 @@ export class HookReplayBackend {
|
|
|
300
345
|
|
|
301
346
|
/** Close one session selected by a runtime lifecycle cleanup hook. */
|
|
302
347
|
async cleanupSession(input: SessionLookupInput & { reason: string }): Promise<void> {
|
|
303
|
-
const
|
|
304
|
-
if (!
|
|
348
|
+
const ownerKey = resolveSessionOwnerKey(this.stateValue, input);
|
|
349
|
+
if (!ownerKey) {
|
|
305
350
|
return;
|
|
306
351
|
}
|
|
307
352
|
|
|
308
|
-
const session = this.stateValue.sessions.get(
|
|
353
|
+
const session = this.stateValue.sessions.get(ownerKey);
|
|
309
354
|
if (!session) {
|
|
310
355
|
return;
|
|
311
356
|
}
|
|
@@ -346,6 +391,10 @@ export class HookReplayBackend {
|
|
|
346
391
|
|
|
347
392
|
/** Emit spans/marks under the stored session scope stack and ATIF capture window. */
|
|
348
393
|
emitCapturedUnderSession(label: string, session: SessionState, emit: () => void): void {
|
|
394
|
+
if (queueCapturedEmit(session, label, emit)) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
349
398
|
this.safeReplay(label, session, () => {
|
|
350
399
|
const previousStack = this.nf.currentScopeStack();
|
|
351
400
|
try {
|
|
@@ -373,23 +422,39 @@ export class HookReplayBackend {
|
|
|
373
422
|
}
|
|
374
423
|
|
|
375
424
|
/** Drain, close, export, and delete one session. */
|
|
376
|
-
private async closeSession(session: SessionState, summary: JsonRecord): Promise<void> {
|
|
425
|
+
private async closeSession(session: SessionState, summary: JsonRecord, metadata?: JsonRecord): Promise<void> {
|
|
426
|
+
this.materializeDeferredSessionRoot(session);
|
|
377
427
|
drainSession(this.sessionManager(), session);
|
|
378
|
-
closeSessionRoot(this.sessionManager(), session, summary, session.finalOutput ?? summary);
|
|
428
|
+
closeSessionRoot(this.sessionManager(), session, summary, session.finalOutput ?? summary, metadata);
|
|
379
429
|
this.flushSubscriberDelivery('session_close');
|
|
430
|
+
this.forgetPendingSubagentLineage(session);
|
|
380
431
|
deleteSession(this.stateValue, session);
|
|
381
432
|
}
|
|
382
433
|
|
|
383
434
|
/** Emit a session-level OpenClaw lifecycle mark. */
|
|
384
|
-
private emitSessionMark(name: string, session: SessionState, data: JsonRecord): void {
|
|
435
|
+
private emitSessionMark(name: string, session: SessionState, data: JsonRecord, timestampMicros?: number): void {
|
|
385
436
|
this.emitCapturedUnderSession(name, session, () => {
|
|
386
|
-
emitMark
|
|
437
|
+
const params: Parameters<typeof emitMark>[0] = {
|
|
387
438
|
nf: this.nf,
|
|
388
439
|
state: this.stateValue,
|
|
389
440
|
session,
|
|
390
441
|
name,
|
|
391
442
|
data,
|
|
392
|
-
|
|
443
|
+
metadata: toJsonRecord({
|
|
444
|
+
source: name,
|
|
445
|
+
hook_event_name: name.startsWith('openclaw.') ? name.slice('openclaw.'.length) : undefined,
|
|
446
|
+
sessionId: session.sessionId,
|
|
447
|
+
sessionKey: session.sessionKey,
|
|
448
|
+
agentId: session.agentId,
|
|
449
|
+
runId: typeof data.runId === 'string' ? data.runId : undefined,
|
|
450
|
+
}),
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
if (timestampMicros !== undefined) {
|
|
454
|
+
params.timestamp = timestampMicros;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
emitMark(params);
|
|
393
458
|
});
|
|
394
459
|
}
|
|
395
460
|
|
|
@@ -426,9 +491,202 @@ export class HookReplayBackend {
|
|
|
426
491
|
this.replayPendingLlmOutputsForSession(session, options),
|
|
427
492
|
emitUnpairedModelCallTimingMarks: (session: SessionState) => this.emitUnpairedModelCallTimingMarks(session),
|
|
428
493
|
logBoundedWarn: (key: string, message: string) => this.logBoundedWarn(key, message),
|
|
494
|
+
resolveSessionRootContext: (input: Parameters<typeof ensureSession>[1]) => this.resolveSessionRootContext(input),
|
|
429
495
|
};
|
|
430
496
|
}
|
|
431
497
|
|
|
498
|
+
/** Prefer nested child scopes only when the hook surface provides real subagent lineage. */
|
|
499
|
+
private resolveSessionRootContext(input: Parameters<typeof ensureSession>[1]): Partial<Parameters<typeof ensureSession>[1]> | undefined {
|
|
500
|
+
this.pruneExpiredPendingSubagentLineage(microsToMs(input.timestamp) ?? Date.now());
|
|
501
|
+
const lineage = this.resolvePendingSubagentLineage(input);
|
|
502
|
+
if (lineage) {
|
|
503
|
+
const parentSession = this.resolveTrackedSession({ requesterSessionKey: lineage.requesterSessionKey });
|
|
504
|
+
return {
|
|
505
|
+
childSessionKey: input.childSessionKey ?? lineage.childSessionKey,
|
|
506
|
+
runId: input.runId ?? lineage.runId,
|
|
507
|
+
agentId: input.agentId ?? lineage.agentId,
|
|
508
|
+
scopeRole: 'subagent',
|
|
509
|
+
parentHandle: parentSession?.rootHandle,
|
|
510
|
+
deferRootOpen: input.deferRootOpen ?? (parentSession?.rootHandle ? false : true),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (this.isDocumentedSubagentSessionKey(input.sessionKey ?? input.childSessionKey)) {
|
|
515
|
+
return {
|
|
516
|
+
scopeRole: 'subagent',
|
|
517
|
+
deferRootOpen: input.deferRootOpen ?? true,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Track stable parent/child lineage from subagent hooks until child session hooks can use it. */
|
|
525
|
+
private trackPendingSubagentLineage(
|
|
526
|
+
event: PluginHookSubagentSpawnedEvent,
|
|
527
|
+
ctx: PluginHookSubagentContext,
|
|
528
|
+
observedAtMs: number,
|
|
529
|
+
): void {
|
|
530
|
+
this.pruneExpiredPendingSubagentLineage(observedAtMs);
|
|
531
|
+
const requesterSessionKey = ctx.requesterSessionKey?.trim();
|
|
532
|
+
const childSessionKey = (ctx.childSessionKey ?? event.childSessionKey)?.trim();
|
|
533
|
+
if (!requesterSessionKey || !childSessionKey) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
this.pendingSubagentLineageByChildSessionKey.set(childSessionKey, {
|
|
538
|
+
childSessionKey,
|
|
539
|
+
requesterSessionKey,
|
|
540
|
+
runId: ctx.runId ?? event.runId,
|
|
541
|
+
agentId: event.agentId,
|
|
542
|
+
observedAtMs,
|
|
543
|
+
});
|
|
544
|
+
if (ctx.runId ?? event.runId) {
|
|
545
|
+
this.pendingSubagentChildKeyByRunId.set(ctx.runId ?? event.runId, childSessionKey);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Resolve the requester session if it exists, or seed a deferred lazy root placeholder for later promotion. */
|
|
550
|
+
private ensureRequesterSessionAnchor(requesterSessionKey: string | undefined, timestampMicros?: number): SessionState | undefined {
|
|
551
|
+
const trimmedRequesterSessionKey = requesterSessionKey?.trim();
|
|
552
|
+
if (!trimmedRequesterSessionKey) {
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
this.resolveTrackedSession({ requesterSessionKey: trimmedRequesterSessionKey }) ??
|
|
558
|
+
this.ensureSession({
|
|
559
|
+
sessionKey: trimmedRequesterSessionKey,
|
|
560
|
+
requesterSessionKey: trimmedRequesterSessionKey,
|
|
561
|
+
source: 'lazy_session',
|
|
562
|
+
timestamp: timestampMicros,
|
|
563
|
+
deferRootOpen: true,
|
|
564
|
+
})
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Open a deferred child session root once the requester scope is known. */
|
|
569
|
+
private promoteDeferredSubagentSession(childSessionKey: string): void {
|
|
570
|
+
const session = this.resolveTrackedSession({ sessionKey: childSessionKey, childSessionKey });
|
|
571
|
+
if (!session) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
this.materializeDeferredSessionRoot(session);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Promote any deferred child sessions waiting on a requester root once it exists. */
|
|
579
|
+
private promoteDeferredSubagentSessionsForRequester(requesterSessionKey: string | undefined): void {
|
|
580
|
+
const trimmedRequesterSessionKey = requesterSessionKey?.trim();
|
|
581
|
+
if (!trimmedRequesterSessionKey) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const requesterSession = this.resolveTrackedSession({ requesterSessionKey: trimmedRequesterSessionKey });
|
|
586
|
+
if (!requesterSession?.rootHandle) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
for (const lineage of this.pendingSubagentLineageByChildSessionKey.values()) {
|
|
591
|
+
if (lineage.requesterSessionKey === trimmedRequesterSessionKey) {
|
|
592
|
+
this.promoteDeferredSubagentSession(lineage.childSessionKey);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Materialize one deferred session root with nested lineage when available. */
|
|
598
|
+
private materializeDeferredSessionRoot(session: SessionState): void {
|
|
599
|
+
if (session.rootHandle) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
this.pruneExpiredPendingSubagentLineage(Date.now());
|
|
604
|
+
const lineage = this.resolvePendingSubagentLineage({
|
|
605
|
+
sessionId: session.sessionId,
|
|
606
|
+
sessionKey: session.sessionKey,
|
|
607
|
+
});
|
|
608
|
+
const parentSession =
|
|
609
|
+
lineage === undefined
|
|
610
|
+
? undefined
|
|
611
|
+
: this.ensureRequesterSessionAnchor(lineage.requesterSessionKey, session.pendingRootTimestampMicros);
|
|
612
|
+
if (parentSession && !parentSession.rootHandle) {
|
|
613
|
+
materializeSessionRoot(this.sessionManager(), parentSession, {
|
|
614
|
+
sessionId: parentSession.sessionId,
|
|
615
|
+
sessionKey: parentSession.sessionKey ?? lineage?.requesterSessionKey,
|
|
616
|
+
source: parentSession.source,
|
|
617
|
+
resumedFrom: parentSession.resumedFrom,
|
|
618
|
+
timestamp: parentSession.pendingRootTimestampMicros,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
const parentHandle = parentSession?.rootHandle;
|
|
622
|
+
|
|
623
|
+
materializeSessionRoot(this.sessionManager(), session, {
|
|
624
|
+
sessionId: session.sessionId,
|
|
625
|
+
sessionKey: session.sessionKey,
|
|
626
|
+
runId: lineage?.runId,
|
|
627
|
+
agentId: session.agentId ?? lineage?.agentId,
|
|
628
|
+
source: session.source,
|
|
629
|
+
resumedFrom: session.resumedFrom,
|
|
630
|
+
scopeRole: session.scopeRole,
|
|
631
|
+
parentHandle,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Resolve stable subagent lineage from child session key first, then run id as a fallback. */
|
|
636
|
+
private resolvePendingSubagentLineage(input: SessionLookupInput): PendingSubagentLineage | undefined {
|
|
637
|
+
const childSessionKey = [input.sessionKey, input.childSessionKey]
|
|
638
|
+
.find((value): value is string => this.isDocumentedSubagentSessionKey(value))
|
|
639
|
+
?.trim();
|
|
640
|
+
if (childSessionKey) {
|
|
641
|
+
return this.pendingSubagentLineageByChildSessionKey.get(childSessionKey);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const runChildSessionKey =
|
|
645
|
+
typeof input.runId === 'string' && input.runId.length > 0 ? this.pendingSubagentChildKeyByRunId.get(input.runId) : undefined;
|
|
646
|
+
return runChildSessionKey === undefined ? undefined : this.pendingSubagentLineageByChildSessionKey.get(runChildSessionKey);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Drop stale pending lineage entries so abandoned subagent spawns do not accumulate indefinitely. */
|
|
650
|
+
private pruneExpiredPendingSubagentLineage(nowMs: number): void {
|
|
651
|
+
const ttlMs = this.config.correlation.recordTtlMs;
|
|
652
|
+
for (const [childSessionKey, lineage] of this.pendingSubagentLineageByChildSessionKey) {
|
|
653
|
+
if (nowMs - lineage.observedAtMs > ttlMs) {
|
|
654
|
+
this.pendingSubagentLineageByChildSessionKey.delete(childSessionKey);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
for (const [runId, childSessionKey] of this.pendingSubagentChildKeyByRunId) {
|
|
659
|
+
if (!this.pendingSubagentLineageByChildSessionKey.has(childSessionKey)) {
|
|
660
|
+
this.pendingSubagentChildKeyByRunId.delete(runId);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Resolve one session through the same alias map used by the replay state. */
|
|
666
|
+
private resolveTrackedSession(input: SessionLookupInput): SessionState | undefined {
|
|
667
|
+
const ownerKey = resolveSessionOwnerKey(this.stateValue, input);
|
|
668
|
+
return ownerKey === undefined ? undefined : this.stateValue.sessions.get(ownerKey);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Free lineage bookkeeping once the child session is closed. */
|
|
672
|
+
private forgetPendingSubagentLineage(session: SessionState): void {
|
|
673
|
+
const childSessionKey = session.sessionKey;
|
|
674
|
+
if (childSessionKey) {
|
|
675
|
+
this.pendingSubagentLineageByChildSessionKey.delete(childSessionKey);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
for (const [runId, trackedChildSessionKey] of this.pendingSubagentChildKeyByRunId) {
|
|
679
|
+
if (trackedChildSessionKey === childSessionKey) {
|
|
680
|
+
this.pendingSubagentChildKeyByRunId.delete(runId);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Match the documented native subagent session key shape without depending on private OpenClaw internals. */
|
|
686
|
+
private isDocumentedSubagentSessionKey(value?: string): value is string {
|
|
687
|
+
return typeof value === 'string' && /^agent:[^:]+:subagent:/.test(value);
|
|
688
|
+
}
|
|
689
|
+
|
|
432
690
|
/** Log one warning per key to avoid noisy repeated hook failures. */
|
|
433
691
|
private logBoundedWarn(key: string, message: string): void {
|
|
434
692
|
const count = this.warningCounts.get(key) ?? 0;
|
|
@@ -441,12 +699,16 @@ export class HookReplayBackend {
|
|
|
441
699
|
|
|
442
700
|
export { llmKey };
|
|
443
701
|
|
|
444
|
-
/** Expose
|
|
445
|
-
export function
|
|
702
|
+
/** Expose owner-key resolution for tests without exporting the full session module. */
|
|
703
|
+
export function resolveBackendSessionOwnerKey(
|
|
446
704
|
state: HookReplayBackendState,
|
|
447
|
-
input: Parameters<typeof
|
|
705
|
+
input: Parameters<typeof resolveSessionOwnerKey>[1],
|
|
448
706
|
): string | undefined {
|
|
449
|
-
return
|
|
707
|
+
return resolveSessionOwnerKey(state, input);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function microsToMs(timestampMicros: number | undefined): number | undefined {
|
|
711
|
+
return timestampMicros === undefined ? undefined : Math.trunc(timestampMicros / 1000);
|
|
450
712
|
}
|
|
451
713
|
|
|
452
714
|
/** Build the lifecycle summary stored as the session_end mark payload. */
|