@parall/agent-core 1.43.0 → 1.44.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.
@@ -120,6 +120,31 @@ export declare class ParallAgentGateway {
120
120
  constructor(opts: ParallGatewayOptions);
121
121
  run(abortSignal: AbortSignal): Promise<void>;
122
122
  private tryClaimMessage;
123
+ /**
124
+ * Lane currently being dispatched per session — lets external activity
125
+ * signals renew exactly the caller's lane (renewing all lanes would keep
126
+ * an unrelated stalled fork's lane leased forever).
127
+ */
128
+ private sessionActiveLanes;
129
+ noteSessionLane(sessionKey: string, laneKey: string | null): void;
130
+ /**
131
+ * External runtime-activity signal for adapters whose tool activity does
132
+ * not flow through the RuntimeEvent stream (openclaw hooks call this from
133
+ * the tool-call lifecycle): renews the session's OWN active ledger lane so
134
+ * a long tool call cannot outlive the lease and get dethroned mid-turn.
135
+ * No-op without an active ledger lane for the session.
136
+ */
137
+ touchRuntimeActivity(sessionKey: string): void;
138
+ /** Sessions whose in-flight turn surfaced a runtime error event. */
139
+ private turnErrorSessions;
140
+ /**
141
+ * Consume (read-and-clear) the error marker for sessionKey's last turn.
142
+ * Feeds complete's turn_outcome so an error turn's lane members are
143
+ * released for retry instead of no_action-swept (design §3). Consuming
144
+ * (rather than peeking) keeps one-shot fork session keys from accumulating
145
+ * in the set forever.
146
+ */
147
+ consumeTurnError(sessionKey: string): boolean;
123
148
  private emitDispatchReceived;
124
149
  /** True when this event's lifecycle is owned by the dispatch lane ledger. */
125
150
  private usesLaneLedger;
@@ -1 +1 @@
1
- {"version":3,"file":"gateway-base.d.ts","sourceRoot":"","sources":["../src/gateway-base.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"gateway-base.d.ts","sourceRoot":"","sources":["../src/gateway-base.ts"],"names":[],"mappings":"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"}
@@ -410,6 +410,44 @@ export class ParallAgentGateway {
410
410
  this.dispatchedMessages.add(id);
411
411
  return true;
412
412
  }
413
+ /**
414
+ * Lane currently being dispatched per session — lets external activity
415
+ * signals renew exactly the caller's lane (renewing all lanes would keep
416
+ * an unrelated stalled fork's lane leased forever).
417
+ */
418
+ sessionActiveLanes = new Map();
419
+ noteSessionLane(sessionKey, laneKey) {
420
+ if (laneKey == null)
421
+ this.sessionActiveLanes.delete(sessionKey);
422
+ else
423
+ this.sessionActiveLanes.set(sessionKey, laneKey);
424
+ }
425
+ /**
426
+ * External runtime-activity signal for adapters whose tool activity does
427
+ * not flow through the RuntimeEvent stream (openclaw hooks call this from
428
+ * the tool-call lifecycle): renews the session's OWN active ledger lane so
429
+ * a long tool call cannot outlive the lease and get dethroned mid-turn.
430
+ * No-op without an active ledger lane for the session.
431
+ */
432
+ touchRuntimeActivity(sessionKey) {
433
+ if (this.ledgerDisabled)
434
+ return;
435
+ const laneKey = this.sessionActiveLanes.get(sessionKey);
436
+ if (laneKey)
437
+ this.laneLedger?.renewByKey(laneKey);
438
+ }
439
+ /** Sessions whose in-flight turn surfaced a runtime error event. */
440
+ turnErrorSessions = new Set();
441
+ /**
442
+ * Consume (read-and-clear) the error marker for sessionKey's last turn.
443
+ * Feeds complete's turn_outcome so an error turn's lane members are
444
+ * released for retry instead of no_action-swept (design §3). Consuming
445
+ * (rather than peeking) keeps one-shot fork session keys from accumulating
446
+ * in the set forever.
447
+ */
448
+ consumeTurnError(sessionKey) {
449
+ return this.turnErrorSessions.delete(sessionKey);
450
+ }
413
451
  async emitDispatchReceived(event) {
414
452
  const sourceType = event.ackSourceType ?? (event.type === 'task' ? 'task_activity' : 'message');
415
453
  const sourceId = event.ackSourceId ?? event.messageId;
@@ -814,6 +852,7 @@ export class ParallAgentGateway {
814
852
  this.pendingRestartNotification = null;
815
853
  }
816
854
  resetDispatchMetrics(sessionKey);
855
+ this.turnErrorSessions.delete(sessionKey);
817
856
  return runWithSessionKey(sessionKey, async () => {
818
857
  let dispatchSpan = null;
819
858
  setSessionChatId(sessionKey, event.targetId);
@@ -950,6 +989,9 @@ export class ParallAgentGateway {
950
989
  pendingSendCallIds.delete(runtimeEvent.callId)) {
951
990
  recordMessageSend(sessionKey, !runtimeEvent.error);
952
991
  }
992
+ if (runtimeEvent.type === 'error') {
993
+ this.turnErrorSessions.add(sessionKey);
994
+ }
953
995
  await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath);
954
996
  }
955
997
  if (!binding) {
@@ -1122,7 +1164,12 @@ export class ParallAgentGateway {
1122
1164
  body: buildForkScopePrefix(last) + buildEventBody(last),
1123
1165
  earlier,
1124
1166
  captureText: batchText,
1125
- hasMoreLocal: () => fork.queue.length > 0,
1167
+ // Per-LANE residue check (parity with the main-buffer path):
1168
+ // the fork queue can hold several lanes (channel + thread of
1169
+ // the same chat). A whole-queue check would defer THIS lane's
1170
+ // complete behind another lane's items and never revisit it —
1171
+ // its members would sit received until lease expiry.
1172
+ hasMoreLocal: () => fork.queue.some((it) => this.dispatchGroupKey(it.event) === this.dispatchGroupKey(last)),
1126
1173
  });
1127
1174
  if (outcome === 'foreign') {
1128
1175
  // Another pod owns the lane — the events stay pending server-side.
@@ -1130,6 +1177,15 @@ export class ParallAgentGateway {
1130
1177
  item.resolve(false);
1131
1178
  break;
1132
1179
  }
1180
+ if (outcome === 'failed') {
1181
+ // Error turn: complete(error) already released the members for
1182
+ // redelivery. Do NOT push them to processedEvents — the redrive
1183
+ // must not arrive wearing an "already handled" prefix. Keep
1184
+ // draining; the released work rejoins the next claim.
1185
+ for (const item of items)
1186
+ item.resolve(false);
1187
+ continue;
1188
+ }
1133
1189
  dispatched = outcome === 'dispatched';
1134
1190
  }
1135
1191
  else {
@@ -1168,6 +1224,10 @@ export class ParallAgentGateway {
1168
1224
  }
1169
1225
  }
1170
1226
  finally {
1227
+ // One-shot fork keys are never dispatched again — drop any error marker
1228
+ // the legacy (non-ledger) path left unconsumed, or the set grows with
1229
+ // every failed ephemeral fork.
1230
+ this.turnErrorSessions.delete(fork.fork.sessionKey);
1171
1231
  if (fork.deadlineTimer) {
1172
1232
  clearTimeout(fork.deadlineTimer);
1173
1233
  fork.deadlineTimer = null;
@@ -1295,6 +1355,13 @@ export class ParallAgentGateway {
1295
1355
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1296
1356
  continue;
1297
1357
  }
1358
+ if (outcome === 'failed') {
1359
+ // Error turn settled with complete(error) — members released for
1360
+ // redelivery. The fork results were not consumed by the failed
1361
+ // turn; keep them for the next real dispatch.
1362
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1363
+ continue;
1364
+ }
1298
1365
  // Dispatched — resolution happened server-side (reply cover or
1299
1366
  // no_action sweep); no legacy acks.
1300
1367
  continue;
@@ -1438,9 +1505,16 @@ export class ParallAgentGateway {
1438
1505
  // fold leaves the event buffered — the drain claims it as its own
1439
1506
  // turn. Injection requires an exact lane match (same chat AND same
1440
1507
  // thread) — a thread message never rides a channel turn.
1508
+ // The adapter-capability check comes BEFORE the fold: on a
1509
+ // buffer-only adapter (openclaw — no enqueueDuringDispatch) a
1510
+ // folded-but-never-injected member would be broad-covered by the
1511
+ // current turn's reply without the model ever seeing it. Leaving
1512
+ // the event un-folded keeps it buffered; the drain claims it as
1513
+ // its own turn and folds it there.
1441
1514
  if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1515
+ this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
1442
1516
  (await this.laneLedger?.steerLive(event)) &&
1443
- (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event)))) {
1517
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch(this.opts.runtimeKey, buildEventBody(event)))) {
1444
1518
  this.opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
1445
1519
  }
1446
1520
  }
@@ -1474,13 +1548,20 @@ export class ParallAgentGateway {
1474
1548
  this.dispatchState.mainBuffer.push(event);
1475
1549
  return false;
1476
1550
  }
1477
- try {
1478
- await this.emitDispatchReceived(event);
1479
- }
1480
- catch (err) {
1481
- this.opts.log?.warn?.(`mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`);
1482
- this.dispatchState.mainBuffer.push(event);
1483
- return false;
1551
+ // Ledger-managed events must never touch the legacy received surface
1552
+ // — the fork's lane claim marks them received. A mark-received here
1553
+ // outruns the claim, strands the row ownerless, and the chat goes
1554
+ // silent (the 0713 black hole; dispatch-convergence-design.md §6).
1555
+ // This was the only unguarded call site of the three.
1556
+ if (!this.usesLaneLedger(event)) {
1557
+ try {
1558
+ await this.emitDispatchReceived(event);
1559
+ }
1560
+ catch (err) {
1561
+ this.opts.log?.warn?.(`mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`);
1562
+ this.dispatchState.mainBuffer.push(event);
1563
+ return false;
1564
+ }
1484
1565
  }
1485
1566
  const fork = await this.opts.dispatchAdapter.forkSession({
1486
1567
  sessionKey: this.opts.runtimeKey,
@@ -39,6 +39,8 @@ export interface LaneFlowHost {
39
39
  disableLedger(reason: string): void;
40
40
  usesLaneLedger(event: ParallEvent): boolean;
41
41
  emitDispatchReceived(event: ParallEvent): Promise<void>;
42
+ consumeTurnError(sessionKey: string): boolean;
43
+ noteSessionLane(sessionKey: string, laneKey: string | null): void;
42
44
  runDispatch(event: ParallEvent, sessionKey: string, bodyForAgent: string, earlierEvents?: ParallEvent[], captureText?: string[]): Promise<boolean>;
43
45
  tryClaimMessage(id: string): boolean;
44
46
  buildMessageDispatchDecision(chatId: string, message: DispatchableMessage): Promise<MessageDispatchDecision>;
@@ -57,7 +59,7 @@ export declare function dispatchLaneGroup(host: LaneFlowHost, opts: {
57
59
  earlier: ParallEvent[];
58
60
  captureText?: string[];
59
61
  hasMoreLocal: () => boolean;
60
- }): Promise<'dispatched' | 'foreign' | 'shutdown'>;
62
+ }): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'>;
61
63
  /**
62
64
  * Consume one typed dispatch (task/comment/schedule/trigger/approval) under
63
65
  * its typed-lane occupancy guard: claim the dsp:<id> lane (skip when another
@@ -1 +1 @@
1
- {"version":3,"file":"gateway-lane-flow.d.ts","sourceRoot":"","sources":["../src/gateway-lane-flow.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"gateway-lane-flow.d.ts","sourceRoot":"","sources":["../src/gateway-lane-flow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAEtF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;GAKG;AAEH,0EAA0E;AAC1E,MAAM,WAAW,YAAY;IAC3B,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAChC;;;;;;;OAOG;IACH,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtE,IAAI,EAAE;QACJ,MAAM,EAAE,YAAY,CAAC;QACrB,GAAG,CAAC,EAAE,aAAa,CAAC;QACpB,MAAM,EAAE;YAAE,MAAM,EAAE,MAAM,CAAA;SAAE,CAAC;QAC3B,eAAe,EAAE,eAAe,CAAC;QACjC,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;IAC5C,oBAAoB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9C,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;IAClE,WAAW,CACT,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,aAAa,CAAC,EAAE,WAAW,EAAE,EAC7B,WAAW,CAAC,EAAE,MAAM,EAAE,GACrB,OAAO,CAAC,OAAO,CAAC,CAAC;IACpB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IACrC,4BAA4B,CAC1B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACpC,kBAAkB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1D;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE;IACJ,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,OAAO,CAAC;CAC7B,GACA,OAAO,CAAC,YAAY,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC,CAqG3D;AAOD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,YAAY,EAClB,GAAG,EAAE;IAAE,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,EACzE,GAAG,EAAE,CAAC,eAAe,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,EACnD,GAAG,EAAE,CAAC,eAAe,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,GAC1E,OAAO,CAAC,IAAI,CAAC,CAiGf;AAED;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAC1C,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACvD,OAAO,CAAC,IAAI,CAAC,CAuDf"}
@@ -40,6 +40,9 @@ export async function dispatchLaneGroup(host, opts) {
40
40
  }
41
41
  return 'foreign';
42
42
  }
43
+ // Register the session's active lane so external activity signals
44
+ // (touchRuntimeActivity from adapter hooks) renew exactly this lane.
45
+ host.noteSessionLane(opts.sessionKey, lane.laneKey);
43
46
  let dispatched = false;
44
47
  try {
45
48
  dispatched = await host.runDispatch(event, opts.sessionKey, opts.body, opts.earlier, opts.captureText);
@@ -47,13 +50,53 @@ export async function dispatchLaneGroup(host, opts) {
47
50
  catch (err) {
48
51
  // Failed turn: hand the members back so the retry (this pod or the
49
52
  // next) re-claims immediately instead of waiting out the lease.
53
+ host.noteSessionLane(opts.sessionKey, null);
50
54
  await ledger.release(lane.laneKey).catch(() => { });
51
55
  throw err;
52
56
  }
57
+ finally {
58
+ if (dispatched)
59
+ host.noteSessionLane(opts.sessionKey, null);
60
+ }
53
61
  if (!dispatched) {
54
62
  // Shutdown short-circuit — shutdown() releases all active lanes.
55
63
  return 'shutdown';
56
64
  }
65
+ if (host.consumeTurnError(opts.sessionKey)) {
66
+ // An error turn must not no_action-sweep its members — settle the lane
67
+ // NOW with an error complete so they release for retry on the redrive
68
+ // budget (dispatch-convergence-design.md §3). Settling immediately (even
69
+ // with same-lane work still buffered) is deliberate: carrying the error
70
+ // across buffered turns would let a later reply broad-cover the failed
71
+ // member, or requeue the later turn's successful work. Released members
72
+ // rejoin the next claim, merged with whatever was buffered. Dropping the
73
+ // local dedupe claims lets the server's re-drive hint retrigger the
74
+ // messages immediately instead of waiting out the renotify pacing.
75
+ ledger.markTurnError(lane.laneKey);
76
+ // Clear dedupe for EVERY lane member, not just this batch: an earlier
77
+ // successful batch may have deferred its complete via hasMoreLocal, so
78
+ // the error complete below releases those members too — their redrive
79
+ // would be permanently blocked by a stale local claim.
80
+ for (const msgId of lane.folded.keys()) {
81
+ host.dispatchedMessages.delete(msgId);
82
+ }
83
+ // Invalidate any armed steer state before settling: a pending injection
84
+ // left over from the failed turn would make the retry claim enter the
85
+ // "already injected" path, produce an empty turn, and no_action-sweep the
86
+ // released members without ever retrying them. abortDispatch is the
87
+ // adapter contract's idempotent "clear pending steer state" hook.
88
+ try {
89
+ host.opts.dispatchAdapter.abortDispatch?.(opts.sessionKey);
90
+ }
91
+ catch {
92
+ // best-effort — a throwing abort must not block the error settlement
93
+ }
94
+ await ledger.completeIfIdle(lane.laneKey, false);
95
+ // 'failed' — callers must NOT record these events as handled: the server
96
+ // just released them for redelivery, and an "already handled" fork prefix
97
+ // (or a consumed fork summary) on the redrive would be a lie.
98
+ return 'failed';
99
+ }
57
100
  const pendingInjections = host.opts.dispatchAdapter.hasPendingInjections?.(opts.sessionKey) ?? false;
58
101
  await ledger.completeIfIdle(lane.laneKey, pendingInjections || opts.hasMoreLocal());
59
102
  return 'dispatched';
@@ -14,6 +14,13 @@ export type ActiveLane = {
14
14
  /** Lease expiry (ms epoch) and TTL from the claim — renewal pacing state. */
15
15
  leaseUntilMs?: number;
16
16
  leaseTtlMs?: number;
17
+ /**
18
+ * Sticky error bit: some turn on this lane surfaced a runtime error. Read
19
+ * (and only cleared) by the lane's final complete, so an error outcome
20
+ * survives buffered same-lane turns in between — a later successful turn
21
+ * must not no_action-sweep the failed turn's members.
22
+ */
23
+ turnError?: boolean;
17
24
  };
18
25
  /**
19
26
  * Thrown when the server predates the dispatch ledger (claim endpoint 404s
@@ -71,7 +78,22 @@ export declare class LaneLedger {
71
78
  * re-drives any same-target pending work. A STALE_LANE answer means a
72
79
  * takeover already owns the resource — local state is dropped either way.
73
80
  */
81
+ /**
82
+ * Record that the turn on this lane surfaced a runtime error. The flow
83
+ * settles an errored lane immediately (dispatchLaneGroup returns 'failed'
84
+ * after a forced complete), so the bit normally lives for one turn only —
85
+ * it is the transport between the gateway's per-session error signal and
86
+ * this lane's complete request.
87
+ */
88
+ markTurnError(laneKey: string): void;
74
89
  completeIfIdle(laneKey: string, hasMoreLocal: boolean): Promise<void>;
90
+ /**
91
+ * Renew one lane by its key — the external runtime-activity hook for
92
+ * adapters whose tool traffic bypasses the RuntimeEvent stream (openclaw
93
+ * hooks). Scoped to the session's own lane: renewing every lane would let
94
+ * one busy fork keep an unrelated stalled fork's lane leased forever.
95
+ */
96
+ renewByKey(laneKey: string): void;
75
97
  /**
76
98
  * Long-turn keepalive: renew the lane's lease on runtime activity, throttled
77
99
  * so a chatty turn doesn't spam the server. Without this, a legitimately
@@ -1 +1 @@
1
- {"version":3,"file":"lane-ledger.d.ts","sourceRoot":"","sources":["../src/lane-ledger.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,sDAAsD;AACtD,MAAM,MAAM,UAAU,GAAG;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,6EAA6E;IAC7E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;CAAG;AAEpD,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,CAAC;AAqBrD;;;;;;;;;;;GAWG;AACH,qBAAa,UAAU;IAInB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;gBAGpC,IAAI,EAAE;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,aAAa,CAAC;KACrB;IAGH,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,+FAA+F;IAC/F,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO;IAIpC,UAAU,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM;IAOtC,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,GAAG,SAAS;IAIvD,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM;IAIzC;;;;;;OAMG;IACG,UAAU,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA4EnE;;;;;OAKG;IACG,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IA0BrD;;;;;OAKG;IACG,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA0B3E;;;;;;;OAOG;IACH,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI;IA6BlC;;;;OAIG;IACG,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYvC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAOjC,yEAAyE;IACzE,IAAI,WAAW,IAAI,MAAM,CAExB;IAED;;;;;OAKG;IACG,UAAU,CAAC,GAAG,EAAE;QACpB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA8B9B;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;CAW1B"}
1
+ {"version":3,"file":"lane-ledger.d.ts","sourceRoot":"","sources":["../src/lane-ledger.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,sDAAsD;AACtD,MAAM,MAAM,UAAU,GAAG;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,6EAA6E;IAC7E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;CAAG;AAEpD,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,CAAC;AAqBrD;;;;;;;;;;;GAWG;AACH,qBAAa,UAAU;IAInB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;gBAGpC,IAAI,EAAE;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,aAAa,CAAC;KACrB;IAGH,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,+FAA+F;IAC/F,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO;IAIpC,UAAU,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM;IAOtC,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,GAAG,SAAS;IAIvD,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM;IAIzC;;;;;;OAMG;IACG,UAAU,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAsFnE;;;;;OAKG;IACG,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC;IA0BrD;;;;;OAKG;IACH;;;;;;OAMG;IACH,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK9B,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA6B3E;;;;;OAKG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAKjC;;;;;;;OAOG;IACH,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI;IA6BlC;;;;OAIG;IACG,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYvC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAOjC,yEAAyE;IACzE,IAAI,WAAW,IAAI,MAAM,CAExB;IAED;;;;;OAKG;IACG,UAAU,CAAC,GAAG,EAAE;QACpB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA8B9B;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;CAW1B"}
@@ -88,7 +88,16 @@ export class LaneLedger {
88
88
  throw err;
89
89
  }
90
90
  if (!res.claimed || !res.lane) {
91
- this.opts.log?.info(`lane for ${targetUri} held by a healthy incumbent — leaving events pending for re-drive`);
91
+ if (res.reason === 'empty') {
92
+ // Nothing foldable: resolved elsewhere, or stranded outside the
93
+ // ledger (the reconciler reclaims strays past the received TTL).
94
+ // Post-convergence this should not happen on a message lane — warn
95
+ // so a regression surfaces (design §3).
96
+ this.opts.log?.warn(`claim for ${targetUri} came back empty — nothing foldable; leaving to the reconciler`);
97
+ }
98
+ else {
99
+ this.opts.log?.info(`lane for ${targetUri} held by a healthy incumbent — leaving events pending for re-drive`);
100
+ }
92
101
  return null;
93
102
  }
94
103
  const leaseUntilMs = Date.parse(res.lease_until ?? '');
@@ -183,6 +192,18 @@ export class LaneLedger {
183
192
  * re-drives any same-target pending work. A STALE_LANE answer means a
184
193
  * takeover already owns the resource — local state is dropped either way.
185
194
  */
195
+ /**
196
+ * Record that the turn on this lane surfaced a runtime error. The flow
197
+ * settles an errored lane immediately (dispatchLaneGroup returns 'failed'
198
+ * after a forced complete), so the bit normally lives for one turn only —
199
+ * it is the transport between the gateway's per-session error signal and
200
+ * this lane's complete request.
201
+ */
202
+ markTurnError(laneKey) {
203
+ const lane = this.lanes.get(laneKey);
204
+ if (lane)
205
+ lane.turnError = true;
206
+ }
186
207
  async completeIfIdle(laneKey, hasMoreLocal) {
187
208
  const lane = this.lanes.get(laneKey);
188
209
  if (!lane || hasMoreLocal)
@@ -194,6 +215,9 @@ export class LaneLedger {
194
215
  lane: lane.lane,
195
216
  target_uri: lane.targetUri,
196
217
  thread_root_id: lane.threadRootId,
218
+ // An error turn releases its members for retry instead of sweeping
219
+ // them as handled (ignored by older servers).
220
+ turn_outcome: lane.turnError ? 'error' : 'ok',
197
221
  });
198
222
  if (res.swept_no_action > 0 || res.redriven) {
199
223
  this.opts.log?.info(`lane complete for ${lane.targetUri}: swept ${res.swept_no_action} no_action, redriven=${res.redriven}`);
@@ -208,6 +232,17 @@ export class LaneLedger {
208
232
  this.opts.log?.warn(`lane complete failed for ${lane.targetUri}: ${String(err)}`);
209
233
  }
210
234
  }
235
+ /**
236
+ * Renew one lane by its key — the external runtime-activity hook for
237
+ * adapters whose tool traffic bypasses the RuntimeEvent stream (openclaw
238
+ * hooks). Scoped to the session's own lane: renewing every lane would let
239
+ * one busy fork keep an unrelated stalled fork's lane leased forever.
240
+ */
241
+ renewByKey(laneKey) {
242
+ const lane = this.lanes.get(laneKey);
243
+ if (lane)
244
+ this.maybeRenew(lane);
245
+ }
211
246
  /**
212
247
  * Long-turn keepalive: renew the lane's lease on runtime activity, throttled
213
248
  * so a chatty turn doesn't spam the server. Without this, a legitimately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/agent-core",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "description": "Shared agent runtime orchestration helpers for Parall",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "@opentelemetry/sdk-logs": "^0.57.0",
36
36
  "@opentelemetry/sdk-metrics": "^1.30.0",
37
37
  "@opentelemetry/sdk-trace-node": "^1.30.0",
38
- "@parall/sdk": "1.43.0"
38
+ "@parall/sdk": "1.44.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^22.0.0",
@@ -641,6 +641,45 @@ export class ParallAgentGateway {
641
641
  return true;
642
642
  }
643
643
 
644
+ /**
645
+ * Lane currently being dispatched per session — lets external activity
646
+ * signals renew exactly the caller's lane (renewing all lanes would keep
647
+ * an unrelated stalled fork's lane leased forever).
648
+ */
649
+ private sessionActiveLanes = new Map<string, string>();
650
+
651
+ noteSessionLane(sessionKey: string, laneKey: string | null): void {
652
+ if (laneKey == null) this.sessionActiveLanes.delete(sessionKey);
653
+ else this.sessionActiveLanes.set(sessionKey, laneKey);
654
+ }
655
+
656
+ /**
657
+ * External runtime-activity signal for adapters whose tool activity does
658
+ * not flow through the RuntimeEvent stream (openclaw hooks call this from
659
+ * the tool-call lifecycle): renews the session's OWN active ledger lane so
660
+ * a long tool call cannot outlive the lease and get dethroned mid-turn.
661
+ * No-op without an active ledger lane for the session.
662
+ */
663
+ touchRuntimeActivity(sessionKey: string): void {
664
+ if (this.ledgerDisabled) return;
665
+ const laneKey = this.sessionActiveLanes.get(sessionKey);
666
+ if (laneKey) this.laneLedger?.renewByKey(laneKey);
667
+ }
668
+
669
+ /** Sessions whose in-flight turn surfaced a runtime error event. */
670
+ private turnErrorSessions = new Set<string>();
671
+
672
+ /**
673
+ * Consume (read-and-clear) the error marker for sessionKey's last turn.
674
+ * Feeds complete's turn_outcome so an error turn's lane members are
675
+ * released for retry instead of no_action-swept (design §3). Consuming
676
+ * (rather than peeking) keeps one-shot fork session keys from accumulating
677
+ * in the set forever.
678
+ */
679
+ consumeTurnError(sessionKey: string): boolean {
680
+ return this.turnErrorSessions.delete(sessionKey);
681
+ }
682
+
644
683
  private async emitDispatchReceived(event: ParallEvent): Promise<void> {
645
684
  const sourceType = event.ackSourceType ?? (event.type === 'task' ? 'task_activity' : 'message');
646
685
  const sourceId = event.ackSourceId ?? event.messageId;
@@ -693,7 +732,7 @@ export class ParallAgentGateway {
693
732
  earlier: ParallEvent[];
694
733
  captureText?: string[];
695
734
  hasMoreLocal: () => boolean;
696
- }): Promise<'dispatched' | 'foreign' | 'shutdown'> {
735
+ }): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
697
736
  return dispatchLaneGroup(this.laneFlowHost(), opts);
698
737
  }
699
738
 
@@ -1137,6 +1176,7 @@ export class ParallAgentGateway {
1137
1176
  }
1138
1177
 
1139
1178
  resetDispatchMetrics(sessionKey);
1179
+ this.turnErrorSessions.delete(sessionKey);
1140
1180
  return runWithSessionKey(sessionKey, async () => {
1141
1181
  let dispatchSpan: ReturnType<typeof startDispatchSpan> = null;
1142
1182
 
@@ -1299,6 +1339,9 @@ export class ParallAgentGateway {
1299
1339
  ) {
1300
1340
  recordMessageSend(sessionKey, !runtimeEvent.error);
1301
1341
  }
1342
+ if (runtimeEvent.type === 'error') {
1343
+ this.turnErrorSessions.add(sessionKey);
1344
+ }
1302
1345
  await this.createRuntimeStep(
1303
1346
  binding.agentSessionId,
1304
1347
  event,
@@ -1495,13 +1538,29 @@ export class ParallAgentGateway {
1495
1538
  body: buildForkScopePrefix(last) + buildEventBody(last),
1496
1539
  earlier,
1497
1540
  captureText: batchText,
1498
- hasMoreLocal: () => fork.queue.length > 0,
1541
+ // Per-LANE residue check (parity with the main-buffer path):
1542
+ // the fork queue can hold several lanes (channel + thread of
1543
+ // the same chat). A whole-queue check would defer THIS lane's
1544
+ // complete behind another lane's items and never revisit it —
1545
+ // its members would sit received until lease expiry.
1546
+ hasMoreLocal: () =>
1547
+ fork.queue.some(
1548
+ (it) => this.dispatchGroupKey(it.event) === this.dispatchGroupKey(last),
1549
+ ),
1499
1550
  });
1500
1551
  if (outcome === 'foreign') {
1501
1552
  // Another pod owns the lane — the events stay pending server-side.
1502
1553
  for (const item of items) item.resolve(false);
1503
1554
  break;
1504
1555
  }
1556
+ if (outcome === 'failed') {
1557
+ // Error turn: complete(error) already released the members for
1558
+ // redelivery. Do NOT push them to processedEvents — the redrive
1559
+ // must not arrive wearing an "already handled" prefix. Keep
1560
+ // draining; the released work rejoins the next claim.
1561
+ for (const item of items) item.resolve(false);
1562
+ continue;
1563
+ }
1505
1564
  dispatched = outcome === 'dispatched';
1506
1565
  } else {
1507
1566
  dispatched = await this.runDispatch(
@@ -1542,6 +1601,10 @@ export class ParallAgentGateway {
1542
1601
  remaining.resolve(false);
1543
1602
  }
1544
1603
  } finally {
1604
+ // One-shot fork keys are never dispatched again — drop any error marker
1605
+ // the legacy (non-ledger) path left unconsumed, or the set grows with
1606
+ // every failed ephemeral fork.
1607
+ this.turnErrorSessions.delete(fork.fork.sessionKey);
1545
1608
  if (fork.deadlineTimer) {
1546
1609
  clearTimeout(fork.deadlineTimer);
1547
1610
  fork.deadlineTimer = null;
@@ -1654,7 +1717,7 @@ export class ParallAgentGateway {
1654
1717
  this.opts.runtimeKey,
1655
1718
  );
1656
1719
  if (this.usesLaneLedger(event)) {
1657
- let outcome: 'dispatched' | 'foreign' | 'shutdown';
1720
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed';
1658
1721
  try {
1659
1722
  outcome = await this.dispatchLaneGroup({
1660
1723
  events,
@@ -1681,6 +1744,13 @@ export class ParallAgentGateway {
1681
1744
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1682
1745
  continue;
1683
1746
  }
1747
+ if (outcome === 'failed') {
1748
+ // Error turn settled with complete(error) — members released for
1749
+ // redelivery. The fork results were not consumed by the failed
1750
+ // turn; keep them for the next real dispatch.
1751
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
1752
+ continue;
1753
+ }
1684
1754
  // Dispatched — resolution happened server-side (reply cover or
1685
1755
  // no_action sweep); no legacy acks.
1686
1756
  continue;
@@ -1769,7 +1839,7 @@ export class ParallAgentGateway {
1769
1839
  if (this.usesLaneLedger(event)) {
1770
1840
  // Ledger flow: claim replaces mark-received; complete/reply replace
1771
1841
  // acks. A foreign incumbent leaves the event pending for re-drive.
1772
- let outcome: 'dispatched' | 'foreign' | 'shutdown' = 'shutdown';
1842
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed' = 'shutdown';
1773
1843
  try {
1774
1844
  try {
1775
1845
  outcome = await this.dispatchLaneGroup({
@@ -1840,10 +1910,17 @@ export class ParallAgentGateway {
1840
1910
  // fold leaves the event buffered — the drain claims it as its own
1841
1911
  // turn. Injection requires an exact lane match (same chat AND same
1842
1912
  // thread) — a thread message never rides a channel turn.
1913
+ // The adapter-capability check comes BEFORE the fold: on a
1914
+ // buffer-only adapter (openclaw — no enqueueDuringDispatch) a
1915
+ // folded-but-never-injected member would be broad-covered by the
1916
+ // current turn's reply without the model ever seeing it. Leaving
1917
+ // the event un-folded keeps it buffered; the drain claims it as
1918
+ // its own turn and folds it there.
1843
1919
  if (
1844
1920
  this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
1921
+ this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
1845
1922
  (await this.laneLedger?.steerLive(event)) &&
1846
- (await this.opts.dispatchAdapter.enqueueDuringDispatch?.(
1923
+ (await this.opts.dispatchAdapter.enqueueDuringDispatch(
1847
1924
  this.opts.runtimeKey,
1848
1925
  buildEventBody(event),
1849
1926
  ))
@@ -1892,14 +1969,21 @@ export class ParallAgentGateway {
1892
1969
  return false;
1893
1970
  }
1894
1971
 
1895
- try {
1896
- await this.emitDispatchReceived(event);
1897
- } catch (err) {
1898
- this.opts.log?.warn?.(
1899
- `mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`,
1900
- );
1901
- this.dispatchState.mainBuffer.push(event);
1902
- return false;
1972
+ // Ledger-managed events must never touch the legacy received surface
1973
+ // — the fork's lane claim marks them received. A mark-received here
1974
+ // outruns the claim, strands the row ownerless, and the chat goes
1975
+ // silent (the 0713 black hole; dispatch-convergence-design.md §6).
1976
+ // This was the only unguarded call site of the three.
1977
+ if (!this.usesLaneLedger(event)) {
1978
+ try {
1979
+ await this.emitDispatchReceived(event);
1980
+ } catch (err) {
1981
+ this.opts.log?.warn?.(
1982
+ `mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`,
1983
+ );
1984
+ this.dispatchState.mainBuffer.push(event);
1985
+ return false;
1986
+ }
1903
1987
  }
1904
1988
  const fork = await this.opts.dispatchAdapter.forkSession({
1905
1989
  sessionKey: this.opts.runtimeKey,
@@ -37,6 +37,8 @@ export interface LaneFlowHost {
37
37
  disableLedger(reason: string): void;
38
38
  usesLaneLedger(event: ParallEvent): boolean;
39
39
  emitDispatchReceived(event: ParallEvent): Promise<void>;
40
+ consumeTurnError(sessionKey: string): boolean;
41
+ noteSessionLane(sessionKey: string, laneKey: string | null): void;
40
42
  runDispatch(
41
43
  event: ParallEvent,
42
44
  sessionKey: string,
@@ -68,7 +70,7 @@ export async function dispatchLaneGroup(
68
70
  captureText?: string[];
69
71
  hasMoreLocal: () => boolean;
70
72
  },
71
- ): Promise<'dispatched' | 'foreign' | 'shutdown'> {
73
+ ): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
72
74
  const ledger = host.laneLedger!;
73
75
  const event = opts.events[opts.events.length - 1];
74
76
  let lane: Awaited<ReturnType<LaneLedger['ensureLane']>>;
@@ -106,6 +108,9 @@ export async function dispatchLaneGroup(
106
108
  }
107
109
  return 'foreign';
108
110
  }
111
+ // Register the session's active lane so external activity signals
112
+ // (touchRuntimeActivity from adapter hooks) renew exactly this lane.
113
+ host.noteSessionLane(opts.sessionKey, lane.laneKey);
109
114
  let dispatched = false;
110
115
  try {
111
116
  dispatched = await host.runDispatch(
@@ -118,13 +123,50 @@ export async function dispatchLaneGroup(
118
123
  } catch (err) {
119
124
  // Failed turn: hand the members back so the retry (this pod or the
120
125
  // next) re-claims immediately instead of waiting out the lease.
126
+ host.noteSessionLane(opts.sessionKey, null);
121
127
  await ledger.release(lane.laneKey).catch(() => {});
122
128
  throw err;
129
+ } finally {
130
+ if (dispatched) host.noteSessionLane(opts.sessionKey, null);
123
131
  }
124
132
  if (!dispatched) {
125
133
  // Shutdown short-circuit — shutdown() releases all active lanes.
126
134
  return 'shutdown';
127
135
  }
136
+ if (host.consumeTurnError(opts.sessionKey)) {
137
+ // An error turn must not no_action-sweep its members — settle the lane
138
+ // NOW with an error complete so they release for retry on the redrive
139
+ // budget (dispatch-convergence-design.md §3). Settling immediately (even
140
+ // with same-lane work still buffered) is deliberate: carrying the error
141
+ // across buffered turns would let a later reply broad-cover the failed
142
+ // member, or requeue the later turn's successful work. Released members
143
+ // rejoin the next claim, merged with whatever was buffered. Dropping the
144
+ // local dedupe claims lets the server's re-drive hint retrigger the
145
+ // messages immediately instead of waiting out the renotify pacing.
146
+ ledger.markTurnError(lane.laneKey);
147
+ // Clear dedupe for EVERY lane member, not just this batch: an earlier
148
+ // successful batch may have deferred its complete via hasMoreLocal, so
149
+ // the error complete below releases those members too — their redrive
150
+ // would be permanently blocked by a stale local claim.
151
+ for (const msgId of lane.folded.keys()) {
152
+ host.dispatchedMessages.delete(msgId);
153
+ }
154
+ // Invalidate any armed steer state before settling: a pending injection
155
+ // left over from the failed turn would make the retry claim enter the
156
+ // "already injected" path, produce an empty turn, and no_action-sweep the
157
+ // released members without ever retrying them. abortDispatch is the
158
+ // adapter contract's idempotent "clear pending steer state" hook.
159
+ try {
160
+ host.opts.dispatchAdapter.abortDispatch?.(opts.sessionKey);
161
+ } catch {
162
+ // best-effort — a throwing abort must not block the error settlement
163
+ }
164
+ await ledger.completeIfIdle(lane.laneKey, false);
165
+ // 'failed' — callers must NOT record these events as handled: the server
166
+ // just released them for redelivery, and an "already handled" fork prefix
167
+ // (or a consumed fork summary) on the redrive would be a lie.
168
+ return 'failed';
169
+ }
128
170
  const pendingInjections =
129
171
  host.opts.dispatchAdapter.hasPendingInjections?.(opts.sessionKey) ?? false;
130
172
  await ledger.completeIfIdle(lane.laneKey, pendingInjections || opts.hasMoreLocal());
@@ -19,6 +19,13 @@ export type ActiveLane = {
19
19
  /** Lease expiry (ms epoch) and TTL from the claim — renewal pacing state. */
20
20
  leaseUntilMs?: number;
21
21
  leaseTtlMs?: number;
22
+ /**
23
+ * Sticky error bit: some turn on this lane surfaced a runtime error. Read
24
+ * (and only cleared) by the lane's final complete, so an error outcome
25
+ * survives buffered same-lane turns in between — a later successful turn
26
+ * must not no_action-sweep the failed turn's members.
27
+ */
28
+ turnError?: boolean;
22
29
  };
23
30
 
24
31
  /**
@@ -122,9 +129,19 @@ export class LaneLedger {
122
129
  throw err;
123
130
  }
124
131
  if (!res.claimed || !res.lane) {
125
- this.opts.log?.info(
126
- `lane for ${targetUri} held by a healthy incumbent — leaving events pending for re-drive`,
127
- );
132
+ if (res.reason === 'empty') {
133
+ // Nothing foldable: resolved elsewhere, or stranded outside the
134
+ // ledger (the reconciler reclaims strays past the received TTL).
135
+ // Post-convergence this should not happen on a message lane — warn
136
+ // so a regression surfaces (design §3).
137
+ this.opts.log?.warn(
138
+ `claim for ${targetUri} came back empty — nothing foldable; leaving to the reconciler`,
139
+ );
140
+ } else {
141
+ this.opts.log?.info(
142
+ `lane for ${targetUri} held by a healthy incumbent — leaving events pending for re-drive`,
143
+ );
144
+ }
128
145
  return null;
129
146
  }
130
147
  const leaseUntilMs = Date.parse(res.lease_until ?? '');
@@ -218,6 +235,18 @@ export class LaneLedger {
218
235
  * re-drives any same-target pending work. A STALE_LANE answer means a
219
236
  * takeover already owns the resource — local state is dropped either way.
220
237
  */
238
+ /**
239
+ * Record that the turn on this lane surfaced a runtime error. The flow
240
+ * settles an errored lane immediately (dispatchLaneGroup returns 'failed'
241
+ * after a forced complete), so the bit normally lives for one turn only —
242
+ * it is the transport between the gateway's per-session error signal and
243
+ * this lane's complete request.
244
+ */
245
+ markTurnError(laneKey: string): void {
246
+ const lane = this.lanes.get(laneKey);
247
+ if (lane) lane.turnError = true;
248
+ }
249
+
221
250
  async completeIfIdle(laneKey: string, hasMoreLocal: boolean): Promise<void> {
222
251
  const lane = this.lanes.get(laneKey);
223
252
  if (!lane || hasMoreLocal) return;
@@ -228,6 +257,9 @@ export class LaneLedger {
228
257
  lane: lane.lane,
229
258
  target_uri: lane.targetUri,
230
259
  thread_root_id: lane.threadRootId,
260
+ // An error turn releases its members for retry instead of sweeping
261
+ // them as handled (ignored by older servers).
262
+ turn_outcome: lane.turnError ? 'error' : 'ok',
231
263
  });
232
264
  if (res.swept_no_action > 0 || res.redriven) {
233
265
  this.opts.log?.info(
@@ -244,6 +276,17 @@ export class LaneLedger {
244
276
  }
245
277
  }
246
278
 
279
+ /**
280
+ * Renew one lane by its key — the external runtime-activity hook for
281
+ * adapters whose tool traffic bypasses the RuntimeEvent stream (openclaw
282
+ * hooks). Scoped to the session's own lane: renewing every lane would let
283
+ * one busy fork keep an unrelated stalled fork's lane leased forever.
284
+ */
285
+ renewByKey(laneKey: string): void {
286
+ const lane = this.lanes.get(laneKey);
287
+ if (lane) this.maybeRenew(lane);
288
+ }
289
+
247
290
  /**
248
291
  * Long-turn keepalive: renew the lane's lease on runtime activity, throttled
249
292
  * so a chatty turn doesn't spam the server. Without this, a legitimately