@parall/parall 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.
@@ -1 +1 @@
1
- {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE9D,uGAAuG;AACvG,KAAK,qBAAqB,CAAC,CAAC,GAAG,OAAO,IAAI,WAAW,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;AACnF,OAAO,EAQL,KAAK,eAAe,EAGrB,MAAM,oBAAoB,CAAC;AAqB5B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAiKxD,wBAAgB,6BAA6B,CAAC,IAAI,EAAE;IAClD,IAAI,EAAE,aAAa,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;CACtB,GAAG,eAAe,CA0KlB;AAED,eAAO,MAAM,aAAa,EAAE,qBAAqB,CAAC,qBAAqB,CAiKtE,CAAC"}
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE9D,uGAAuG;AACvG,KAAK,qBAAqB,CAAC,CAAC,GAAG,OAAO,IAAI,WAAW,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;AACnF,OAAO,EASL,KAAK,eAAe,EAGrB,MAAM,oBAAoB,CAAC;AAqB5B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AA2KxD,wBAAgB,6BAA6B,CAAC,IAAI,EAAE;IAClD,IAAI,EAAE,aAAa,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;CACtB,GAAG,eAAe,CA0KlB;AAED,eAAO,MAAM,aAAa,EAAE,qBAAqB,CAAC,qBAAqB,CAqLtE,CAAC"}
package/dist/gateway.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ParallAgentGateway, capabilityBinDir, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, initAgentTelemetry, createOtelLogger, } from '@parall/agent-core';
1
+ import { ParallAgentGateway, capabilityBinDir, dispatchLaneContextDir, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, initAgentTelemetry, createOtelLogger, } from '@parall/agent-core';
2
2
  import { appendPreparedLocalAttachmentRefs, ensureLocalAttachmentGitExclude, pinLocalAttachmentPaths, } from '@parall/agent-core/internal/attachment-input';
3
3
  import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
4
4
  import * as crypto from 'node:crypto';
@@ -11,6 +11,15 @@ import { fetchAndApplyPlatformConfig, getChannelCapabilityKeys } from './config-
11
11
  import { startWikiHelper } from './wiki-helper.js';
12
12
  import { SessionManager } from './oc-session.js';
13
13
  import { cleanupForkSession, clearSessionState, forkOrchestratorSession, resolveSessionId, resolveTranscriptFile, } from './fork.js';
14
+ /**
15
+ * Per-session dispatch context file (PRLL_CONTEXT_FILE contract) — same
16
+ * encoding as the claude/codex bridges: base64url(sessionKey) under the
17
+ * state dir's dispatch-context/.
18
+ */
19
+ function sessionContextFilePath(stateDir, sessionKey) {
20
+ const fileName = Buffer.from(sessionKey).toString('base64url');
21
+ return path.join(stateDir, 'dispatch-context', `${fileName}.json`);
22
+ }
14
23
  function resolveWsUrl(account) {
15
24
  if (account.config.ws_url)
16
25
  return account.config.ws_url;
@@ -404,6 +413,18 @@ export const parallGateway = {
404
413
  runtimeKey: orchestratorKey,
405
414
  runtimeRef: { hostname: os.hostname(), pid: process.pid },
406
415
  dispatchAdapter,
416
+ // Opts openclaw into the dispatch ledger (claim/fold/complete +
417
+ // idempotent reply effects) — the same shared-gateway machinery
418
+ // claude/codex ride; openclaw stays buffer-only (no mid-turn steer),
419
+ // which the ledger does not require. Replies already flow through
420
+ // @parall/cli, which reads PRLL_CONTEXT_DIR (injected in hooks.ts)
421
+ // to bind dispatch_lane + reply effect keys.
422
+ // Design: docs/engineering-design/dispatch-convergence-design.md §7 (S2).
423
+ dispatchContextDir: dispatchLaneContextDir(stateDir),
424
+ // Per-session context file (PRLL_CONTEXT_FILE contract) — the CLI's
425
+ // TYPED dispatch binding (parall tasks update → task_update effect)
426
+ // reads lane/event/task fields from this file, not the lane dir.
427
+ contextFilePathForSession: (sessionKey) => sessionContextFilePath(stateDir, sessionKey),
407
428
  log: otelLog,
408
429
  // Live capability view for hint routing: the channel reply hint
409
430
  // points at the vendor CLI only while the grant is active.
@@ -428,6 +449,12 @@ export const parallGateway = {
428
449
  wikiMountRoot,
429
450
  ws,
430
451
  orchestratorSessionKey: orchestratorKey,
452
+ dispatchContextDir: dispatchLaneContextDir(stateDir),
453
+ contextFilePathForSession: (sessionKey) => sessionContextFilePath(stateDir, sessionKey),
454
+ // Lease-renewal bridge: openclaw tool calls flow through hooks,
455
+ // not the RuntimeEvent stream, so hook activity must renew the
456
+ // ledger lanes or a long exec outlives the lease (STALE_LANE).
457
+ touchRuntimeActivity: (sessionKey) => gateway.touchRuntimeActivity(sessionKey),
431
458
  });
432
459
  },
433
460
  onSessionBinding: async ({ sessionKey, agentSessionId }) => {
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAgH7D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,iBAAiB,QAmLzD"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAgH7D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,iBAAiB,QA+OzD"}
package/dist/hooks.js CHANGED
@@ -118,6 +118,45 @@ export function registerParallHooks(api) {
118
118
  };
119
119
  });
120
120
  const pendingSendCalls = new Map();
