@parall/agent-core 1.51.0 → 1.52.1
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/dispatch-adapter.d.ts +78 -1
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/gateway-base.d.ts +29 -7
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +145 -34
- package/dist/gateway-lane-flow.d.ts +9 -3
- package/dist/gateway-lane-flow.d.ts.map +1 -1
- package/dist/gateway-lane-flow.js +50 -18
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/lane-ledger.d.ts +66 -2
- package/dist/lane-ledger.d.ts.map +1 -1
- package/dist/lane-ledger.js +151 -8
- package/dist/prompt-fragments.d.ts +1 -1
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +16 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +40 -0
- package/dist/skills/parall-clips.d.ts +1 -1
- package/dist/skills/parall-clips.d.ts.map +1 -1
- package/dist/skills/parall-clips.js +45 -5
- package/dist/telemetry.d.ts +6 -4
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +77 -6
- package/package.json +2 -2
- package/src/dispatch-adapter.ts +96 -2
- package/src/gateway-base.ts +171 -39
- package/src/gateway-lane-flow.ts +65 -19
- package/src/index.ts +3 -0
- package/src/lane-ledger.ts +197 -9
- package/src/prompt-fragments.ts +16 -0
- package/src/redact.ts +46 -0
- package/src/skills/parall-clips.ts +45 -5
- package/src/telemetry.ts +70 -5
package/src/gateway-base.ts
CHANGED
|
@@ -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
|
-
/**
|
|
817
|
-
|
|
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
|
|
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
|
|
823
|
-
*
|
|
824
|
-
*
|
|
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.
|
|
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
|
-
: {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1593
|
-
|
|
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:
|
|
1735
|
-
// redelivery. Do NOT push them to
|
|
1736
|
-
// must not arrive wearing an
|
|
1737
|
-
// draining; the released work
|
|
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
|
|
1798
|
-
// the legacy (non-ledger) path left unconsumed, or the
|
|
1799
|
-
// every failed ephemeral fork.
|
|
1800
|
-
this.
|
|
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
|
|
1945
|
-
// redelivery. The fork results were
|
|
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.
|
|
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/…)
|
package/src/gateway-lane-flow.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { ApiError } from '@parall/sdk';
|
|
2
2
|
import type { ParallClient } from '@parall/sdk';
|
|
3
|
-
import type {
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
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
|
|
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
|
|
211
|
+
// best-effort — a throwing abort must not block the settlement
|
|
171
212
|
}
|
|
172
213
|
await ledger.completeIfIdle(lane.laneKey, false);
|
|
173
|
-
// '
|
|
174
|
-
// just released them for redelivery, and an
|
|
175
|
-
// (or a consumed fork summary) on the
|
|
176
|
-
|
|
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
|