@parall/agent-core 1.51.0 → 1.52.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.
@@ -38,6 +38,8 @@ import {
38
38
  type ForkSessionHandle,
39
39
  type GatewayLogger,
40
40
  type RuntimeEvent,
41
+ type SettledTurnOutcome,
42
+ type TurnOutcomeEvent,
41
43
  } from './dispatch-adapter.js';
42
44
  import {
43
45
  clearTypedDedupeForEvent,
@@ -46,11 +48,13 @@ import {
46
48
  dispatchLaneGroup,
47
49
  resolveDispatchByID,
48
50
  settleDrainedTypedGroup,
51
+ steerLaneMessage,
49
52
  typedLedgerEventIds,
50
53
  } from './gateway-lane-flow.js';
51
54
  import type { LaneFlowHost, TypedConsumeHooks } from './gateway-lane-flow.js';
52
- import { LaneLedger } from './lane-ledger.js';
55
+ import { LaneLedger, bindLaneSession, releaseLocalMessageClaims } from './lane-ledger.js';
53
56
  import { routeTrigger } from './routing.js';
57
+ import { redactTurnOutcome } from './redact.js';
54
58
  import { StepPersister, isRetryableStepError } from './step-persister.js';
55
59
  import { SessionLifecycleCoordinator, type TurnHandle } from './session-lifecycle.js';
56
60
  import { ForkSessionFinalizer } from './fork-session-finalizer.js';
@@ -80,6 +84,7 @@ import {
80
84
  endDispatchSpan,
81
85
  recordDispatchMetric,
82
86
  recordMissingReply,
87
+ recordTurnUsage,
83
88
  runWithSessionKey,
84
89
  } from './telemetry.js';
85
90
  import type { DispatchState, ParallEvent } from './types.js';
@@ -413,6 +418,9 @@ export class ParallAgentGateway {
413
418
  orgId: opts.config.org_id,
414
419
  contextDir: opts.dispatchContextDir,
415
420
  log: opts.log,
421
+ coverageMode: opts.dispatchAdapter.inputLifecycleMode ?? 'implicit',
422
+ releaseLocalClaims: (sourceIds) =>
423
+ releaseLocalMessageClaims(this.dispatchedMessages, sourceIds),
416
424
  });
417
425
  }
418
426
  this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
@@ -813,18 +821,53 @@ export class ParallAgentGateway {
813
821
  if (laneKey) this.laneLedger?.renewByKey(laneKey);
814
822
  }
815
823
 
816
- /** Sessions whose in-flight turn surfaced a runtime error event. */
817
- private turnErrorSessions = new Set<string>();
824
+ /**
825
+ * Settled LLM-layer outcome of each session's last turn — an error to
826
+ * release lane members on the redrive budget, or a deferred usage-limit
827
+ * wait (agent-turn-outcome-design.md). Absent = clean turn.
828
+ */
829
+ private turnOutcomes = new Map<string, SettledTurnOutcome>();
818
830
 
819
831
  /**
820
- * Consume (read-and-clear) the error marker for sessionKey's last turn.
832
+ * Consume (read-and-clear) the settled outcome for sessionKey's last turn.
821
833
  * Feeds complete's turn_outcome so an error turn's lane members are
822
- * released for retry instead of no_action-swept (design §3). Consuming
823
- * (rather than peeking) keeps one-shot fork session keys from accumulating
824
- * in the set forever.
834
+ * released for retry (and a deferred turn's members re-deliver at retryAt)
835
+ * instead of being no_action-swept (design §3). Consuming (rather than
836
+ * peeking) keeps one-shot fork session keys from accumulating forever.
837
+ */
838
+ consumeTurnOutcome(sessionKey: string): SettledTurnOutcome | undefined {
839
+ const outcome = this.turnOutcomes.get(sessionKey);
840
+ this.turnOutcomes.delete(sessionKey);
841
+ return outcome;
842
+ }
843
+
844
+ /**
845
+ * Boolean view of consumeTurnOutcome for the typed/fork call sites, which
846
+ * have no deferred semantics: ANY non-clean outcome (error or deferred)
847
+ * counts as an errored turn there — release-for-retry beats a false
848
+ * "handled".
825
849
  */
