@parall/agent-core 1.42.1 → 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.
- package/dist/bin/channel-exec.d.ts +4 -0
- package/dist/bin/channel-exec.d.ts.map +1 -0
- package/dist/bin/channel-exec.js +246 -0
- package/dist/channel-capability.d.ts +16 -0
- package/dist/channel-capability.d.ts.map +1 -0
- package/dist/channel-capability.js +155 -0
- package/dist/channel-token.d.ts +19 -0
- package/dist/channel-token.d.ts.map +1 -0
- package/dist/channel-token.js +73 -0
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +21 -16
- package/dist/gateway-base.d.ts +37 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +120 -12
- package/dist/gateway-lane-flow.d.ts +23 -2
- package/dist/gateway-lane-flow.d.ts.map +1 -1
- package/dist/gateway-lane-flow.js +118 -6
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lane-ledger.d.ts +22 -0
- package/dist/lane-ledger.d.ts.map +1 -1
- package/dist/lane-ledger.js +36 -1
- package/dist/platform-config.d.ts +15 -0
- package/dist/platform-config.d.ts.map +1 -1
- package/dist/platform-config.js +28 -0
- package/dist/prompt-fragments.d.ts +1 -1
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +29 -7
- package/dist/skills/index.js +1 -1
- package/dist/skills/parall-platform.d.ts +1 -1
- package/dist/skills/parall-platform.d.ts.map +1 -1
- package/dist/skills/parall-platform.js +23 -4
- package/dist/types.d.ts +5 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/bin/channel-exec.ts +262 -0
- package/src/channel-capability.ts +187 -0
- package/src/channel-token.ts +92 -0
- package/src/event-format.ts +21 -16
- package/src/gateway-base.ts +141 -19
- package/src/gateway-lane-flow.ts +132 -5
- package/src/index.ts +3 -0
- package/src/lane-ledger.ts +46 -3
- package/src/platform-config.ts +44 -0
- package/src/prompt-fragments.ts +29 -7
- package/src/skills/index.ts +1 -1
- package/src/skills/parall-platform.ts +23 -4
- package/src/types.ts +5 -1
package/src/gateway-base.ts
CHANGED
|
@@ -148,6 +148,14 @@ export type ParallGatewayOptions = {
|
|
|
148
148
|
* this directory. Absent → legacy received/ack flow (openclaw / hermes).
|
|
149
149
|
*/
|
|
150
150
|
dispatchContextDir?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Live view of the agent's platform-granted capability keys (bridges wire
|
|
153
|
+
* PlatformConfigManager.capabilities().map(c => c.key)). Read at
|
|
154
|
+
* channel-event build time so the reply hint routes to the capability
|
|
155
|
+
* affordance (e.g. feishu-cli → lark-cli) — the single outbound path.
|
|
156
|
+
* Absent/empty → the hint states outbound is disabled.
|
|
157
|
+
*/
|
|
158
|
+
getCapabilityKeys?: () => string[];
|
|
151
159
|
onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
|
|
152
160
|
onSessionReady?: (state: {
|
|
153
161
|
activeSessionId?: string;
|
|
@@ -293,6 +301,9 @@ export class ParallAgentGateway {
|
|
|
293
301
|
// (stable mapping; avoids one connection fetch per inbound message).
|
|
294
302
|
private readonly channelConnectionProviders = new Map<string, string>();
|
|
295
303
|
private readonly dispatchedMessages = new Set<string>();
|
|
304
|
+
// Per-WorkItem failure backoff for typed dispatch consumption — see
|
|
305
|
+
// LaneFlowHost.typedRedriveBackoff in gateway-lane-flow.ts.
|
|
306
|
+
readonly typedRedriveBackoff = new Map<string, { failures: number; until: number }>();
|
|
296
307
|
private readonly forkStates = new Map<string, ActiveForkState>();
|
|
297
308
|
private readonly dispatchState: DispatchState = {
|
|
298
309
|
mainDispatching: false,
|
|
@@ -448,8 +459,17 @@ export class ParallAgentGateway {
|
|
|
448
459
|
source_id: data.id,
|
|
449
460
|
})
|
|
450
461
|
.then(
|
|
451
|
-
() =>
|
|
452
|
-
() =>
|
|
462
|
+
() => true,
|
|
463
|
+
(err) => {
|
|
464
|
+
// Same contract as ackDispatchEvent: a failed ack is a
|
|
465
|
+
// failed consume (arms backoff) and must free the hot-path
|
|
466
|
+
// dedupe so the re-drive isn't rejected by this pod forever.
|
|
467
|
+
this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
|
|
468
|
+
this.opts.log?.warn(
|
|
469
|
+
`dispatch ack failed for task ${data.id}, releasing for re-drive: ${String(err)}`,
|
|
470
|
+
);
|
|
471
|
+
return false;
|
|
472
|
+
},
|
|
453
473
|
);
|
|
454
474
|
},
|
|
455
475
|
);
|
|
@@ -621,6 +641,45 @@ export class ParallAgentGateway {
|
|
|
621
641
|
return true;
|
|
622
642
|
}
|
|
623
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
|
+
|
|
624
683
|
private async emitDispatchReceived(event: ParallEvent): Promise<void> {
|
|
625
684
|
const sourceType = event.ackSourceType ?? (event.type === 'task' ? 'task_activity' : 'message');
|
|
626
685
|
const sourceId = event.ackSourceId ?? event.messageId;
|
|
@@ -673,24 +732,27 @@ export class ParallAgentGateway {
|
|
|
673
732
|
earlier: ParallEvent[];
|
|
674
733
|
captureText?: string[];
|
|
675
734
|
hasMoreLocal: () => boolean;
|
|
676
|
-
}): Promise<'dispatched' | 'foreign' | 'shutdown'> {
|
|
735
|
+
}): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
|
|
677
736
|
return dispatchLaneGroup(this.laneFlowHost(), opts);
|
|
678
737
|
}
|
|
679
738
|
|
|
680
739
|
private consumeTypedDispatch(
|
|
681
740
|
ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
|
|
682
741
|
run: (dispatchEventId?: string) => Promise<boolean>,
|
|
683
|
-
ack: (dispatchEventId?: string) => void | Promise<void>,
|
|
742
|
+
ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
|
|
684
743
|
): Promise<void> {
|
|
685
744
|
return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
|
|
686
745
|
}
|
|
687
746
|
|
|
688
747
|
// Typed completion must wait until the administrative ack has either
|
|
689
748
|
// committed or failed. Errors stay best-effort: a failed ack leaves the row
|
|
690
|
-
// received, so Complete releases and re-drives it safely.
|
|
691
|
-
|
|
749
|
+
// received, so Complete releases and re-drives it safely. The boolean
|
|
750
|
+
// outcome feeds the typed-consume backoff — an ack that failed must count
|
|
751
|
+
// as a failed consume, or an ack outage would clear the backoff entry and
|
|
752
|
+
// let the release re-drive spin at wire speed.
|
|
753
|
+
private ackDispatchEvent(dispatchEventId: string, onFailure?: () => void): Promise<boolean> {
|
|
692
754
|
return this.opts.client.ackDispatchByID(this.opts.config.org_id, dispatchEventId).then(
|
|
693
|
-
() =>
|
|
755
|
+
() => true,
|
|
694
756
|
(err) => {
|
|
695
757
|
// Complete will return the still-received item to pending and publish
|
|
696
758
|
// an immediate hint. Free its hot-path claim first, otherwise that
|
|
@@ -699,6 +761,7 @@ export class ParallAgentGateway {
|
|
|
699
761
|
this.opts.log?.warn(
|
|
700
762
|
`dispatch ack failed for ${dispatchEventId}, releasing for re-drive: ${String(err)}`,
|
|
701
763
|
);
|
|
764
|
+
return false;
|
|
702
765
|
},
|
|
703
766
|
);
|
|
704
767
|
}
|
|
@@ -1113,6 +1176,7 @@ export class ParallAgentGateway {
|
|
|
1113
1176
|
}
|
|
1114
1177
|
|
|
1115
1178
|
resetDispatchMetrics(sessionKey);
|
|
1179
|
+
this.turnErrorSessions.delete(sessionKey);
|
|
1116
1180
|
return runWithSessionKey(sessionKey, async () => {
|
|
1117
1181
|
let dispatchSpan: ReturnType<typeof startDispatchSpan> = null;
|
|
1118
1182
|
|
|
@@ -1275,6 +1339,9 @@ export class ParallAgentGateway {
|
|
|
1275
1339
|
) {
|
|
1276
1340
|
recordMessageSend(sessionKey, !runtimeEvent.error);
|
|
1277
1341
|
}
|
|
1342
|
+
if (runtimeEvent.type === 'error') {
|
|
1343
|
+
this.turnErrorSessions.add(sessionKey);
|
|
1344
|
+
}
|
|
1278
1345
|
await this.createRuntimeStep(
|
|
1279
1346
|
binding.agentSessionId,
|
|
1280
1347
|
event,
|
|
@@ -1471,13 +1538,29 @@ export class ParallAgentGateway {
|
|
|
1471
1538
|
body: buildForkScopePrefix(last) + buildEventBody(last),
|
|
1472
1539
|
earlier,
|
|
1473
1540
|
captureText: batchText,
|
|
1474
|
-
|
|
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
|
+
),
|
|
1475
1550
|
});
|
|
1476
1551
|
if (outcome === 'foreign') {
|
|
1477
1552
|
// Another pod owns the lane — the events stay pending server-side.
|
|
1478
1553
|
for (const item of items) item.resolve(false);
|
|
1479
1554
|
break;
|
|
1480
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
|
+
}
|
|
1481
1564
|
dispatched = outcome === 'dispatched';
|
|
1482
1565
|
} else {
|
|
1483
1566
|
dispatched = await this.runDispatch(
|
|
@@ -1518,6 +1601,10 @@ export class ParallAgentGateway {
|
|
|
1518
1601
|
remaining.resolve(false);
|
|
1519
1602
|
}
|
|
1520
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);
|
|
1521
1608
|
if (fork.deadlineTimer) {
|
|
1522
1609
|
clearTimeout(fork.deadlineTimer);
|
|
1523
1610
|
fork.deadlineTimer = null;
|
|
@@ -1630,7 +1717,7 @@ export class ParallAgentGateway {
|
|
|
1630
1717
|
this.opts.runtimeKey,
|
|
1631
1718
|
);
|
|
1632
1719
|
if (this.usesLaneLedger(event)) {
|
|
1633
|
-
let outcome: 'dispatched' | 'foreign' | 'shutdown';
|
|
1720
|
+
let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed';
|
|
1634
1721
|
try {
|
|
1635
1722
|
outcome = await this.dispatchLaneGroup({
|
|
1636
1723
|
events,
|
|
@@ -1657,6 +1744,13 @@ export class ParallAgentGateway {
|
|
|
1657
1744
|
this.dispatchState.pendingForkResults.unshift(...pendingFork);
|
|
1658
1745
|
continue;
|
|
1659
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
|
+
}
|
|
1660
1754
|
// Dispatched — resolution happened server-side (reply cover or
|
|
1661
1755
|
// no_action sweep); no legacy acks.
|
|
1662
1756
|
continue;
|
|
@@ -1745,7 +1839,7 @@ export class ParallAgentGateway {
|
|
|
1745
1839
|
if (this.usesLaneLedger(event)) {
|
|
1746
1840
|
// Ledger flow: claim replaces mark-received; complete/reply replace
|
|
1747
1841
|
// acks. A foreign incumbent leaves the event pending for re-drive.
|
|
1748
|
-
let outcome: 'dispatched' | 'foreign' | 'shutdown' = 'shutdown';
|
|
1842
|
+
let outcome: 'dispatched' | 'foreign' | 'shutdown' | 'failed' = 'shutdown';
|
|
1749
1843
|
try {
|
|
1750
1844
|
try {
|
|
1751
1845
|
outcome = await this.dispatchLaneGroup({
|
|
@@ -1816,10 +1910,17 @@ export class ParallAgentGateway {
|
|
|
1816
1910
|
// fold leaves the event buffered — the drain claims it as its own
|
|
1817
1911
|
// turn. Injection requires an exact lane match (same chat AND same
|
|
1818
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.
|
|
1819
1919
|
if (
|
|
1820
1920
|
this.mainCurrentGroupKey === this.dispatchGroupKey(event) &&
|
|
1921
|
+
this.opts.dispatchAdapter.enqueueDuringDispatch != null &&
|
|
1821
1922
|
(await this.laneLedger?.steerLive(event)) &&
|
|
1822
|
-
(await this.opts.dispatchAdapter.enqueueDuringDispatch
|
|
1923
|
+
(await this.opts.dispatchAdapter.enqueueDuringDispatch(
|
|
1823
1924
|
this.opts.runtimeKey,
|
|
1824
1925
|
buildEventBody(event),
|
|
1825
1926
|
))
|
|
@@ -1868,14 +1969,21 @@ export class ParallAgentGateway {
|
|
|
1868
1969
|
return false;
|
|
1869
1970
|
}
|
|
1870
1971
|
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
+
}
|
|
1879
1987
|
}
|
|
1880
1988
|
const fork = await this.opts.dispatchAdapter.forkSession({
|
|
1881
1989
|
sessionKey: this.opts.runtimeKey,
|
|
@@ -2471,6 +2579,19 @@ export class ParallAgentGateway {
|
|
|
2471
2579
|
}
|
|
2472
2580
|
}
|
|
2473
2581
|
|
|
2582
|
+
// The reply hint routes on the live capability grant: `<provider>-cli`
|
|
2583
|
+
// present → the vendor CLI is on PATH (broker shim) and is THE reply
|
|
2584
|
+
// path; absent → outbound is disabled for this org (flag/connection off)
|
|
2585
|
+
// and the hint must say so instead of pointing at a retired clip. The
|
|
2586
|
+
// provider label lookup above is best-effort/cosmetic — when it fails,
|
|
2587
|
+
// ANY granted `*-cli` capability keeps the hint on the CLI path: a
|
|
2588
|
+
// transient metadata miss must not flip an actively granted agent's
|
|
2589
|
+
// hint to "outbound disabled" and strand a valid external message.
|
|
2590
|
+
const keys = this.opts.getCapabilityKeys?.() ?? [];
|
|
2591
|
+
const cliCapable = provider
|
|
2592
|
+
? keys.includes(`${provider}-cli`)
|
|
2593
|
+
: keys.some((k) => k.endsWith('-cli'));
|
|
2594
|
+
|
|
2474
2595
|
const event: ParallEvent = {
|
|
2475
2596
|
type: 'channel_message',
|
|
2476
2597
|
targetId: conv.id,
|
|
@@ -2485,6 +2606,7 @@ export class ParallAgentGateway {
|
|
|
2485
2606
|
channelConversationType: conv.conversation_type || undefined,
|
|
2486
2607
|
channelExternalConversationId: conv.external_conversation_id,
|
|
2487
2608
|
channelExternalMessageId: msg.external_message_id,
|
|
2609
|
+
channelCliCapable: cliCapable,
|
|
2488
2610
|
ackSourceType: 'channel_message',
|
|
2489
2611
|
ackSourceId: msg.id,
|
|
2490
2612
|
};
|
package/src/gateway-lane-flow.ts
CHANGED
|
@@ -18,6 +18,15 @@ export interface LaneFlowHost {
|
|
|
18
18
|
ledgerDisabled: boolean;
|
|
19
19
|
shuttingDown: boolean;
|
|
20
20
|
dispatchedMessages: Set<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Per-WorkItem failure backoff for typed dispatch consumption. A consume
|
|
23
|
+
* that ends without an ack re-arms the entry; the next attempt for the
|
|
24
|
+
* same WorkItem is delayed (not skipped — a skipped attempt would strand
|
|
25
|
+
* the pending row until reconnect catch-up) so a claim→fail→complete
|
|
26
|
+
* tight loop is throttled to exponential intervals instead of spinning at
|
|
27
|
+
* wire speed against the server's immediate re-drive (2026-07-11 OOM).
|
|
28
|
+
*/
|
|
29
|
+
typedRedriveBackoff: Map<string, { failures: number; until: number }>;
|
|
21
30
|
opts: {
|
|
22
31
|
client: ParallClient;
|
|
23
32
|
log?: GatewayLogger;
|
|
@@ -28,6 +37,8 @@ export interface LaneFlowHost {
|
|
|
28
37
|
disableLedger(reason: string): void;
|
|
29
38
|
usesLaneLedger(event: ParallEvent): boolean;
|
|
30
39
|
emitDispatchReceived(event: ParallEvent): Promise<void>;
|
|
40
|
+
consumeTurnError(sessionKey: string): boolean;
|
|
41
|
+
noteSessionLane(sessionKey: string, laneKey: string | null): void;
|
|
31
42
|
runDispatch(
|
|
32
43
|
event: ParallEvent,
|
|
33
44
|
sessionKey: string,
|
|
@@ -59,7 +70,7 @@ export async function dispatchLaneGroup(
|
|
|
59
70
|
captureText?: string[];
|
|
60
71
|
hasMoreLocal: () => boolean;
|
|
61
72
|
},
|
|
62
|
-
): Promise<'dispatched' | 'foreign' | 'shutdown'> {
|
|
73
|
+
): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
|
|
63
74
|
const ledger = host.laneLedger!;
|
|
64
75
|
const event = opts.events[opts.events.length - 1];
|
|
65
76
|
let lane: Awaited<ReturnType<LaneLedger['ensureLane']>>;
|
|
@@ -97,6 +108,9 @@ export async function dispatchLaneGroup(
|
|
|
97
108
|
}
|
|
98
109
|
return 'foreign';
|
|
99
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);
|
|
100
114
|
let dispatched = false;
|
|
101
115
|
try {
|
|
102
116
|
dispatched = await host.runDispatch(
|
|
@@ -109,34 +123,141 @@ export async function dispatchLaneGroup(
|
|
|
109
123
|
} catch (err) {
|
|
110
124
|
// Failed turn: hand the members back so the retry (this pod or the
|
|
111
125
|
// next) re-claims immediately instead of waiting out the lease.
|
|
126
|
+
host.noteSessionLane(opts.sessionKey, null);
|
|
112
127
|
await ledger.release(lane.laneKey).catch(() => {});
|
|
113
128
|
throw err;
|
|
129
|
+
} finally {
|
|
130
|
+
if (dispatched) host.noteSessionLane(opts.sessionKey, null);
|
|
114
131
|
}
|
|
115
132
|
if (!dispatched) {
|
|
116
133
|
// Shutdown short-circuit — shutdown() releases all active lanes.
|
|
117
134
|
return 'shutdown';
|
|
118
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
|
+
}
|
|
119
170
|
const pendingInjections =
|
|
120
171
|
host.opts.dispatchAdapter.hasPendingInjections?.(opts.sessionKey) ?? false;
|
|
121
172
|
await ledger.completeIfIdle(lane.laneKey, pendingInjections || opts.hasMoreLocal());
|
|
122
173
|
return 'dispatched';
|
|
123
174
|
}
|
|
124
175
|
|
|
176
|
+
/** Failure backoff pacing for typed dispatch retries (base 2s, cap 5min). */
|
|
177
|
+
const TYPED_BACKOFF_BASE_MS = 2_000;
|
|
178
|
+
const TYPED_BACKOFF_CAP_MS = 5 * 60_000;
|
|
179
|
+
const TYPED_BACKOFF_MAP_CAP = 512;
|
|
180
|
+
|
|
125
181
|
/**
|
|
126
182
|
* Consume one typed dispatch (task/comment/schedule/trigger/approval) under
|
|
127
183
|
* its typed-lane occupancy guard: claim the dsp:<id> lane (skip when another
|
|
128
184
|
* pod holds it or the WorkItem is already resolved), run the handler, ack on
|
|
129
185
|
* success (the doc's option (b): notification delivered, tracked elsewhere),
|
|
130
186
|
* then release the lane. Legacy run+ack flow when the ledger is unavailable.
|
|
187
|
+
*
|
|
188
|
+
* Repeated failures back off: a consume that ends un-acked re-arms the
|
|
189
|
+
* WorkItem's backoff entry, and the next attempt sleeps out the remaining
|
|
190
|
+
* window before claiming. Without this, complete's release re-drives the
|
|
191
|
+
* item instantly and a persistently-failing consume (e.g. buffered behind a
|
|
192
|
+
* saturated fork pool) spins at wire speed — the client half of the
|
|
193
|
+
* 2026-07-11 poison loop (the server half is the redrive budget).
|
|
131
194
|
*/
|
|
132
195
|
export async function consumeTypedDispatch(
|
|
133
196
|
host: LaneFlowHost,
|
|
134
197
|
ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
|
|
135
198
|
run: (dispatchEventId?: string) => Promise<boolean>,
|
|
136
|
-
ack: (dispatchEventId?: string) => void | Promise<void>,
|
|
199
|
+
ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
|
|
137
200
|
): Promise<void> {
|
|
201
|
+
const backoffKey = ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`;
|
|
202
|
+
const armed = host.typedRedriveBackoff.get(backoffKey);
|
|
203
|
+
if (armed) {
|
|
204
|
+
const waitMs = armed.until - Date.now();
|
|
205
|
+
if (waitMs > 0) {
|
|
206
|
+
host.opts.log?.info(
|
|
207
|
+
`typed dispatch ${backoffKey} backing off ${Math.ceil(waitMs / 1000)}s after ${armed.failures} failed consume(s)`,
|
|
208
|
+
);
|
|
209
|
+
// unref: the wait must never be what keeps the process alive — on
|
|
210
|
+
// SIGTERM the gateway drains and exits while this timer is pending
|
|
211
|
+
// (the re-drive/renotify path re-delivers on the next pod).
|
|
212
|
+
await new Promise<void>((resolve) => {
|
|
213
|
+
const timer = setTimeout(resolve, waitMs);
|
|
214
|
+
timer.unref?.();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (host.shuttingDown) return;
|
|
218
|
+
}
|
|
219
|
+
// An ack callback that returns void (legacy custom acks) counts as success;
|
|
220
|
+
// an explicit false (ackDispatchEvent's failed HTTP ack) is a failed
|
|
221
|
+
// consume — clearing backoff there would let an ack outage re-create the
|
|
222
|
+
// wire-speed release/re-drive loop against a pre-budget server.
|
|
223
|
+
const settleAck = (ackResult: boolean | void) => ackResult !== false;
|
|
224
|
+
const settle = (acked: boolean) => {
|
|
225
|
+
if (acked) {
|
|
226
|
+
host.typedRedriveBackoff.delete(backoffKey);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const failures = (host.typedRedriveBackoff.get(backoffKey)?.failures ?? 0) + 1;
|
|
230
|
+
const backoffMs = Math.min(TYPED_BACKOFF_CAP_MS, TYPED_BACKOFF_BASE_MS * 2 ** (failures - 1));
|
|
231
|
+
// True LRU: delete-then-set moves an updated key to the tail of the
|
|
232
|
+
// Map's insertion order, so capacity eviction always removes the
|
|
233
|
+
// least-recently-FAILING key — an actively-failing old key must not be
|
|
234
|
+
// evicted ahead of a quieter newer one. Evict only when inserting a new
|
|
235
|
+
// key at capacity (an in-place update never shrinks the map).
|
|
236
|
+
if (
|
|
237
|
+
host.typedRedriveBackoff.delete(backoffKey) === false &&
|
|
238
|
+
host.typedRedriveBackoff.size >= TYPED_BACKOFF_MAP_CAP
|
|
239
|
+
) {
|
|
240
|
+
const oldest = host.typedRedriveBackoff.keys().next().value;
|
|
241
|
+
if (oldest !== undefined) host.typedRedriveBackoff.delete(oldest);
|
|
242
|
+
}
|
|
243
|
+
host.typedRedriveBackoff.set(backoffKey, { failures, until: Date.now() + backoffMs });
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Legacy run+ack (no ledger): same settle-in-finally contract as the lane
|
|
247
|
+
// path — a thrown run/ack must arm backoff, not skip it.
|
|
248
|
+
const runLegacy = async () => {
|
|
249
|
+
let acked = false;
|
|
250
|
+
try {
|
|
251
|
+
if (await run(ref.dispatchEventId)) {
|
|
252
|
+
acked = settleAck(await ack(ref.dispatchEventId));
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
settle(acked);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
138
259
|
if (!host.laneLedger || host.ledgerDisabled) {
|
|
139
|
-
|
|
260
|
+
await runLegacy();
|
|
140
261
|
return;
|
|
141
262
|
}
|
|
142
263
|
let lane: Awaited<ReturnType<LaneLedger['claimTyped']>>;
|
|
@@ -145,23 +266,29 @@ export async function consumeTypedDispatch(
|
|
|
145
266
|
} catch (err) {
|
|
146
267
|
if (err instanceof LedgerUnsupportedError) {
|
|
147
268
|
host.disableLedger('claim endpoint missing');
|
|
148
|
-
|
|
269
|
+
await runLegacy();
|
|
149
270
|
return;
|
|
150
271
|
}
|
|
151
272
|
throw err;
|
|
152
273
|
}
|
|
153
274
|
if (!lane) {
|
|
275
|
+
// Held elsewhere or already resolved — not a local failure; leave the
|
|
276
|
+
// backoff state as-is (a stale entry is cleared by the next local ack).
|
|
154
277
|
host.opts.log?.info(
|
|
155
278
|
`typed dispatch ${ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`} not claimable (held elsewhere or already resolved) — skipping`,
|
|
156
279
|
);
|
|
157
280
|
return;
|
|
158
281
|
}
|
|
282
|
+
let acked = false;
|
|
159
283
|
try {
|
|
160
284
|
// Ack must settle before Complete. A fire-and-forget ack races the lane
|
|
161
285
|
// release: Complete can return the still-received typed item to pending
|
|
162
286
|
// and publish a re-drive while its successful ack is still in flight.
|
|
163
|
-
if (await run(lane.typedDispatchEventId))
|
|
287
|
+
if (await run(lane.typedDispatchEventId)) {
|
|
288
|
+
acked = settleAck(await ack(lane.typedDispatchEventId));
|
|
289
|
+
}
|
|
164
290
|
} finally {
|
|
291
|
+
settle(acked);
|
|
165
292
|
// Release the occupancy row. A buffered dispatch may outlive this guard
|
|
166
293
|
// (lane TTL) — acceptable at-least-once; the ledger's resolution paths
|
|
167
294
|
// still dedupe the persistent side effects.
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './provider-config.js';
|
|
|
2
2
|
export * from './types.js';
|
|
3
3
|
export * from './lane-key.js';
|
|
4
4
|
export type { LaneFlowHost } from './gateway-lane-flow.js';
|
|
5
|
+
export { consumeTypedDispatch } from './gateway-lane-flow.js';
|
|
5
6
|
export * from './session-state.js';
|
|
6
7
|
export * from './routing.js';
|
|
7
8
|
export * from './event-format.js';
|
|
@@ -11,6 +12,8 @@ export * from './dispatch-adapter.js';
|
|
|
11
12
|
export { createLogger, childLogger } from './logger.js';
|
|
12
13
|
export * from './gateway-base.js';
|
|
13
14
|
export * from './platform-config.js';
|
|
15
|
+
export * from './channel-capability.js';
|
|
16
|
+
export * from './channel-token.js';
|
|
14
17
|
export { writeSkillFiles, buildSkillReferences, SKILLS } from './skills/index.js';
|
|
15
18
|
export type { SkillMeta } from './skills/index.js';
|
|
16
19
|
export {
|
package/src/lane-ledger.ts
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
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
|
package/src/platform-config.ts
CHANGED
|
@@ -17,6 +17,46 @@ export interface PlatformConfigManager {
|
|
|
17
17
|
fetch(): Promise<PlatformDefaults>;
|
|
18
18
|
current(): PlatformDefaults;
|
|
19
19
|
rawConfig(): Record<string, unknown> | null;
|
|
20
|
+
/**
|
|
21
|
+
* Channel capabilities delivered in `agents.capabilities[]` — the
|
|
22
|
+
* declaration plane of the channel-capability broker. Reads the current
|
|
23
|
+
* (possibly LKG-cached) config with NO freshness gate: the credential is
|
|
24
|
+
* pull-at-use (the mint endpoint re-evaluates the grant on every call), so
|
|
25
|
+
* a stale declaration fail-closes there with a self-explanatory 403 — like
|
|
26
|
+
* a stale model, it is suboptimal, never dangerous.
|
|
27
|
+
*/
|
|
28
|
+
capabilities(): AgentCapability[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// One platform-granted capability (agents.capabilities[] in platform-config).
|
|
32
|
+
// `fragment` is the complete system-prompt declaration text; the server is
|
|
33
|
+
// the SSOT for channel knowledge, runtimes just splice it in.
|
|
34
|
+
export interface AgentCapability {
|
|
35
|
+
key: string;
|
|
36
|
+
source: string;
|
|
37
|
+
fragment: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Defensive extraction: entries missing a non-empty string key/fragment are
|
|
41
|
+
// skipped so a malformed or future-shaped payload can never inject a blank
|
|
42
|
+
// declaration into a system prompt.
|
|
43
|
+
export function extractCapabilities(config: Record<string, unknown>): AgentCapability[] {
|
|
44
|
+
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
|
45
|
+
const raw = agents.capabilities;
|
|
46
|
+
if (!Array.isArray(raw)) return [];
|
|
47
|
+
const out: AgentCapability[] = [];
|
|
48
|
+
for (const entry of raw) {
|
|
49
|
+
if (typeof entry !== 'object' || entry === null) continue;
|
|
50
|
+
const e = entry as Record<string, unknown>;
|
|
51
|
+
if (typeof e.key !== 'string' || !e.key) continue;
|
|
52
|
+
if (typeof e.fragment !== 'string' || !e.fragment) continue;
|
|
53
|
+
out.push({
|
|
54
|
+
key: e.key,
|
|
55
|
+
source: typeof e.source === 'string' ? e.source : '',
|
|
56
|
+
fragment: e.fragment,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
20
60
|
}
|
|
21
61
|
|
|
22
62
|
export interface PlatformManagementProfile {
|
|
@@ -276,5 +316,9 @@ export function createPlatformConfigManager(opts: {
|
|
|
276
316
|
rawConfig(): Record<string, unknown> | null {
|
|
277
317
|
return currentRawConfig;
|
|
278
318
|
},
|
|
319
|
+
|
|
320
|
+
capabilities(): AgentCapability[] {
|
|
321
|
+
return currentRawConfig ? extractCapabilities(currentRawConfig) : [];
|
|
322
|
+
},
|
|
279
323
|
};
|
|
280
324
|
}
|