121
+ // Lane lease keepalive across the tool-call lifecycle. OpenClaw tool calls
122
+ // bypass the RuntimeEvent stream, so lane renewal must ride these hooks —
123
+ // and a ONE-SHOT touch at tool start is not enough: a single exec running
124
+ // past the lane TTL (10min server-side) would still be dethroned mid-call
125
+ // and the eventual CLI reply would die with STALE_LANE. While any tool call
126
+ // is in flight for a session, an unref'd interval keeps touching; the
127
+ // half-TTL throttle in the ledger decides when a real heartbeat is sent.
128
+ const laneRenewals = new Map();
129
+ const LANE_RENEW_INTERVAL_MS = 180_000;
130
+ const beginToolRenewal = (sessionKey) => {
131
+ const accountId = extractAccountIdFromSessionKey(sessionKey);
132
+ const state = accountId ? getParallAccountState(accountId) : undefined;
133
+ if (!state?.touchRuntimeActivity)
134
+ return;
135
+ state.touchRuntimeActivity(sessionKey);
136
+ const entry = laneRenewals.get(sessionKey) ?? { count: 0 };
137
+ entry.count++;
138
+ if (!entry.timer) {
139
+ entry.timer = setInterval(() => state.touchRuntimeActivity?.(sessionKey), LANE_RENEW_INTERVAL_MS);
140
+ entry.timer.unref?.();
141
+ }
142
+ laneRenewals.set(sessionKey, entry);
143
+ };
144
+ const endToolRenewal = (sessionKey) => {
145
+ const entry = laneRenewals.get(sessionKey);
146
+ if (entry) {
147
+ entry.count--;
148
+ if (entry.count <= 0) {
149
+ if (entry.timer)
150
+ clearInterval(entry.timer);
151
+ laneRenewals.delete(sessionKey);
152
+ }
153
+ }
154
+ const accountId = extractAccountIdFromSessionKey(sessionKey);
155
+ const state = accountId ? getParallAccountState(accountId) : undefined;
156
+ // Final touch on completion — covers the reply window right after a
157
+ // long tool call ends.
158
+ state?.touchRuntimeActivity?.(sessionKey);
159
+ };
121
160
  // before_tool_call -> (1) update dispatch metrics, (2) await step creation, (3) ENV injection
122
161
  // OpenClaw tool calls bypass the RuntimeEvent stream (they go through hooks, not the
123
162
  // gateway-base for-await loop), so dispatch metrics must be updated here as well.