826
850
  consumeTurnError(sessionKey: string): boolean {
827
- return this.turnErrorSessions.delete(sessionKey);
851
+ return this.consumeTurnOutcome(sessionKey) !== undefined;
852
+ }
853
+
854
+ /**
855
+ * Fold turn signals into the session's settled outcome. Two sources, one
856
+ * precedence rule: the bridge's explicit turn_outcome classification always
857
+ * wins (it may refine the SAME failure a generic `error` event already
858
+ * reported — e.g. Claude's result frame yields both), while a bare `error`
859
+ * event only fills the slot when no classification exists. Wire order makes
860
+ * this safe: every bridge emits its error events before the turn-boundary
861
+ * turn_outcome.
862
+ */
863
+ private recordTurnErrorSignal(sessionKey: string): void {
864
+ if (!this.turnOutcomes.has(sessionKey)) {
865
+ this.turnOutcomes.set(sessionKey, { kind: 'error' });
866
+ }
867
+ }
868
+
869
+ private recordTurnClassification(sessionKey: string, next: SettledTurnOutcome): void {
870
+ this.turnOutcomes.set(sessionKey, next);
828
871
  }
829
872
 
830
873
  private async emitDispatchReceived(event: ParallEvent): Promise<void> {
@@ -906,7 +949,7 @@ export class ParallAgentGateway {
906
949
  earlier: ParallEvent[];
907
950
  captureText?: string[];
908
951
  hasMoreLocal: () => boolean;
909
- }): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
952
+ }): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed' | 'deferred'> {
910
953
  return dispatchLaneGroup(this.laneFlowHost(), opts);
911
954
  }
912
955
 
@@ -1075,7 +1118,14 @@ export class ParallAgentGateway {
1075
1118
  }
1076
1119
  : event.type === 'approval'
1077
1120
  ? { approval_id: event.messageId }
1078
- : { message_id: event.messageId },
1121
+ : {
1122
+ message_id: event.messageId,
1123
+ // Thread context for the session→chat backlink:
1124
+ // a thread reply can only be navigated to with
1125
+ // its thread root (the chat surface rejects
1126
+ // bare thread-message targets).
1127
+ ...(event.threadRootId ? { thread_root_id: event.threadRootId } : {}),
1128
+ },
1079
1129
  sender_id: event.senderId,
1080
1130
  sender_name: event.senderName,
1081
1131
  summary: event.body.substring(0, 200),
