@martian-engineering/lossless-claw 0.5.3 → 0.6.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.
@@ -13,9 +13,11 @@ import {
13
13
  resolveRequesterConversationScopeId,
14
14
  } from "./lcm-expand-tool.delegation.js";
15
15
  import {
16
+ acquireExpansionConcurrencySlot,
16
17
  clearDelegatedExpansionContext,
17
18
  evaluateExpansionRecursionGuard,
18
19
  recordExpansionDelegationTelemetry,
20
+ releaseExpansionConcurrencySlot,
19
21
  resolveExpansionRequestId,
20
22
  resolveNextExpansionDepth,
21
23
  stampDelegatedExpansionContext,
@@ -531,34 +533,36 @@ export function createLcmExpandQueryTool(input: {
531
533
  });
532
534
  }
533
535
 
534
- const conversationScope = await resolveLcmConversationScope({
535
- lcm: input.lcm,
536
- deps: input.deps,
537
- sessionId: input.sessionId,
538
- sessionKey: input.sessionKey,
539
- params: p,
540
- });
541
- let scopedConversationId = conversationScope.conversationId;
542
- if (
543
- !conversationScope.allConversations &&
544
- scopedConversationId == null &&
545
- callerSessionKey
546
- ) {
547
- scopedConversationId = await resolveRequesterConversationScopeId({
548
- deps: input.deps,
549
- requesterSessionKey: callerSessionKey,
536
+ const originSessionKey = recursionCheck.originSessionKey || callerSessionKey || "main";
537
+
538
+ try {
539
+ const conversationScope = await resolveLcmConversationScope({
550
540
  lcm: input.lcm,
541
+ deps: input.deps,
542
+ sessionId: input.sessionId,
543
+ sessionKey: input.sessionKey,
544
+ params: p,
551
545
  });
552
- }
546
+ let scopedConversationId = conversationScope.conversationId;
547
+ if (
548
+ !conversationScope.allConversations &&
549
+ scopedConversationId == null &&
550
+ callerSessionKey
551
+ ) {
552
+ scopedConversationId = await resolveRequesterConversationScopeId({
553
+ deps: input.deps,
554
+ requesterSessionKey: callerSessionKey,
555
+ lcm: input.lcm,
556
+ });
557
+ }
553
558
 
554
- if (!conversationScope.allConversations && scopedConversationId == null) {
555
- return jsonResult({
556
- error:
557
- "No LCM conversation found for this session. Provide conversationId or set allConversations=true.",
558
- });
559
- }
559
+ if (!conversationScope.allConversations && scopedConversationId == null) {
560
+ return jsonResult({
561
+ error:
562
+ "No LCM conversation found for this session. Provide conversationId or set allConversations=true.",
563
+ });
564
+ }
560
565
 
561
- try {
562
566
  const candidates = await resolveSummaryCandidates({
563
567
  lcm: input.lcm,
564
568
  explicitSummaryIds,
@@ -608,11 +612,35 @@ export function createLcmExpandQueryTool(input: {
608
612
  });
609
613
  }
610
614
 
615
+ const concurrencyCheck = acquireExpansionConcurrencySlot({
616
+ originSessionKey,
617
+ requestId,
618
+ });
619
+ if (concurrencyCheck.blocked) {
620
+ recordExpansionDelegationTelemetry({
621
+ deps: input.deps,
622
+ component: "lcm_expand_query",
623
+ event: "block",
624
+ requestId,
625
+ sessionKey: callerSessionKey,
626
+ expansionDepth: recursionCheck.expansionDepth,
627
+ originSessionKey: concurrencyCheck.originSessionKey,
628
+ reason: concurrencyCheck.reason,
629
+ });
630
+ return jsonResult({
631
+ errorCode: concurrencyCheck.code,
632
+ error: concurrencyCheck.message,
633
+ requestId: concurrencyCheck.requestId,
634
+ expansionDepth: recursionCheck.expansionDepth,
635
+ originSessionKey: concurrencyCheck.originSessionKey,
636
+ reason: concurrencyCheck.reason,
637
+ });
638
+ }
639
+
611
640
  const requesterAgentId = input.deps.normalizeAgentId(
612
641
  input.deps.parseAgentSessionKey(callerSessionKey)?.agentId,
613
642
  );
614
643
  const childExpansionDepth = resolveNextExpansionDepth(callerSessionKey);
615
- const originSessionKey = recursionCheck.originSessionKey || callerSessionKey || "main";
616
644
 
617
645
  const task = buildDelegatedExpandQueryTask({
618
646
  summaryIds,
@@ -787,6 +815,11 @@ export function createLcmExpandQueryTool(input: {
787
815
  return jsonResult({
788
816
  error: failure,
789
817
  });
818
+ } finally {
819
+ releaseExpansionConcurrencySlot({
820
+ originSessionKey,
821
+ requestId,
822
+ });
790
823
  }
791
824
  },
792
825
  };
@@ -3,6 +3,7 @@ import { resolveDelegatedExpansionGrantId } from "../expansion-auth.js";
3
3
  import type { LcmDependencies } from "../types.js";
4
4
 
5
5
  export const EXPANSION_RECURSION_ERROR_CODE = "EXPANSION_RECURSION_BLOCKED";
6
+ export const EXPANSION_CONCURRENCY_ERROR_CODE = "EXPANSION_CONCURRENCY_BLOCKED";
6
7
  const EXPANSION_DELEGATION_DEPTH_CAP = 1;
7
8
 
8
9
  type TelemetryEvent = "start" | "block" | "timeout" | "success";
@@ -23,6 +24,7 @@ export type DelegatedExpansionContext = {
23
24
  };
24
25
 
25
26
  export type ExpansionRecursionBlockReason = "depth_cap" | "idempotent_reentry";
27
+ export type ExpansionConcurrencyBlockReason = "origin_session_in_flight";
26
28
 
27
29
  export type ExpansionRecursionGuardDecision =
28
30
  | {
@@ -41,8 +43,24 @@ export type ExpansionRecursionGuardDecision =
41
43
  originSessionKey: string;
42
44
  };
43
45
 
46
+ export type ExpansionConcurrencyGuardDecision =
47
+ | {
48
+ blocked: false;
49
+ requestId: string;
50
+ originSessionKey: string;
51
+ }
52
+ | {
53
+ blocked: true;
54
+ code: typeof EXPANSION_CONCURRENCY_ERROR_CODE;
55
+ reason: ExpansionConcurrencyBlockReason;
56
+ message: string;
57
+ requestId: string;
58
+ originSessionKey: string;
59
+ };
60
+
44
61
  const delegatedContextBySessionKey = new Map<string, DelegatedExpansionContext>();
45
62
  const blockedRequestIdsBySessionKey = new Map<string, Set<string>>();
63
+ const activeRequestIdByOriginSessionKey = new Map<string, string>();
46
64
 
47
65
  function normalizeSessionKey(sessionKey?: string): string {
48
66
  return typeof sessionKey === "string" ? sessionKey.trim() : "";
@@ -90,6 +108,14 @@ function buildExpansionRecursionRecoveryGuidance(originSessionKey: string): stri
90
108
  );
91
109
  }
92
110
 
111
+ function buildExpansionConcurrencyRecoveryGuidance(originSessionKey: string): string {
112
+ return (
113
+ "Recovery: Wait for the active expansion to finish before retrying. " +
114
+ `If you need an immediate fallback, stay in the origin session (${originSessionKey}) ` +
115
+ "and use `lcm_grep` or `lcm_describe` instead."
116
+ );
117
+ }
118
+
93
119
  /**
94
120
  * Create a stable request identifier for delegated expansion orchestration.
95
121
  */
@@ -213,6 +239,66 @@ export function evaluateExpansionRecursionGuard(params: {
213
239
  };
214
240
  }
215
241
 
242
+ /**
243
+ * Acquire the single active delegated-expansion slot for an origin session.
244
+ * A second concurrent caller from the same origin session is blocked
245
+ * immediately so sub-agents do not deadlock on a shared lane.
246
+ */
247
+ export function acquireExpansionConcurrencySlot(params: {
248
+ originSessionKey?: string;
249
+ requestId: string;
250
+ }): ExpansionConcurrencyGuardDecision {
251
+ const originSessionKey = normalizeSessionKey(params.originSessionKey) || "main";
252
+ const requestId = params.requestId.trim();
253
+ const activeRequestId = activeRequestIdByOriginSessionKey.get(originSessionKey);
254
+
255
+ if (activeRequestId && activeRequestId !== requestId) {
256
+ return {
257
+ blocked: true,
258
+ code: EXPANSION_CONCURRENCY_ERROR_CODE,
259
+ reason: "origin_session_in_flight",
260
+ message:
261
+ `${EXPANSION_CONCURRENCY_ERROR_CODE}: Another lcm_expand_query delegation is already ` +
262
+ `in flight for origin session (${originSessionKey}; activeRequestId=${activeRequestId}). ` +
263
+ buildExpansionConcurrencyRecoveryGuidance(originSessionKey),
264
+ requestId,
265
+ originSessionKey,
266
+ };
267
+ }
268
+
269
+ if (!activeRequestId) {
270
+ activeRequestIdByOriginSessionKey.set(originSessionKey, requestId);
271
+ }
272
+
273
+ return {
274
+ blocked: false,
275
+ requestId,
276
+ originSessionKey,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Release the active delegated-expansion slot for an origin session.
282
+ */
283
+ export function releaseExpansionConcurrencySlot(params: {
284
+ originSessionKey?: string;
285
+ requestId?: string;
286
+ }): void {
287
+ const originSessionKey = normalizeSessionKey(params.originSessionKey);
288
+ if (!originSessionKey) {
289
+ return;
290
+ }
291
+ const activeRequestId = activeRequestIdByOriginSessionKey.get(originSessionKey);
292
+ if (!activeRequestId) {
293
+ return;
294
+ }
295
+ const requestId = params.requestId?.trim();
296
+ if (requestId && activeRequestId !== requestId) {
297
+ return;
298
+ }
299
+ activeRequestIdByOriginSessionKey.delete(originSessionKey);
300
+ }
301
+
216
302
  /**
217
303
  * Emit structured delegated expansion telemetry with monotonic counters.
218
304
  */
@@ -279,6 +365,7 @@ export function getExpansionDelegationTelemetrySnapshotForTests(): Record<Teleme
279
365
  export function resetExpansionDelegationGuardForTests(): void {
280
366
  delegatedContextBySessionKey.clear();
281
367
  blockedRequestIdsBySessionKey.clear();
368
+ activeRequestIdByOriginSessionKey.clear();
282
369
  telemetryCounters.start = 0;
283
370
  telemetryCounters.block = 0;
284
371
  telemetryCounters.timeout = 0;