@@ -125,6 +164,9 @@ export function registerParallHooks(api) {
125
164
  const sessionKey = ctx.sessionKey;
126
165
  if (sessionKey) {
127
166
  recordToolCall(sessionKey);
167
+ // Every tool (not just exec) keeps the ledger lane alive for its
168
+ // whole lifetime — see laneRenewals above.
169
+ beginToolRenewal(sessionKey);
128
170
  if (event.toolName === 'exec' && event.toolCallId) {
129
171
  const command = event.params?.command;
130
172
  if (isParallSendCommand(command)) {
@@ -209,6 +251,20 @@ export function registerParallHooks(api) {
209
251
  injectedEnv.PRLL_TRIGGER_MESSAGE_ID = triggerMsgId;
210
252
  if (state.wikiMountRoot)
211
253
  injectedEnv.PRLL_WIKI_MOUNT_ROOT = state.wikiMountRoot;
254
+ // Dispatch-ledger contract: the CLI keys into this directory by send
255
+ // target to bind dispatch_lane + the reply:<dsp> effect key (idempotent
256
+ // replies). Set only when the gateway runs the ledger.
257
+ if (state.dispatchContextDir)
258
+ injectedEnv.PRLL_CONTEXT_DIR = state.dispatchContextDir;
259
+ // Per-session context file — the CLI's TYPED binding (parall tasks
260
+ // update → task_update:<dsp> effect) reads lane/event/task from here.
261
+ if (state.contextFilePathForSession) {
262
+ injectedEnv.PRLL_CONTEXT_FILE = state.contextFilePathForSession(sessionKey);
263
+ }
264
+ // Long tool calls bypass the RuntimeEvent stream — forward activity so
265
+ // the ledger lane lease renews (a >TTL exec would otherwise be dethroned
266
+ // mid-turn and the eventual CLI reply would die with STALE_LANE).
267
+ state.touchRuntimeActivity?.(sessionKey);
212
268
  // OpenClaw context (upstream doesn't inject these into exec env yet)
213
269
  injectedEnv.OPENCLAW_SESSION_KEY = sessionKey;
214
270
  if (event.toolCallId)
@@ -223,6 +279,8 @@ export function registerParallHooks(api) {
223
279
  });
224
280
  // after_tool_call -> track CLI outcomes + send tool_result step to AgentSession
225
281
  api.on('after_tool_call', async (event, ctx) => {
282
+ if (ctx.sessionKey)
283
+ endToolRenewal(ctx.sessionKey);
226
284
  if (ctx.sessionKey && event.toolCallId && pendingSendCalls.delete(event.toolCallId)) {
227
285
  recordMessageSend(ctx.sessionKey, !event.error);
228
286
  }
@@ -26479,6 +26479,9 @@ function laneKeyForTarget(targetUri, threadRootId) {
26479
26479
  return Buffer.from(`${targetUri}
26480
26480
  ${threadRootId ?? ""}`, "utf8").toString("base64url");
26481
26481
  }
26482
+ function dispatchLaneContextDir(stateDir) {
26483
+ return path.join(stateDir, "dispatch-lane-context");
26484
+ }
26482
26485
  function laneContextFilePath(contextDir, targetUri, threadRootId) {
26483
26486
  return path.join(contextDir, `${laneKeyForTarget(targetUri, threadRootId)}.json`);
26484
26487
  }
@@ -27940,7 +27943,6 @@ var ParallClient = class _ParallClient {
27940
27943
  async steerDispatch(orgId, req) {
27941
27944
  return this.request("POST", ENDPOINTS.DISPATCH_STEER(orgId), req);
27942
27945
  }
27943
- /** End a turn: no_action sweep of the lane's members + lane release + re-drive check. */
27944
27946
  async completeDispatch(orgId, req) {
27945
27947
  return this.request("POST", ENDPOINTS.DISPATCH_COMPLETE(orgId), req);
27946
27948
  }
@@ -27948,6 +27950,10 @@ var ParallClient = class _ParallClient {
27948
27950
  * End a turn for a lane-less runtime: resolve the turn's folded WorkItems
27949
27951
  * by source — broad-cover to the turn's reply Effect when one exists,
27950
27952
  * no_action sweep otherwise. Idempotent.
27953
+ *
27954
+ * @deprecated Legacy ok-only alias — use {@link completeDispatch} with the
27955
+ * `sources` form, which also carries `turn_outcome`. The endpoint retires
27956
+ * at S3b (dispatch-convergence-design.md §3).
27951
27957
  */
27952
27958
  async completeDispatchSources(orgId, req) {
27953
27959
  return this.request("POST", ENDPOINTS.DISPATCH_COMPLETE_SOURCES(orgId), req);
@@ -29153,7 +29159,11 @@ var LaneLedger = class {
29153
29159
  throw err;
29154
29160
  }
29155
29161
  if (!res.claimed || !res.lane) {
29156
- this.opts.log?.info(`lane for ${targetUri} held by a healthy incumbent \u2014 leaving events pending for re-drive`);
29162
+ if (res.reason === "empty") {
29163
+ this.opts.log?.warn(`claim for ${targetUri} came back empty \u2014 nothing foldable; leaving to the reconciler`);
29164
+ } else {
29165
+ this.opts.log?.info(`lane for ${targetUri} held by a healthy incumbent \u2014 leaving events pending for re-drive`);
29166
+ }
29157
29167
  return null;
29158
29168
  }
29159
29169
  const leaseUntilMs = Date.parse(res.lease_until ?? "");
@@ -29230,6 +29240,18 @@ var LaneLedger = class {
29230
29240
  * re-drives any same-target pending work. A STALE_LANE answer means a
29231
29241
  * takeover already owns the resource — local state is dropped either way.
29232
29242
  */
29243
+ /**
29244
+ * Record that the turn on this lane surfaced a runtime error. The flow
29245
+ * settles an errored lane immediately (dispatchLaneGroup returns 'failed'
29246
+ * after a forced complete), so the bit normally lives for one turn only —
29247
+ * it is the transport between the gateway's per-session error signal and
29248
+ * this lane's complete request.
29249
+ */
29250
+ markTurnError(laneKey) {
29251
+ const lane = this.lanes.get(laneKey);
29252
+ if (lane)
29253
+ lane.turnError = true;
29254
+ }
29233
29255
  async completeIfIdle(laneKey, hasMoreLocal) {
29234
29256
  const lane = this.lanes.get(laneKey);
29235
29257
  if (!lane || hasMoreLocal)
@@ -29240,7 +29262,10 @@ var LaneLedger = class {
29240
29262
  const res = await this.opts.client.completeDispatch(this.opts.orgId, {
29241
29263
  lane: lane.lane,
29242
29264
  target_uri: lane.targetUri,
29243
- thread_root_id: lane.threadRootId
29265
+ thread_root_id: lane.threadRootId,
29266
+ // An error turn releases its members for retry instead of sweeping
29267
+ // them as handled (ignored by older servers).
29268
+ turn_outcome: lane.turnError ? "error" : "ok"
29244
29269
  });
29245
29270
  if (res.swept_no_action > 0 || res.redriven) {
29246
29271
  this.opts.log?.info(`lane complete for ${lane.targetUri}: swept ${res.swept_no_action} no_action, redriven=${res.redriven}`);
@@ -29253,6 +29278,17 @@ var LaneLedger = class {
29253
29278
  this.opts.log?.warn(`lane complete failed for ${lane.targetUri}: ${String(err)}`);
29254
29279
  }
29255
29280
  }
29281
+ /**
29282
+ * Renew one lane by its key — the external runtime-activity hook for
29283
+ * adapters whose tool traffic bypasses the RuntimeEvent stream (openclaw
29284
+ * hooks). Scoped to the session's own lane: renewing every lane would let
29285
+ * one busy fork keep an unrelated stalled fork's lane leased forever.
29286
+ */
29287
+ renewByKey(laneKey) {
29288
+ const lane = this.lanes.get(laneKey);
29289
+ if (lane)
29290
+ this.maybeRenew(lane);
29291
+ }
29256
29292
  /**
29257
29293
  * Long-turn keepalive: renew the lane's lease on runtime activity, throttled
29258
29294
  * so a chatty turn doesn't spam the server. Without this, a legitimately
@@ -29394,17 +29430,34 @@ async function dispatchLaneGroup(host, opts) {
29394
29430
  }
29395
29431
  return "foreign";
29396
29432
  }
29433
+ host.noteSessionLane(opts.sessionKey, lane.laneKey);
29397
29434
  let dispatched = false;
29398
29435
  try {
29399
29436
  dispatched = await host.runDispatch(event, opts.sessionKey, opts.body, opts.earlier, opts.captureText);
29400
29437
  } catch (err) {
29438
+ host.noteSessionLane(opts.sessionKey, null);
29401
29439
  await ledger.release(lane.laneKey).catch(() => {
29402
29440
  });
29403
29441
  throw err;
29442
+ } finally {
29443
+ if (dispatched)
29444
+ host.noteSessionLane(opts.sessionKey, null);
29404
29445
  }
29405
29446
  if (!dispatched) {
29406
29447
  return "shutdown";
29407
29448
  }
29449
+ if (host.consumeTurnError(opts.sessionKey)) {
29450
+ ledger.markTurnError(lane.laneKey);
29451
+ for (const msgId of lane.folded.keys()) {
29452
+ host.dispatchedMessages.delete(msgId);
29453
+ }
29454
+ try {
29455
+ host.opts.dispatchAdapter.abortDispatch?.(opts.sessionKey);
29456
+ } catch {
29457
+ }
29458
+ await ledger.completeIfIdle(lane.laneKey, false);
29459
+ return "failed";
29460
+ }
29408
29461
  const pendingInjections = host.opts.dispatchAdapter.hasPendingInjections?.(opts.sessionKey) ?? false;
29409
29462
  await ledger.completeIfIdle(lane.laneKey, pendingInjections || opts.hasMoreLocal());
29410
29463
  return "dispatched";
@@ -30728,6 +30781,44 @@ var ParallAgentGateway = class {
30728
30781
  this.dispatchedMessages.add(id);
30729
30782
  return true;
30730
30783
  }
30784
+ /**
30785
+ * Lane currently being dispatched per session — lets external activity
30786
+ * signals renew exactly the caller's lane (renewing all lanes would keep
30787
+ * an unrelated stalled fork's lane leased forever).
30788
+ */
30789
+ sessionActiveLanes = /* @__PURE__ */ new Map();
30790
+ noteSessionLane(sessionKey, laneKey) {
30791
+ if (laneKey == null)
30792
+ this.sessionActiveLanes.delete(sessionKey);
30793
+ else
30794
+ this.sessionActiveLanes.set(sessionKey, laneKey);
30795
+ }
30796
+ /**
30797
+ * External runtime-activity signal for adapters whose tool activity does
30798
+ * not flow through the RuntimeEvent stream (openclaw hooks call this from
30799
+ * the tool-call lifecycle): renews the session's OWN active ledger lane so
30800
+ * a long tool call cannot outlive the lease and get dethroned mid-turn.
30801
+ * No-op without an active ledger lane for the session.
30802
+ */
30803
+ touchRuntimeActivity(sessionKey) {
30804
+ if (this.ledgerDisabled)
30805
+ return;
30806
+ const laneKey = this.sessionActiveLanes.get(sessionKey);
30807
+ if (laneKey)
30808
+ this.laneLedger?.renewByKey(laneKey);
30809
+ }
30810
+ /** Sessions whose in-flight turn surfaced a runtime error event. */
30811
+ turnErrorSessions = /* @__PURE__ */ new Set();
30812
+ /**
30813
+ * Consume (read-and-clear) the error marker for sessionKey's last turn.
30814
+ * Feeds complete's turn_outcome so an error turn's lane members are
30815
+ * released for retry instead of no_action-swept (design §3). Consuming
30816
+ * (rather than peeking) keeps one-shot fork session keys from accumulating
30817
+ * in the set forever.
30818
+ */
30819
+ consumeTurnError(sessionKey) {
30820
+ return this.turnErrorSessions.delete(sessionKey);
30821
+ }
30731
30822
  async emitDispatchReceived(event) {
30732
30823
  const sourceType = event.ackSourceType ?? (event.type === "task" ? "task_activity" : "message");
30733
30824
  const sourceId = event.ackSourceId ?? event.messageId;
@@ -31077,6 +31168,7 @@ var ParallAgentGateway = class {
31077
31168
  this.pendingRestartNotification = null;
31078
31169
  }
31079
31170
  resetDispatchMetrics(sessionKey);
31171
+ this.turnErrorSessions.delete(sessionKey);
31080
31172
  return runWithSessionKey(sessionKey, async () => {
31081
31173
  let dispatchSpan = null;
31082
31174
  setSessionChatId(sessionKey, event.targetId);
@@ -31184,6 +31276,9 @@ var ParallAgentGateway = class {
31184
31276
  } else if (runtimeEvent.type === "tool_result" && pendingSendCallIds.delete(runtimeEvent.callId)) {
31185
31277
  recordMessageSend(sessionKey, !runtimeEvent.error);
31186
31278
  }
31279
+ if (runtimeEvent.type === "error") {
31280
+ this.turnErrorSessions.add(sessionKey);
31281
+ }
31187
31282
  await this.createRuntimeStep(binding.agentSessionId, event, runtimeEvent, stepIdFilePath, contextFilePath, laneContextFilePath2);
31188
31283
  }
31189
31284
  if (!binding) {
@@ -31331,13 +31426,23 @@ var ParallAgentGateway = class {
31331
31426
  body: buildForkScopePrefix(last) + buildEventBody(last),
31332
31427
  earlier,
31333
31428
  captureText: batchText,
31334
- hasMoreLocal: () => fork.queue.length > 0
31429
+ // Per-LANE residue check (parity with the main-buffer path):
31430
+ // the fork queue can hold several lanes (channel + thread of
31431
+ // the same chat). A whole-queue check would defer THIS lane's
31432
+ // complete behind another lane's items and never revisit it —
31433
+ // its members would sit received until lease expiry.
31434
+ hasMoreLocal: () => fork.queue.some((it) => this.dispatchGroupKey(it.event) === this.dispatchGroupKey(last))
31335
31435
  });
31336
31436
  if (outcome === "foreign") {
31337
31437
  for (const item of items)
31338
31438
  item.resolve(false);
31339
31439
  break;
31340
31440
  }
31441
+ if (outcome === "failed") {
31442
+ for (const item of items)
31443
+ item.resolve(false);
31444
+ continue;
31445
+ }
31341
31446
  dispatched = outcome === "dispatched";
31342
31447
  } else {
31343
31448
  dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildForkScopePrefix(last) + buildEventBody(last), earlier, batchText);
@@ -31371,6 +31476,7 @@ var ParallAgentGateway = class {
31371
31476
  remaining.resolve(false);
31372
31477
  }
31373
31478
  } finally {
31479
+ this.turnErrorSessions.delete(fork.fork.sessionKey);
31374
31480
  if (fork.deadlineTimer) {
31375
31481
  clearTimeout(fork.deadlineTimer);
31376
31482
  fork.deadlineTimer = null;
@@ -31483,6 +31589,10 @@ var ParallAgentGateway = class {
31483
31589
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
31484
31590
  continue;
31485
31591
  }
31592
+ if (outcome === "failed") {
31593
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
31594
+ continue;
31595
+ }
31486
31596
  continue;
31487
31597
  }
31488
31598
  try {
@@ -31586,7 +31696,7 @@ var ParallAgentGateway = class {
31586
31696
  }
31587
31697
  this.dispatchState.mainBuffer.push(event);
31588
31698
  if (this.usesLaneLedger(event)) {
31589
- if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) && await this.laneLedger?.steerLive(event) && await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event))) {
31699
+ if (this.mainCurrentGroupKey === this.dispatchGroupKey(event) && this.opts.dispatchAdapter.enqueueDuringDispatch != null && await this.laneLedger?.steerLive(event) && await this.opts.dispatchAdapter.enqueueDuringDispatch(this.opts.runtimeKey, buildEventBody(event))) {
31590
31700
  this.opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
31591
31701
  }
31592
31702
  } else if (this.dispatchState.mainCurrentTargetId === event.targetId && await this.opts.dispatchAdapter.enqueueDuringDispatch?.(this.opts.runtimeKey, buildEventBody(event))) {
@@ -31613,12 +31723,14 @@ var ParallAgentGateway = class {
31613
31723
  this.dispatchState.mainBuffer.push(event);
31614
31724
  return false;
31615
31725
  }
31616
- try {
31617
- await this.emitDispatchReceived(event);
31618
- } catch (err) {
31619
- this.opts.log?.warn?.(`mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`);
31620
- this.dispatchState.mainBuffer.push(event);
31621
- return false;
31726
+ if (!this.usesLaneLedger(event)) {
31727
+ try {
31728
+ await this.emitDispatchReceived(event);
31729
+ } catch (err) {
31730
+ this.opts.log?.warn?.(`mark-received failed for fork dispatch, leaving unacked for retry: ${String(err)}`);
31731
+ this.dispatchState.mainBuffer.push(event);
31732
+ return false;
31733
+ }
31622
31734
  }
31623
31735
  const fork = await this.opts.dispatchAdapter.forkSession({
31624
31736
  sessionKey: this.opts.runtimeKey,
@@ -33873,6 +33985,10 @@ function cleanupForkSession(opts) {
33873
33985
  }
33874
33986
 
33875
33987
  // dist/gateway.js
33988
+ function sessionContextFilePath(stateDir, sessionKey) {
33989
+ const fileName = Buffer.from(sessionKey).toString("base64url");
33990
+ return path8.join(stateDir, "dispatch-context", `${fileName}.json`);
33991
+ }
33876
33992
  function resolveWsUrl(account) {
33877
33993
  if (account.config.ws_url)
33878
33994
  return account.config.ws_url;
@@ -34242,6 +34358,18 @@ var parallGateway = {
34242
34358
  runtimeKey: orchestratorKey,
34243
34359
  runtimeRef: { hostname: os2.hostname(), pid: process.pid },
34244
34360
  dispatchAdapter,
34361
+ // Opts openclaw into the dispatch ledger (claim/fold/complete +
34362
+ // idempotent reply effects) — the same shared-gateway machinery
34363
+ // claude/codex ride; openclaw stays buffer-only (no mid-turn steer),
34364
+ // which the ledger does not require. Replies already flow through
34365
+ // @parall/cli, which reads PRLL_CONTEXT_DIR (injected in hooks.ts)
34366
+ // to bind dispatch_lane + reply effect keys.
34367
+ // Design: docs/engineering-design/dispatch-convergence-design.md §7 (S2).
34368
+ dispatchContextDir: dispatchLaneContextDir(stateDir),
34369
+ // Per-session context file (PRLL_CONTEXT_FILE contract) — the CLI's
34370
+ // TYPED dispatch binding (parall tasks update → task_update effect)
34371
+ // reads lane/event/task fields from this file, not the lane dir.
34372
+ contextFilePathForSession: (sessionKey) => sessionContextFilePath(stateDir, sessionKey),
34245
34373
  log: otelLog,
34246
34374
  // Live capability view for hint routing: the channel reply hint
34247
34375
  // points at the vendor CLI only while the grant is active.
@@ -34265,7 +34393,13 @@ var parallGateway = {
34265
34393
  runtimeRef: { hostname: os2.hostname(), pid: process.pid },
34266
34394
  wikiMountRoot,
34267
34395
  ws,
34268
- orchestratorSessionKey: orchestratorKey
34396
+ orchestratorSessionKey: orchestratorKey,
34397
+ dispatchContextDir: dispatchLaneContextDir(stateDir),
34398
+ contextFilePathForSession: (sessionKey) => sessionContextFilePath(stateDir, sessionKey),
34399
+ // Lease-renewal bridge: openclaw tool calls flow through hooks,
34400
+ // not the RuntimeEvent stream, so hook activity must renew the
34401
+ // ledger lanes or a long exec outlives the lease (STALE_LANE).
34402
+ touchRuntimeActivity: (sessionKey) => gateway.touchRuntimeActivity(sessionKey)
34269
34403
  });
34270
34404
  },
34271
34405
  onSessionBinding: async ({ sessionKey, agentSessionId }) => {
@@ -34450,10 +34584,41 @@ function registerParallHooks(api) {
34450
34584
  };
34451
34585
  });
34452
34586
  const pendingSendCalls = /* @__PURE__ */ new Map();
34587
+ const laneRenewals = /* @__PURE__ */ new Map();
34588
+ const LANE_RENEW_INTERVAL_MS = 18e4;
34589
+ const beginToolRenewal = (sessionKey) => {
34590
+ const accountId = extractAccountIdFromSessionKey(sessionKey);
34591
+ const state = accountId ? getParallAccountState(accountId) : void 0;
34592
+ if (!state?.touchRuntimeActivity)
34593
+ return;
34594
+ state.touchRuntimeActivity(sessionKey);
34595
+ const entry = laneRenewals.get(sessionKey) ?? { count: 0 };
34596
+ entry.count++;
34597
+ if (!entry.timer) {
34598
+ entry.timer = setInterval(() => state.touchRuntimeActivity?.(sessionKey), LANE_RENEW_INTERVAL_MS);
34599
+ entry.timer.unref?.();
34600
+ }
34601
+ laneRenewals.set(sessionKey, entry);
34602
+ };
34603
+ const endToolRenewal = (sessionKey) => {
34604
+ const entry = laneRenewals.get(sessionKey);
34605
+ if (entry) {
34606
+ entry.count--;
34607
+ if (entry.count <= 0) {
34608
+ if (entry.timer)
34609
+ clearInterval(entry.timer);
34610
+ laneRenewals.delete(sessionKey);
34611
+ }
34612
+ }
34613
+ const accountId = extractAccountIdFromSessionKey(sessionKey);
34614
+ const state = accountId ? getParallAccountState(accountId) : void 0;
34615
+ state?.touchRuntimeActivity?.(sessionKey);
34616
+ };
34453
34617
  api.on("before_tool_call", async (event, ctx) => {
34454
34618
  const sessionKey = ctx.sessionKey;
34455
34619
  if (sessionKey) {
34456
34620
  recordToolCall(sessionKey);
34621
+ beginToolRenewal(sessionKey);
34457
34622
  if (event.toolName === "exec" && event.toolCallId) {
34458
34623
  const command = event.params?.command;
34459
34624
  if (isParallSendCommand(command)) {
@@ -34522,6 +34687,12 @@ function registerParallHooks(api) {
34522
34687
  injectedEnv.PRLL_TRIGGER_MESSAGE_ID = triggerMsgId;
34523
34688
  if (state.wikiMountRoot)
34524
34689
  injectedEnv.PRLL_WIKI_MOUNT_ROOT = state.wikiMountRoot;
34690
+ if (state.dispatchContextDir)
34691
+ injectedEnv.PRLL_CONTEXT_DIR = state.dispatchContextDir;
34692
+ if (state.contextFilePathForSession) {
34693
+ injectedEnv.PRLL_CONTEXT_FILE = state.contextFilePathForSession(sessionKey);
34694
+ }
34695
+ state.touchRuntimeActivity?.(sessionKey);
34525
34696
  injectedEnv.OPENCLAW_SESSION_KEY = sessionKey;
34526
34697
  if (event.toolCallId)
34527
34698
  injectedEnv.OPENCLAW_TOOL_CALL_ID = event.toolCallId;
@@ -34533,6 +34704,8 @@ function registerParallHooks(api) {
34533
34704
  };
34534
34705
  });
34535
34706
  api.on("after_tool_call", async (event, ctx) => {
34707
+ if (ctx.sessionKey)
34708
+ endToolRenewal(ctx.sessionKey);
34536
34709
  if (ctx.sessionKey && event.toolCallId && pendingSendCalls.delete(event.toolCallId)) {
34537
34710
  recordMessageSend(ctx.sessionKey, !event.error);
34538
34711
  }
package/dist/runtime.d.ts CHANGED
@@ -16,6 +16,23 @@ export type ParallAccountState = {
16
16
  wikiMountRoot?: string;
17
17
  ws?: ParallWs;
18
18
  orchestratorSessionKey?: string;
19
+ /**
20
+ * Per-lane dispatch context dir (PRLL_CONTEXT_DIR contract) — set when the
21
+ * gateway runs the dispatch ledger; hooks inject it into exec env so the
22
+ * CLI binds replies to their dispatch lane.
23
+ */
24
+ dispatchContextDir?: string;
25
+ /**
26
+ * Per-session dispatch context file resolver (PRLL_CONTEXT_FILE contract)
27
+ * — the CLI's typed dispatch binding (task_update effects) reads from it.
28
+ */
29
+ contextFilePathForSession?: (sessionKey: string) => string;
30
+ /**
31
+ * Lease-renewal bridge into the gateway: openclaw tool activity flows
32
+ * through hooks (not the RuntimeEvent stream), so hooks must forward it or
33
+ * a long exec outlives the lane lease.
34
+ */
35
+ touchRuntimeActivity?: (sessionKey: string) => void;
19
36
  [key: string]: unknown;
20
37
  };
21
38
  export declare function setParallRuntime(next: PluginRuntime): void;
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjF,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,mBAAmB,EACnB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,qBAAqB,EACrB,kBAAkB,EAClB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,CAAC;AAIF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,QAEnD;AAED,wBAAgB,gBAAgB,IAAI,aAAa,CAKhD;AAKD,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,kBAAkB,QAEjF;AAED,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,QAEzD;AAED,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS,CAEvF;AAED,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,cAAc,EAAE,MAAM,QAOvB;AAED,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEhG;AAED,wBAAgB,yBAAyB,IAAI,WAAW,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAEnF;AAID,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,aAAa,QAEvD;AAED,wBAAgB,gBAAgB,IAAI,aAAa,GAAG,SAAS,CAE5D"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjF,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,mBAAmB,EACnB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,qBAAqB,EACrB,kBAAkB,EAClB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,yBAAyB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3D;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,CAAC;AAIF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,QAEnD;AAED,wBAAgB,gBAAgB,IAAI,aAAa,CAKhD;AAKD,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,kBAAkB,QAEjF;AAED,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,QAEzD;AAED,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS,CAEvF;AAED,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,cAAc,EAAE,MAAM,QAOvB;AAED,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEhG;AAED,wBAAgB,yBAAyB,IAAI,WAAW,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAEnF;AAID,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,aAAa,QAEvD;AAED,wBAAgB,gBAAgB,IAAI,aAAa,GAAG,SAAS,CAE5D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/parall",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "description": "OpenClaw channel plugin for Parall IM",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,8 +16,8 @@
16
16
  "openclaw.plugin.json"
17
17
  ],
18
18
  "dependencies": {
19
- "@parall/agent-core": "1.43.0",
20
- "@parall/sdk": "1.43.0"
19
+ "@parall/agent-core": "1.44.0",
20
+ "@parall/sdk": "1.44.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/node": "^22.0.0",
package/src/gateway.ts CHANGED
@@ -6,6 +6,7 @@ type ChannelGatewayAdapter<T = unknown> = NonNullable<ChannelPlugin<T>['gateway'
6
6
  import {
7
7
  ParallAgentGateway,
8
8
  capabilityBinDir,
9
+ dispatchLaneContextDir,
9
10
  parseShutdownDeadlineMs,
10
11
  parseForkDeadlineMs,
11
12
  parseDispatchDeadlineMs,
@@ -47,6 +48,16 @@ import {
47
48
  resolveTranscriptFile,
48
49
  } from './fork.js';
49
50
 
51
+ /**
52
+ * Per-session dispatch context file (PRLL_CONTEXT_FILE contract) — same
53
+ * encoding as the claude/codex bridges: base64url(sessionKey) under the
54
+ * state dir's dispatch-context/.
55
+ */
56
+ function sessionContextFilePath(stateDir: string, sessionKey: string): string {
57
+ const fileName = Buffer.from(sessionKey).toString('base64url');
58
+ return path.join(stateDir, 'dispatch-context', `${fileName}.json`);
59
+ }
60
+
50
61
  function resolveWsUrl(account: ResolvedParallAccount): string {
51
62
  if (account.config.ws_url) return account.config.ws_url;
52
63
  const base = account.config.parall_url.replace(/\/$/, '');
@@ -482,6 +493,19 @@ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
482
493
  runtimeKey: orchestratorKey,
483
494
  runtimeRef: { hostname: os.hostname(), pid: process.pid },
484
495
  dispatchAdapter,
496
+ // Opts openclaw into the dispatch ledger (claim/fold/complete +
497
+ // idempotent reply effects) — the same shared-gateway machinery
498
+ // claude/codex ride; openclaw stays buffer-only (no mid-turn steer),
499
+ // which the ledger does not require. Replies already flow through
500
+ // @parall/cli, which reads PRLL_CONTEXT_DIR (injected in hooks.ts)
501
+ // to bind dispatch_lane + reply effect keys.
502
+ // Design: docs/engineering-design/dispatch-convergence-design.md §7 (S2).
503
+ dispatchContextDir: dispatchLaneContextDir(stateDir),
504
+ // Per-session context file (PRLL_CONTEXT_FILE contract) — the CLI's
505
+ // TYPED dispatch binding (parall tasks update → task_update effect)
506
+ // reads lane/event/task fields from this file, not the lane dir.
507
+ contextFilePathForSession: (sessionKey: string) =>
508
+ sessionContextFilePath(stateDir, sessionKey),
485
509
  log: otelLog,
486
510
  // Live capability view for hint routing: the channel reply hint
487
511
  // points at the vendor CLI only while the grant is active.
@@ -506,6 +530,13 @@ export const parallGateway: ChannelGatewayAdapter<ResolvedParallAccount> = {
506
530
  wikiMountRoot,
507
531
  ws,
508
532
  orchestratorSessionKey: orchestratorKey,
533
+ dispatchContextDir: dispatchLaneContextDir(stateDir),
534
+ contextFilePathForSession: (sessionKey: string) =>
535
+ sessionContextFilePath(stateDir, sessionKey),
536
+ // Lease-renewal bridge: openclaw tool calls flow through hooks,
537
+ // not the RuntimeEvent stream, so hook activity must renew the
538
+ // ledger lanes or a long exec outlives the lease (STALE_LANE).
539
+ touchRuntimeActivity: (sessionKey: string) => gateway.touchRuntimeActivity(sessionKey),
509
540
  });
510
541
  },
511
542
  onSessionBinding: async ({ sessionKey, agentSessionId }) => {
package/src/hooks.ts CHANGED
@@ -140,6 +140,49 @@ export function registerParallHooks(api: OpenClawPluginApi) {
140
140
 
141
141
  const pendingSendCalls = new Map<string, boolean>();
142
142
 
143
+ // Lane lease keepalive across the tool-call lifecycle. OpenClaw tool calls
144
+ // bypass the RuntimeEvent stream, so lane renewal must ride these hooks —
145
+ // and a ONE-SHOT touch at tool start is not enough: a single exec running
146
+ // past the lane TTL (10min server-side) would still be dethroned mid-call
147
+ // and the eventual CLI reply would die with STALE_LANE. While any tool call
148
+ // is in flight for a session, an unref'd interval keeps touching; the
149
+ // half-TTL throttle in the ledger decides when a real heartbeat is sent.
150
+ const laneRenewals = new Map<string, { count: number; timer?: ReturnType<typeof setInterval> }>();
151
+ const LANE_RENEW_INTERVAL_MS = 180_000;
152
+
153
+ const beginToolRenewal = (sessionKey: string) => {
154
+ const accountId = extractAccountIdFromSessionKey(sessionKey);
155
+ const state = accountId ? getParallAccountState(accountId) : undefined;
156
+ if (!state?.touchRuntimeActivity) return;
157
+ state.touchRuntimeActivity(sessionKey);
158
+ const entry = laneRenewals.get(sessionKey) ?? { count: 0 };
159
+ entry.count++;
160
+ if (!entry.timer) {
161
+ entry.timer = setInterval(
162
+ () => state.touchRuntimeActivity?.(sessionKey),
163
+ LANE_RENEW_INTERVAL_MS,
164
+ );
165
+ entry.timer.unref?.();
166
+ }
167
+ laneRenewals.set(sessionKey, entry);
168
+ };
169
+
170
+ const endToolRenewal = (sessionKey: string) => {
171
+ const entry = laneRenewals.get(sessionKey);
172
+ if (entry) {
173
+ entry.count--;
174
+ if (entry.count <= 0) {
175
+ if (entry.timer) clearInterval(entry.timer);
176
+ laneRenewals.delete(sessionKey);
177
+ }
178
+ }
179
+ const accountId = extractAccountIdFromSessionKey(sessionKey);
180
+ const state = accountId ? getParallAccountState(accountId) : undefined;
181
+ // Final touch on completion — covers the reply window right after a
182
+ // long tool call ends.
183
+ state?.touchRuntimeActivity?.(sessionKey);
184
+ };
185
+
143
186
  // before_tool_call -> (1) update dispatch metrics, (2) await step creation, (3) ENV injection
144
187
  // OpenClaw tool calls bypass the RuntimeEvent stream (they go through hooks, not the
145
188
  // gateway-base for-await loop), so dispatch metrics must be updated here as well.
@@ -147,6 +190,9 @@ export function registerParallHooks(api: OpenClawPluginApi) {
147
190
  const sessionKey = ctx.sessionKey;
148
191
  if (sessionKey) {
149
192
  recordToolCall(sessionKey);
193
+ // Every tool (not just exec) keeps the ledger lane alive for its
194
+ // whole lifetime — see laneRenewals above.
195
+ beginToolRenewal(sessionKey);
150
196
  if (event.toolName === 'exec' && event.toolCallId) {
151
197
  const command = (event.params as Record<string, unknown>)?.command as string | undefined;
152
198
  if (isParallSendCommand(command)) {
@@ -228,6 +274,19 @@ export function registerParallHooks(api: OpenClawPluginApi) {
228
274
  }
229
275
  if (triggerMsgId) injectedEnv.PRLL_TRIGGER_MESSAGE_ID = triggerMsgId;
230
276
  if (state.wikiMountRoot) injectedEnv.PRLL_WIKI_MOUNT_ROOT = state.wikiMountRoot;
277
+ // Dispatch-ledger contract: the CLI keys into this directory by send
278
+ // target to bind dispatch_lane + the reply:<dsp> effect key (idempotent
279
+ // replies). Set only when the gateway runs the ledger.
280
+ if (state.dispatchContextDir) injectedEnv.PRLL_CONTEXT_DIR = state.dispatchContextDir;
281
+ // Per-session context file — the CLI's TYPED binding (parall tasks
282
+ // update → task_update:<dsp> effect) reads lane/event/task from here.
283
+ if (state.contextFilePathForSession) {
284
+ injectedEnv.PRLL_CONTEXT_FILE = state.contextFilePathForSession(sessionKey);
285
+ }
286
+ // Long tool calls bypass the RuntimeEvent stream — forward activity so
287
+ // the ledger lane lease renews (a >TTL exec would otherwise be dethroned
288
+ // mid-turn and the eventual CLI reply would die with STALE_LANE).
289
+ state.touchRuntimeActivity?.(sessionKey);
231
290
 
232
291
  // OpenClaw context (upstream doesn't inject these into exec env yet)
233
292
  injectedEnv.OPENCLAW_SESSION_KEY = sessionKey;
@@ -247,6 +306,7 @@ export function registerParallHooks(api: OpenClawPluginApi) {
247
306
 
248
307
  // after_tool_call -> track CLI outcomes + send tool_result step to AgentSession
249
308
  api.on('after_tool_call', async (event, ctx) => {
309
+ if (ctx.sessionKey) endToolRenewal(ctx.sessionKey);
250
310
  if (ctx.sessionKey && event.toolCallId && pendingSendCalls.delete(event.toolCallId)) {
251
311
  recordMessageSend(ctx.sessionKey, !event.error);
252
312
  }
package/src/runtime.ts CHANGED
@@ -32,6 +32,23 @@ export type ParallAccountState = {
32
32
  wikiMountRoot?: string;
33
33
  ws?: ParallWs;
34
34
  orchestratorSessionKey?: string;
35
+ /**
36
+ * Per-lane dispatch context dir (PRLL_CONTEXT_DIR contract) — set when the
37
+ * gateway runs the dispatch ledger; hooks inject it into exec env so the
38
+ * CLI binds replies to their dispatch lane.
39
+ */
40
+ dispatchContextDir?: string;
41
+ /**
42
+ * Per-session dispatch context file resolver (PRLL_CONTEXT_FILE contract)
43
+ * — the CLI's typed dispatch binding (task_update effects) reads from it.
44
+ */
45
+ contextFilePathForSession?: (sessionKey: string) => string;
46
+ /**
47
+ * Lease-renewal bridge into the gateway: openclaw tool activity flows
48
+ * through hooks (not the RuntimeEvent stream), so hooks must forward it or
49
+ * a long exec outlives the lane lease.
50
+ */
51
+ touchRuntimeActivity?: (sessionKey: string) => void;
35
52
  [key: string]: unknown;
36
53
  };
37
54