@@ -1336,6 +1386,7 @@ export class ParallAgentGateway {
1336
1386
  bodyForAgent: string,
1337
1387
  earlierEvents: ParallEvent[] = [],
1338
1388
  captureText?: string[],
1389
+ inputLifecycle?: import('./dispatch-adapter.js').DispatchInputLifecycle,
1339
1390
  ): Promise<boolean> {
1340
1391
  // Graceful shutdown: once SIGTERM has fired we stop accepting new work.
1341
1392
  // In-flight dispatches that started before the flag flipped keep running
@@ -1353,7 +1404,7 @@ export class ParallAgentGateway {
1353
1404
  }
1354
1405
 
1355
1406
  resetDispatchMetrics(sessionKey);
1356
- this.turnErrorSessions.delete(sessionKey);
1407
+ this.turnOutcomes.delete(sessionKey);
1357
1408
  return runWithSessionKey(sessionKey, async () => {
1358
1409
  let dispatchSpan: ReturnType<typeof startDispatchSpan> = null;
1359
1410
 
@@ -1418,6 +1469,8 @@ export class ParallAgentGateway {
1418
1469
  let inputStepsCreated = false;
1419
1470
  let turnHandle: TurnHandle | undefined;
1420
1471
  let dispatchError: unknown;
1472
+ let turnOutcomeEvent: TurnOutcomeEvent | undefined;
1473
+ let sawErrorEvent = false;
1421
1474
  const pendingSendCallIds = new Set<string>();
1422
1475
  // Turn boundary: must complete one bounded active reconciliation
1423
1476
  // BEFORE this turn's first AgentStep persists — a reused idle session
@@ -1437,6 +1490,7 @@ export class ParallAgentGateway {
1437
1490
  bodyForAgent,
1438
1491
  sessionKey,
1439
1492
  context: dispatchContext,
1493
+ inputLifecycle,
1440
1494
  })) {
1441
1495
  if (runtimeEvent.type === 'runtime_session') {
1442
1496
  const priorAgentSessionId = binding?.agentSessionId;
@@ -1446,6 +1500,13 @@ export class ParallAgentGateway {
1446
1500
  contextFilePath,
1447
1501
  laneContextFilePath,
1448
1502
  );
1503
+ if (activeLane) {
1504
+ // Session attribution for the lane's complete sweep (the server
1505
+ // validates ownership; informational). bindLaneSession flips
1506
+ // sticky-ambiguous on a session rotation instead of blaming
1507
+ // the newest session.
1508
+ bindLaneSession(activeLane, binding.agentSessionId);
1509
+ }
1449
1510
  if (
1450
1511
  event.targetType === 'channel_conversation' &&
1451
1512
  binding.agentSessionId !== priorAgentSessionId
@@ -1480,6 +1541,73 @@ export class ParallAgentGateway {
1480
1541
  continue;
1481
1542
  }
1482
1543
 
1544
+ if (runtimeEvent.type === 'turn_outcome') {
1545
+ // Turn-boundary summary (at most one per turn): capture for the
1546
+ // finally-block telemetry, fold non-ok classes into the settled
1547
+ // outcome the ledger complete consumes, and surface non-ok turns
1548
+ // as an error step so limit/auth failures are visible in the
1549
+ // session panel instead of vanishing as a silent no_action
1550
+ // (agent-turn-outcome-design.md §5).
1551
+ // detail/raw echo provider error bodies — redact before the event
1552
+ // reaches any sink (step, log, telemetry).
1553
+ const outcomeEvent = redactTurnOutcome(runtimeEvent, [this.opts.config.api_key]);
1554
+ turnOutcomeEvent = outcomeEvent;
1555
+ if (outcomeEvent.outcome === 'ok') {
1556
+ // Authoritative boundary verdict: an explicit ok clears any
1557
+ // generic error-event marker recorded earlier this turn (e.g.
1558
+ // codex's broadcast `error` notification followed by a
1559
+ // successful turn/completed) — completing the lane as an error
1560
+ // would redrive successfully handled work.
1561
+ this.turnOutcomes.delete(sessionKey);
1562
+ continue;
1563
+ }
1564
+ this.recordTurnClassification(
1565
+ sessionKey,
1566
+ outcomeEvent.outcome === 'usage_limit'
1567
+ ? {
1568
+ kind: 'deferred',
1569
+ outcomeClass: outcomeEvent.outcome,
1570
+ ...(outcomeEvent.retryAt ? { retryAt: outcomeEvent.retryAt } : {}),
1571
+ }
1572
+ : { kind: 'error', outcomeClass: outcomeEvent.outcome },
1573
+ );
1574
+ const retryNote = outcomeEvent.retryAt ? `, retry at ${outcomeEvent.retryAt}` : '';
1575
+ this.opts.log?.warn(
1576
+ `turn outcome: ${outcomeEvent.outcome}${retryNote}${outcomeEvent.detail ? ` — ${outcomeEvent.detail}` : ''}`,
1577
+ );
1578
+ if (binding) {
1579
+ // Same lifecycle ordering as every other step-producing event:
1580
+ // the turn must have begun and the input steps must exist
1581
+ // before the outcome step persists — an outcome-first turn
1582
+ // (e.g. openclaw pushing the classification as its only event
1583
+ // on a reused session) must not write a step into an idle
1584
+ // session (review round 2 P2).
1585
+ await ensureTurnBegun();
1586
+ if (!inputStepsCreated) {
1587
+ if (earlierEvents.length > 0) {
1588
+ await this.createInputStepsForEarlierEvents(
1589
+ binding.agentSessionId,
1590
+ earlierEvents,
1591
+ );
1592
+ }
1593
+ await this.createInputStep(binding.agentSessionId, event);
1594
+ inputStepsCreated = true;
1595
+ }
1596
+ await this.createRuntimeStep(
1597
+ binding.agentSessionId,
1598
+ event,
1599
+ {
1600
+ type: 'error',
1601
+ message: `LLM turn ${outcomeEvent.outcome}${retryNote}${outcomeEvent.detail ? `: ${outcomeEvent.detail}` : ''}`,
1602
+ },
1603
+ stepIdFilePath,
1604
+ contextFilePath,
1605
+ laneContextFilePath,
1606
+ );
1607
+ }
1608
+ continue;
1609
+ }
1610
+
1483
1611
  if (!binding) {
1484
1612
  const detail = runtimeEvent.type === 'error' ? `: ${runtimeEvent.message}` : '';
1485
1613
  throw new Error(`runtime emitted ${runtimeEvent.type} before runtime_session${detail}`);
@@ -1518,7 +1646,11 @@ export class ParallAgentGateway {
1518
1646
  recordMessageSend(sessionKey, !runtimeEvent.error);
1519
1647
  }
1520
1648
  if (runtimeEvent.type === 'error') {
1521
- this.turnErrorSessions.add(sessionKey);
1649
+ sawErrorEvent = true;
1650
+ // Legacy signal (runtimes without a turn_outcome event, and the
1651
+ // pre-boundary error frames of runtimes with one). Fills the
1652
+ // settled slot only when no explicit classification exists.
1653
+ this.recordTurnErrorSignal(sessionKey);
1522
1654
  }
1523
1655
  await this.createRuntimeStep(
1524
1656
  binding.agentSessionId,
@@ -1589,8 +1721,14 @@ export class ParallAgentGateway {
1589
1721
  const metricsSnapshot = getDispatchMetrics(sessionKey);
1590
1722
  const durationMs = metricsSnapshot ? Date.now() - metricsSnapshot.started_at : 0;
1591
1723
 
1592
- endDispatchSpan(dispatchSpan, metricsSnapshot, dispatchError);
1593
- recordDispatchMetric(event, this.opts.runtimeType, durationMs);
1724
+ // Effective outcome for telemetry: the bridge's turn_outcome wins;
1725
+ // otherwise a thrown dispatch / legacy error event classifies as
1726
+ // runtime_crash, and a quiet turn is ok.
1727
+ const effectiveOutcome =
1728
+ turnOutcomeEvent?.outcome ?? (dispatchError || sawErrorEvent ? 'runtime_crash' : 'ok');
1729
+ endDispatchSpan(dispatchSpan, metricsSnapshot, dispatchError, turnOutcomeEvent);
1730
+ recordDispatchMetric(event, this.opts.runtimeType, durationMs, effectiveOutcome);
1731
+ recordTurnUsage(turnOutcomeEvent?.usage, this.opts.runtimeType);
1594
1732
 
1595
1733
  if (
1596
1734
  metricsSnapshot &&
@@ -1602,7 +1740,7 @@ export class ParallAgentGateway {
1602
1740
  metricsSnapshot.message_send_successes === 0 &&
1603
1741
  !metricsSnapshot.no_reply_called
1604
1742
  ) {
1605
- recordMissingReply(this.opts.runtimeType);
1743
+ recordMissingReply(this.opts.runtimeType, effectiveOutcome);
1606
1744
  }
1607
1745
 
1608
1746
  clearDispatchMetrics(sessionKey);
@@ -1730,11 +1868,12 @@ export class ParallAgentGateway {
1730
1868
  for (const item of items) item.resolve(false);
1731
1869
  break;
1732
1870
  }
1733
- if (outcome === 'failed') {
1734
- // Error turn: complete(error) already released the members for
1735
- // redelivery. Do NOT push them to processedEvents — the redrive
1736
- // must not arrive wearing an "already handled" prefix. Keep
1737
- // draining; the released work rejoins the next claim.
1871
+ if (outcome === 'failed' || outcome === 'deferred') {
1872
+ // Error/deferred turn: the settlement already released (or
1873
+ // scheduled) the members for redelivery. Do NOT push them to
1874
+ // processedEvents — the redelivery must not arrive wearing an
1875
+ // "already handled" prefix. Keep draining; the released work
1876
+ // rejoins the next claim.
1738
1877
  for (const item of items) item.resolve(false);
1739
1878
  continue;
1740
1879
  }
@@ -1794,10 +1933,10 @@ export class ParallAgentGateway {
1794
1933
  remaining.resolve(false);
1795
1934
  }
1796
1935
  } finally {
1797
- // One-shot fork keys are never dispatched again — drop any error marker
1798
- // the legacy (non-ledger) path left unconsumed, or the set grows with
1799
- // every failed ephemeral fork.
1800
- this.turnErrorSessions.delete(fork.fork.sessionKey);
1936
+ // One-shot fork keys are never dispatched again — drop any settled
1937
+ // outcome the legacy (non-ledger) path left unconsumed, or the map
1938
+ // grows with every failed ephemeral fork.
1939
+ this.turnOutcomes.delete(fork.fork.sessionKey);
1801
1940
  if (fork.deadlineTimer) {
1802
1941
  clearTimeout(fork.deadlineTimer);
1803
1942
  fork.deadlineTimer = null;
@@ -1913,7 +2052,7 @@ export class ParallAgentGateway {
1913
2052
  this.opts.runtimeKey,
1914
2053
  );
1915
2054
  if (this.usesLaneLedger(event)) {
1916
- let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed';
2055
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed' | 'deferred';
1917
2056
  try {
1918
2057
  outcome = await this.dispatchLaneGroup({
1919
2058
  events,
@@ -1940,10 +2079,10 @@ export class ParallAgentGateway {
1940
2079
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1941
2080
  continue;
1942
2081
  }
1943
- if (outcome === 'failed') {
1944
- // Error turn settled with complete(error) — members released for
1945
- // redelivery. The fork results were not consumed by the failed
1946
- // turn; keep them for the next real dispatch.
2082
+ if (outcome === 'failed' || outcome === 'deferred') {
2083
+ // Error/deferred turn settled with a non-ok complete — members
2084
+ // released (or scheduled) for redelivery. The fork results were
2085
+ // not consumed by that turn; keep them for the next real dispatch.
1947
2086
  this.dispatchState.pendingForkResults.unshift(...pendingFork);
1948
2087
  continue;
1949
2088
  }
@@ -2109,7 +2248,7 @@ export class ParallAgentGateway {
2109
2248
  if (this.usesLaneLedger(event)) {
2110
2249
  // Ledger flow: claim replaces mark-received; complete/reply replace
2111
2250
  // acks. A foreign incumbent leaves the event pending for re-drive.
2112
- let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed' = 'shutdown';
2251
+ let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed' | 'deferred' = 'shutdown';
2113
2252
  try {
2114
2253
  try {
2115
2254
  outcome = await this.dispatchLaneGroup({
@@ -2225,16 +2364,9 @@ export class ParallAgentGateway {
2225
2364
  if (
2226
2365
  !typedAheadInBuffer &&
2227
2366
  this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
2228
- this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
2229
- (await this.laneLedger?.steerLive(event)) &&
2230
- (await this.opts.dispatchAdapter.enqueueDuringDispatch(
2231
- this.opts.runtimeKey,
2232
- buildEventBody(event),
2233
- ))
2367
+ this.opts.dispatchAdapter.enqueueDuringDispatch != null
2234
2368
  ) {
2235
- this.opts.log?.info(
2236
- `steer folded+injected for ${event.messageId} (will drain for bookkeeping)`,
2237
- );
2369
+ await steerLaneMessage(this.laneFlowHost(), event);
2238
2370
  }
2239
2371
  } else if (
2240
2372
  // Message events only. A typed event (task_comment/schedule/…)
@@ -1,6 +1,12 @@
1
1
  import { ApiError } from '@parall/sdk';
2
2
  import type { ParallClient } from '@parall/sdk';
3
- import type { DispatchAdapter, GatewayLogger } from './dispatch-adapter.js';
3
+ import type {
4
+ DispatchAdapter,
5
+ DispatchInputLifecycle,
6
+ GatewayLogger,
7
+ SettledTurnOutcome,
8
+ } from './dispatch-adapter.js';
9
+ import { buildEventBody } from './event-format.js';
4
10
  import type { DispatchableMessage, MessageDispatchDecision } from './gateway-base.js';
5
11
  import { LedgerUnsupportedError } from './lane-ledger.js';
6
12
  import type { LaneLedger } from './lane-ledger.js';
@@ -26,6 +32,7 @@ export interface LaneFlowHost {
26
32
  shuttingDown: boolean;
27
33
  dispatchedMessages: Set<string>;
28
34
  dispatchedTasks: Set<string>;
35
+ dispatchState: { mainBuffer: ParallEvent[] };
29
36
  /**
30
37
  * Per-WorkItem failure backoff for typed dispatch consumption. A consume
31
38
  * that ends without an ack re-arms the entry; the next attempt for the
@@ -41,11 +48,13 @@ export interface LaneFlowHost {
41
48
  config: { org_id: string };
42
49
  dispatchAdapter: DispatchAdapter;
43
50
  agentUserId: string;
51
+ runtimeKey: string;
44
52
  };
45
53
  disableLedger(reason: string): void;
46
54
  usesLaneLedger(event: ParallEvent): boolean;
47
55
  emitDispatchReceived(event: ParallEvent): Promise<void>;
48
56
  consumeTurnError(sessionKey: string): boolean;
57
+ consumeTurnOutcome(sessionKey: string): SettledTurnOutcome | undefined;
49
58
  noteSessionLane(sessionKey: string, laneKey: string | null): void;
50
59
  runDispatch(
51
60
  event: ParallEvent,
@@ -53,6 +62,7 @@ export interface LaneFlowHost {
53
62
  bodyForAgent: string,
54
63
  earlierEvents?: ParallEvent[],
55
64
  captureText?: string[],
65
+ inputLifecycle?: DispatchInputLifecycle,
56
66
  ): Promise<boolean>;
57
67
  tryClaimMessage(id: string): boolean;
58
68
  buildMessageDispatchDecision(
@@ -62,6 +72,23 @@ export interface LaneFlowHost {
62
72
  handleInboundEvent(event: ParallEvent): Promise<boolean>;
63
73
  }
64
74
 
75
+ export async function steerLaneMessage(host: LaneFlowHost, event: ParallEvent): Promise<void> {
76
+ const { laneLedger: ledger, opts } = host;
77
+ const adapter = opts.dispatchAdapter;
78
+ if (!ledger || !adapter.enqueueDuringDispatch) return;
79
+ const folded = await ledger.steerLive(event);
80
+ if (
81
+ folded &&
82
+ (await adapter.enqueueDuringDispatch(
83
+ opts.runtimeKey,
84
+ buildEventBody(event),
85
+ folded.inputLifecycle,
86
+ ))
87
+ ) {
88
+ opts.log?.info(`steer folded+injected for ${event.messageId} (will drain for bookkeeping)`);
89
+ }
90
+ }
91
+
65
92
  /**
66
93
  * Dispatch one group of same-lane message events under the ledger contract:
67
94
  * claim (or reuse) the lane, run the dispatch, then complete when no local
@@ -78,7 +105,7 @@ export async function dispatchLaneGroup(
78
105
  captureText?: string[];
79
106
  hasMoreLocal: () => boolean;
80
107
  },
81
- ): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
108
+ ): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed' | 'deferred'> {
82
109
  const ledger = host.laneLedger!;
83
110
  const event = opts.events[opts.events.length - 1];
84
111
  let lane: Awaited<ReturnType<LaneLedger['ensureLane']>>;
@@ -121,12 +148,14 @@ export async function dispatchLaneGroup(
121
148
  host.noteSessionLane(opts.sessionKey, lane.laneKey);
122
149
  let dispatched = false;
123
150
  try {
151
+ const inputLifecycle = ledger.inputLifecycle(lane, [...opts.earlier, event]);
124
152
  dispatched = await host.runDispatch(
125
153
  event,
126
154
  opts.sessionKey,
127
155
  opts.body,
128
156
  opts.earlier,
129
157
  opts.captureText,
158
+ inputLifecycle,
130
159
  );
131
160
  } catch (err) {
132
161
  // Failed turn: hand the members back so the retry (this pod or the
@@ -141,20 +170,32 @@ export async function dispatchLaneGroup(
141
170
  // Shutdown short-circuit — shutdown() releases all active lanes.
142
171
  return 'shutdown';
143
172
  }
144
- if (host.consumeTurnError(opts.sessionKey)) {
145
- // An error turn must not no_action-sweep its members — settle the lane
146
- // NOW with an error complete so they release for retry on the redrive
147
- // budget (dispatch-convergence-design.md §3). Settling immediately (even
148
- // with same-lane work still buffered) is deliberate: carrying the error
149
- // across buffered turns would let a later reply broad-cover the failed
150
- // member, or requeue the later turn's successful work. Released members
151
- // rejoin the next claim, merged with whatever was buffered. Dropping the
152
- // local dedupe claims lets the server's re-drive hint retrigger the
153
- // messages immediately instead of waiting out the renotify pacing.
154
- ledger.markTurnError(lane.laneKey);
173
+ const settled = host.consumeTurnOutcome(opts.sessionKey);
174
+ if (settled) {
175
+ // A non-clean turn must not no_action-sweep its members settle the lane
176
+ // NOW (even with same-lane work still buffered; deliberate — carrying the
177
+ // outcome across buffered turns would let a later reply broad-cover the
178
+ // failed member, or requeue the later turn's successful work):
179
+ // - error error complete; members release for retry on the redrive
180
+ // budget (dispatch-convergence-design.md §3).
181
+ // - deferred (usage_limit) deferred complete; members re-deliver at
182
+ // retryAt WITHOUT burning redrive budget (agent-turn-outcome-design.md
183
+ // §6) — the runtime's LLM lane is limit-choked, so an instant retry
184
+ // would just burn a failed turn per redrive cycle.
185
+ // Released members rejoin the next claim, merged with whatever was
186
+ // buffered. Dropping the local dedupe claims lets the re-delivery
187
+ // retrigger the messages when it comes.
188
+ if (settled.kind === 'deferred') {
189
+ ledger.markTurnDeferred(lane.laneKey, {
190
+ outcomeClass: settled.outcomeClass,
191
+ ...(settled.retryAt ? { retryAt: settled.retryAt } : {}),
192
+ });
193
+ } else {
194
+ ledger.markTurnError(lane.laneKey);
195
+ }
155
196
  // Clear dedupe for EVERY lane member, not just this batch: an earlier
156
197
  // successful batch may have deferred its complete via hasMoreLocal, so
157
- // the error complete below releases those members too — their redrive
198
+ // the settlement below releases those members too — their re-delivery
158
199
  // would be permanently blocked by a stale local claim.
159
200
  for (const msgId of lane.folded.keys()) {
160
201
  host.dispatchedMessages.delete(msgId);
@@ -167,13 +208,14 @@ export async function dispatchLaneGroup(
167
208
  try {
168
209
  host.opts.dispatchAdapter.abortDispatch?.(opts.sessionKey);
169
210
  } catch {
170
- // best-effort — a throwing abort must not block the error settlement
211
+ // best-effort — a throwing abort must not block the settlement
171
212
  }
172
213
  await ledger.completeIfIdle(lane.laneKey, false);
173
- // 'failed' — callers must NOT record these events as handled: the server
174
- // just released them for redelivery, and an "already handled" fork prefix
175
- // (or a consumed fork summary) on the redrive would be a lie.
176
- return 'failed';
214
+ // Non-'dispatched' — callers must NOT record these events as handled: the
215
+ // server just released (or scheduled) them for redelivery, and an
216
+ // "already handled" fork prefix (or a consumed fork summary) on the
217
+ // redrive would be a lie.
218
+ return settled.kind === 'deferred' ? 'deferred' : 'failed';
177
219
  }
178
220
  const pendingInjections =
179
221
  host.opts.dispatchAdapter.hasPendingInjections?.(opts.sessionKey) ?? false;
@@ -552,6 +594,10 @@ export async function consumeMessageWorkItem(
552
594
  ): Promise<void> {
553
595
  if (host.shuttingDown) return;
554
596
  if (!host.tryClaimMessage(item.source_id)) return;
597
+ // A failed soft-steer releases the server row before its original local
598
+ // buffer copy drains. The immediate dispatch.new is only a wake-up: keep
599
+ // the newly reacquired local claim and let that one buffered copy retry.
600
+ if (host.dispatchState.mainBuffer.some((event) => event.messageId === item.source_id)) return;
555
601
  // Administrative drop (deleted source / self-sender / skip decision): on
556
602
  // the ledger path the by-id complete closes the pending row terminally and
557
603
  // refuses (409) a row a live lane owns — the owner's turn resolves it. The
package/src/index.ts CHANGED
@@ -9,8 +9,11 @@ export * from './event-format.js';
9
9
  export * from './prompt-fragments.js';
10
10
  export * from './bridge-workspace.js';
11
11
  export * from './dispatch-adapter.js';
12
+ export * from './redact.js';
12
13
  export { createLogger, childLogger } from './logger.js';
13
14
  export * from './gateway-base.js';
15
+ export { bindLaneSession } from './lane-ledger.js';
16
+ export type { ActiveLane } from './lane-ledger.js';
14
17
  export { configureHttpKeepAlive } from './http-keepalive.js';
15
18
  // StepPersister / StepRetryQueue / SessionLifecycleCoordinator /
16
19
  // ForkSessionFinalizer are internal gateway collaborators — deliberately NOT