@parall/agent-core 1.37.0 → 1.38.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/dispatch-adapter.d.ts +6 -0
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/gateway-base.d.ts +54 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +331 -95
- package/dist/gateway-lane-flow.d.ts +74 -0
- package/dist/gateway-lane-flow.d.ts.map +1 -0
- package/dist/gateway-lane-flow.js +167 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/lane-key.d.ts +45 -0
- package/dist/lane-key.d.ts.map +1 -0
- package/dist/lane-key.js +34 -0
- package/dist/lane-ledger.d.ts +112 -0
- package/dist/lane-ledger.d.ts.map +1 -0
- package/dist/lane-ledger.js +333 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/dispatch-adapter.ts +6 -0
- package/src/gateway-base.ts +493 -142
- package/src/gateway-lane-flow.ts +235 -0
- package/src/index.ts +2 -0
- package/src/lane-key.ts +67 -0
- package/src/lane-ledger.ts +370 -0
- package/src/types.ts +2 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { ParallClient } from '@parall/sdk';
|
|
2
|
+
import type { DispatchAdapter, GatewayLogger } from './dispatch-adapter.js';
|
|
3
|
+
import type { DispatchableMessage, MessageDispatchDecision } from './gateway-base.js';
|
|
4
|
+
import { LedgerUnsupportedError } from './lane-ledger.js';
|
|
5
|
+
import type { LaneLedger } from './lane-ledger.js';
|
|
6
|
+
import type { ParallEvent } from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lane-flow layer: the ledger-aware consumption protocols that sit between
|
|
10
|
+
* inbound routing and `runDispatch`. Split out of gateway-base.ts (already an
|
|
11
|
+
* oversized outlier) — the gateway keeps thin delegating methods so tests and
|
|
12
|
+
* call sites stay on the class surface.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The slice of ParallAgentGateway the lane-flow functions operate on. */
|
|
16
|
+
export interface LaneFlowHost {
|
|
17
|
+
laneLedger?: LaneLedger;
|
|
18
|
+
ledgerDisabled: boolean;
|
|
19
|
+
shuttingDown: boolean;
|
|
20
|
+
dispatchedMessages: Set<string>;
|
|
21
|
+
opts: {
|
|
22
|
+
client: ParallClient;
|
|
23
|
+
log?: GatewayLogger;
|
|
24
|
+
config: { org_id: string };
|
|
25
|
+
dispatchAdapter: DispatchAdapter;
|
|
26
|
+
agentUserId: string;
|
|
27
|
+
};
|
|
28
|
+
disableLedger(reason: string): void;
|
|
29
|
+
usesLaneLedger(event: ParallEvent): boolean;
|
|
30
|
+
emitDispatchReceived(event: ParallEvent): Promise<void>;
|
|
31
|
+
runDispatch(
|
|
32
|
+
event: ParallEvent,
|
|
33
|
+
sessionKey: string,
|
|
34
|
+
bodyForAgent: string,
|
|
35
|
+
earlierEvents?: ParallEvent[],
|
|
36
|
+
captureText?: string[],
|
|
37
|
+
): Promise<boolean>;
|
|
38
|
+
tryClaimMessage(id: string): boolean;
|
|
39
|
+
buildMessageDispatchDecision(
|
|
40
|
+
chatId: string,
|
|
41
|
+
message: DispatchableMessage,
|
|
42
|
+
): Promise<MessageDispatchDecision>;
|
|
43
|
+
handleInboundEvent(event: ParallEvent): Promise<boolean>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Dispatch one group of same-lane message events under the ledger contract:
|
|
48
|
+
* claim (or reuse) the lane, run the dispatch, then complete when no local
|
|
49
|
+
* work remains for the lane. No legacy received/ack calls — the claim marks
|
|
50
|
+
* members received and complete/reply resolve them server-side.
|
|
51
|
+
*/
|
|
52
|
+
export async function dispatchLaneGroup(
|
|
53
|
+
host: LaneFlowHost,
|
|
54
|
+
opts: {
|
|
55
|
+
events: ParallEvent[];
|
|
56
|
+
sessionKey: string;
|
|
57
|
+
body: string;
|
|
58
|
+
earlier: ParallEvent[];
|
|
59
|
+
captureText?: string[];
|
|
60
|
+
hasMoreLocal: () => boolean;
|
|
61
|
+
},
|
|
62
|
+
): Promise<'dispatched' | 'foreign' | 'shutdown'> {
|
|
63
|
+
const ledger = host.laneLedger!;
|
|
64
|
+
const event = opts.events[opts.events.length - 1];
|
|
65
|
+
let lane: Awaited<ReturnType<LaneLedger['ensureLane']>>;
|
|
66
|
+
try {
|
|
67
|
+
lane = await ledger.ensureLane(opts.events);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (!(err instanceof LedgerUnsupportedError)) throw err;
|
|
70
|
+
// Old server: run this group through the legacy flow inline and let
|
|
71
|
+
// every later event take the legacy branch via the sticky flag.
|
|
72
|
+
host.disableLedger('claim endpoint missing');
|
|
73
|
+
await host.emitDispatchReceived(event);
|
|
74
|
+
const dispatched = await host.runDispatch(
|
|
75
|
+
event,
|
|
76
|
+
opts.sessionKey,
|
|
77
|
+
opts.body,
|
|
78
|
+
opts.earlier,
|
|
79
|
+
opts.captureText,
|
|
80
|
+
);
|
|
81
|
+
if (!dispatched) return 'shutdown';
|
|
82
|
+
for (const ev of opts.events) {
|
|
83
|
+
host.opts.client
|
|
84
|
+
.ackDispatch(host.opts.config.org_id, {
|
|
85
|
+
source_type: ev.ackSourceType ?? 'message',
|
|
86
|
+
source_id: ev.ackSourceId ?? ev.messageId,
|
|
87
|
+
})
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
}
|
|
90
|
+
return 'dispatched';
|
|
91
|
+
}
|
|
92
|
+
if (!lane) {
|
|
93
|
+
// A healthy incumbent owns the resource. Free the in-memory dedupe
|
|
94
|
+
// entries so the post-complete re-drive can re-trigger these messages.
|
|
95
|
+
for (const ev of opts.events) {
|
|
96
|
+
host.dispatchedMessages.delete(ev.messageId);
|
|
97
|
+
}
|
|
98
|
+
return 'foreign';
|
|
99
|
+
}
|
|
100
|
+
let dispatched = false;
|
|
101
|
+
try {
|
|
102
|
+
dispatched = await host.runDispatch(
|
|
103
|
+
event,
|
|
104
|
+
opts.sessionKey,
|
|
105
|
+
opts.body,
|
|
106
|
+
opts.earlier,
|
|
107
|
+
opts.captureText,
|
|
108
|
+
);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// Failed turn: hand the members back so the retry (this pod or the
|
|
111
|
+
// next) re-claims immediately instead of waiting out the lease.
|
|
112
|
+
await ledger.release(lane.laneKey).catch(() => {});
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
if (!dispatched) {
|
|
116
|
+
// Shutdown short-circuit — shutdown() releases all active lanes.
|
|
117
|
+
return 'shutdown';
|
|
118
|
+
}
|
|
119
|
+
const pendingInjections =
|
|
120
|
+
host.opts.dispatchAdapter.hasPendingInjections?.(opts.sessionKey) ?? false;
|
|
121
|
+
await ledger.completeIfIdle(lane.laneKey, pendingInjections || opts.hasMoreLocal());
|
|
122
|
+
return 'dispatched';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Consume one typed dispatch (task/comment/schedule/trigger/approval) under
|
|
127
|
+
* its typed-lane occupancy guard: claim the dsp:<id> lane (skip when another
|
|
128
|
+
* pod holds it or the WorkItem is already resolved), run the handler, ack on
|
|
129
|
+
* success (the doc's option (b): notification delivered, tracked elsewhere),
|
|
130
|
+
* then release the lane. Legacy run+ack flow when the ledger is unavailable.
|
|
131
|
+
*/
|
|
132
|
+
export async function consumeTypedDispatch(
|
|
133
|
+
host: LaneFlowHost,
|
|
134
|
+
ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
|
|
135
|
+
run: (dispatchEventId?: string) => Promise<boolean>,
|
|
136
|
+
ack: (dispatchEventId?: string) => void,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
if (!host.laneLedger || host.ledgerDisabled) {
|
|
139
|
+
if (await run(ref.dispatchEventId)) ack(ref.dispatchEventId);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let lane: Awaited<ReturnType<LaneLedger['claimTyped']>>;
|
|
143
|
+
try {
|
|
144
|
+
lane = await host.laneLedger.claimTyped(ref);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err instanceof LedgerUnsupportedError) {
|
|
147
|
+
host.disableLedger('claim endpoint missing');
|
|
148
|
+
if (await run(ref.dispatchEventId)) ack(ref.dispatchEventId);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
if (!lane) {
|
|
154
|
+
host.opts.log?.info(
|
|
155
|
+
`typed dispatch ${ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`} not claimable (held elsewhere or already resolved) — skipping`,
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
if (await run(lane.typedDispatchEventId)) ack(lane.typedDispatchEventId);
|
|
161
|
+
} finally {
|
|
162
|
+
// Release the occupancy row. A buffered dispatch may outlive this guard
|
|
163
|
+
// (lane TTL) — acceptable at-least-once; the ledger's resolution paths
|
|
164
|
+
// still dedupe the persistent side effects.
|
|
165
|
+
await host.laneLedger.completeIfIdle(lane.laneKey, false).catch(() => {});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Shared consumption of a message WorkItem referenced by id — the ONE
|
|
171
|
+
* protocol for dispatch catch-up pages and post-complete re-drive hints
|
|
172
|
+
* (live first delivery stays on the message.new handler, which already has
|
|
173
|
+
* the payload). Owns: in-memory dedupe, source fetch (deleted source ⇒
|
|
174
|
+
* administrative ack), self-sender ack, decision routing, ledger-vs-legacy
|
|
175
|
+
* ack parity, and dedupe-entry cleanup on every non-dispatched path.
|
|
176
|
+
*/
|
|
177
|
+
export async function consumeMessageWorkItem(
|
|
178
|
+
host: LaneFlowHost,
|
|
179
|
+
item: { id: string; source_id: string; chat_id: string },
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
if (host.shuttingDown) return;
|
|
182
|
+
if (!host.tryClaimMessage(item.source_id)) return;
|
|
183
|
+
const ackItem = () => {
|
|
184
|
+
host.opts.client.ackDispatchByID(host.opts.config.org_id, item.id).catch(() => {});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
let msg: Awaited<ReturnType<ParallClient['getMessage']>> | null = null;
|
|
188
|
+
try {
|
|
189
|
+
msg = await host.opts.client.getMessage(item.source_id);
|
|
190
|
+
} catch (err: unknown) {
|
|
191
|
+
const status = (err as { status?: number })?.status;
|
|
192
|
+
if (status !== 404) {
|
|
193
|
+
host.opts.log?.warn(
|
|
194
|
+
`message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`,
|
|
195
|
+
);
|
|
196
|
+
host.dispatchedMessages.delete(item.source_id);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!msg || msg.sender_id === host.opts.agentUserId) {
|
|
201
|
+
host.dispatchedMessages.delete(item.source_id);
|
|
202
|
+
ackItem();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const decision = await host.buildMessageDispatchDecision(item.chat_id, msg);
|
|
207
|
+
if (decision.action === 'retry') {
|
|
208
|
+
host.dispatchedMessages.delete(item.source_id);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (decision.action === 'skip') {
|
|
212
|
+
host.dispatchedMessages.delete(item.source_id);
|
|
213
|
+
ackItem();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
decision.event.dispatchEventId = item.id;
|
|
217
|
+
const laneResolved = host.usesLaneLedger(decision.event);
|
|
218
|
+
try {
|
|
219
|
+
const dispatched = await host.handleInboundEvent(decision.event);
|
|
220
|
+
if (dispatched) {
|
|
221
|
+
// Ledger runs resolve server-side (reply cover / no_action sweep);
|
|
222
|
+
// when the ledger is not in effect the WorkItem still needs the
|
|
223
|
+
// legacy administrative ack — without it the row would sit pending
|
|
224
|
+
// forever on this path while catch-up would have acked it.
|
|
225
|
+
if (!laneResolved) ackItem();
|
|
226
|
+
} else {
|
|
227
|
+
host.dispatchedMessages.delete(item.source_id);
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// Free the dedupe entry so a later re-drive / catch-up can retry —
|
|
231
|
+
// same cleanup contract as the other inbound handlers.
|
|
232
|
+
host.dispatchedMessages.delete(item.source_id);
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from './provider-config.js';
|
|
2
2
|
export * from './types.js';
|
|
3
|
+
export * from './lane-key.js';
|
|
4
|
+
export type { LaneFlowHost } from './gateway-lane-flow.js';
|
|
3
5
|
export * from './session-state.js';
|
|
4
6
|
export * from './routing.js';
|
|
5
7
|
export * from './event-format.js';
|
package/src/lane-key.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical lane-key encoding — the SSOT shared by the bridge (writer of
|
|
5
|
+
* `$PRLL_CONTEXT_DIR/<lane-key>.json`) and the CLI (reader, keyed by the send
|
|
6
|
+
* target). Both sides MUST use this exact function: the lane key encodes the
|
|
7
|
+
* full lane identity `(target_uri, thread_root_id)` so a channel lane and a
|
|
8
|
+
* thread lane in the same chat never collide, and base64url keeps any
|
|
9
|
+
* target_uri (including typed `dsp:<id>` resources) filesystem-safe with no
|
|
10
|
+
* escaping ambiguity.
|
|
11
|
+
*/
|
|
12
|
+
export function laneKeyForTarget(targetUri: string, threadRootId?: string | null): string {
|
|
13
|
+
return Buffer.from(`${targetUri}\n${threadRootId ?? ''}`, 'utf8').toString('base64url');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* PRLL_CONTEXT_DIR root under a per-agent stateDir. Owned here alongside the
|
|
18
|
+
* lane-key encoding so the whole context-dir contract has one home; runtime
|
|
19
|
+
* bridges import this instead of re-declaring the path.
|
|
20
|
+
*/
|
|
21
|
+
export function dispatchLaneContextDir(stateDir: string): string {
|
|
22
|
+
return path.join(stateDir, 'dispatch-lane-context');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Path of the bridge-owned dispatch context file for a lane. */
|
|
26
|
+
export function laneContextFilePath(
|
|
27
|
+
contextDir: string,
|
|
28
|
+
targetUri: string,
|
|
29
|
+
threadRootId?: string | null,
|
|
30
|
+
): string {
|
|
31
|
+
return path.join(contextDir, `${laneKeyForTarget(targetUri, threadRootId)}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Path of the CLI-owned reply-state sidecar for a lane. Separate file from the
|
|
36
|
+
* bridge-owned context (two writers, two files): the bridge rewrites the
|
|
37
|
+
* context file throughout the turn (step_id updates) and would clobber any
|
|
38
|
+
* CLI state stored inline.
|
|
39
|
+
*/
|
|
40
|
+
export function laneReplyStateFilePath(
|
|
41
|
+
contextDir: string,
|
|
42
|
+
targetUri: string,
|
|
43
|
+
threadRootId?: string | null,
|
|
44
|
+
): string {
|
|
45
|
+
return path.join(contextDir, `${laneKeyForTarget(targetUri, threadRootId)}.reply-state.json`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Shape of the bridge-written per-lane dispatch context file. */
|
|
49
|
+
export type LaneDispatchContextFile = {
|
|
50
|
+
session_id: string | null;
|
|
51
|
+
step_id: string | null;
|
|
52
|
+
chat_id: string | null;
|
|
53
|
+
trigger_message_id: string | null;
|
|
54
|
+
no_reply: boolean;
|
|
55
|
+
/** WorkItem id of the trigger — basis of the `reply:<dsp>` effect key. */
|
|
56
|
+
dispatch_event_id: string | null;
|
|
57
|
+
/** Server-minted per-turn lane token; every dispatch-bound write carries it. */
|
|
58
|
+
lane: string | null;
|
|
59
|
+
target_uri: string | null;
|
|
60
|
+
thread_root_id: string | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Shape of the CLI-written reply-state sidecar. */
|
|
64
|
+
export type LaneReplyStateFile = {
|
|
65
|
+
dispatch_event_id: string;
|
|
66
|
+
reply_committed: boolean;
|
|
67
|
+
};
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ApiError } from '@parall/sdk';
|
|
4
|
+
import type { ParallClient } from '@parall/sdk';
|
|
5
|
+
import type { GatewayLogger } from './dispatch-adapter.js';
|
|
6
|
+
import { laneContextFilePath, laneKeyForTarget } from './lane-key.js';
|
|
7
|
+
import type { ParallEvent } from './types.js';
|
|
8
|
+
|
|
9
|
+
/** One claimed lane the bridge currently occupies. */
|
|
10
|
+
export type ActiveLane = {
|
|
11
|
+
laneKey: string;
|
|
12
|
+
lane: string;
|
|
13
|
+
targetUri: string;
|
|
14
|
+
threadRootId?: string;
|
|
15
|
+
/** source_id (message id) → WorkItem id for members folded into this lane. */
|
|
16
|
+
folded: Map<string, string>;
|
|
17
|
+
/** WorkItem id, set for typed lanes (resource = dsp:<id>, single member). */
|
|
18
|
+
typedDispatchEventId?: string;
|
|
19
|
+
/** Lease expiry (ms epoch) and TTL from the claim — renewal pacing state. */
|
|
20
|
+
leaseUntilMs?: number;
|
|
21
|
+
leaseTtlMs?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Thrown when the server predates the dispatch ledger (claim endpoint 404s
|
|
26
|
+
* during a rolling deploy). The gateway falls back to the legacy
|
|
27
|
+
* received/ack flow for the rest of its lifetime.
|
|
28
|
+
*/
|
|
29
|
+
export class LedgerUnsupportedError extends Error {}
|
|
30
|
+
|
|
31
|
+
export type LaneGroupOutcome = 'claimed' | 'foreign';
|
|
32
|
+
|
|
33
|
+
function isStaleLane(err: unknown): boolean {
|
|
34
|
+
return err instanceof ApiError && err.status === 409 && err.code === 'STALE_LANE';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isNotFound(err: unknown): boolean {
|
|
38
|
+
return err instanceof ApiError && err.status === 404;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A pre-ledger server (or an edge that doesn't know the route) answers the
|
|
43
|
+
* claim endpoint with an UNSTRUCTURED 404 — no app error code. Our own
|
|
44
|
+
* handlers always attach a code, so a coded 404 is a semantic answer, not a
|
|
45
|
+
* missing endpoint. Transient edge 404s during rolling deploys are further
|
|
46
|
+
* contained by the reconnect re-probe (ledgerDisabled resets on hello).
|
|
47
|
+
*/
|
|
48
|
+
function isEndpointMissing(err: unknown): boolean {
|
|
49
|
+
return err instanceof ApiError && err.status === 404 && !err.code;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* LaneLedger is the bridge-side client of the server dispatch ledger
|
|
54
|
+
* (docs/engineering-design/agent-dispatch-idempotency-design.md): it claims
|
|
55
|
+
* lane occupancy just-in-time before a message dispatch, folds mid-turn
|
|
56
|
+
* same-target messages via steer, completes (no_action sweep + release) when
|
|
57
|
+
* the local queue for a lane runs dry, and releases leftovers on shutdown.
|
|
58
|
+
*
|
|
59
|
+
* It is also the single writer of the per-lane dispatch context files under
|
|
60
|
+
* PRLL_CONTEXT_DIR (`<lane-key>.json`) that the CLI reads to derive dispatch
|
|
61
|
+
* effect keys. The CLI's reply-state sidecar lives next to it and is never
|
|
62
|
+
* touched here (two writers, two files).
|
|
63
|
+
*/
|
|
64
|
+
export class LaneLedger {
|
|
65
|
+
private readonly lanes = new Map<string, ActiveLane>();
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly opts: {
|
|
69
|
+
client: ParallClient;
|
|
70
|
+
orgId: string;
|
|
71
|
+
contextDir: string;
|
|
72
|
+
log?: GatewayLogger;
|
|
73
|
+
},
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
get contextDir(): string {
|
|
77
|
+
return this.opts.contextDir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Only chat message events ride the lane ledger; typed events stay on the legacy ack path. */
|
|
81
|
+
handles(event: ParallEvent): boolean {
|
|
82
|
+
return event.type === 'message' && event.targetId.startsWith('cht_');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
laneKeyFor(event: ParallEvent): string {
|
|
86
|
+
if (event.type !== 'message' && event.dispatchEventId) {
|
|
87
|
+
return laneKeyForTarget(`dsp:${event.dispatchEventId}`);
|
|
88
|
+
}
|
|
89
|
+
return laneKeyForTarget(`prll://${event.targetId}`, event.threadRootId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getForEvent(event: ParallEvent): ActiveLane | undefined {
|
|
93
|
+
return this.lanes.get(this.laneKeyFor(event));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
laneContextPath(lane: ActiveLane): string {
|
|
97
|
+
return laneContextFilePath(this.opts.contextDir, lane.targetUri, lane.threadRootId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Claim (or reuse) the lane for a group of same-lane message events and
|
|
102
|
+
* fold every group member into it. Returns 'foreign' when a healthy
|
|
103
|
+
* incumbent (another pod) holds the resource — the caller must not
|
|
104
|
+
* dispatch; the events stay pending server-side and re-drive after the
|
|
105
|
+
* incumbent completes.
|
|
106
|
+
*/
|
|
107
|
+
async ensureLane(events: ParallEvent[]): Promise<ActiveLane | null> {
|
|
108
|
+
const trigger = events[events.length - 1];
|
|
109
|
+
const laneKey = this.laneKeyFor(trigger);
|
|
110
|
+
let lane = this.lanes.get(laneKey);
|
|
111
|
+
if (!lane) {
|
|
112
|
+
const targetUri = `prll://${trigger.targetId}`;
|
|
113
|
+
let res;
|
|
114
|
+
try {
|
|
115
|
+
res = await this.opts.client.claimDispatch(this.opts.orgId, {
|
|
116
|
+
target_uri: targetUri,
|
|
117
|
+
thread_root_id: trigger.threadRootId,
|
|
118
|
+
limit: 100,
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (isEndpointMissing(err)) throw new LedgerUnsupportedError('claim endpoint unavailable');
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
if (!res.claimed || !res.lane) {
|
|
125
|
+
this.opts.log?.info(
|
|
126
|
+
`lane for ${targetUri} held by a healthy incumbent — leaving events pending for re-drive`,
|
|
127
|
+
);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const leaseUntilMs = Date.parse(res.lease_until ?? '');
|
|
131
|
+
lane = {
|
|
132
|
+
laneKey,
|
|
133
|
+
lane: res.lane,
|
|
134
|
+
targetUri,
|
|
135
|
+
threadRootId: trigger.threadRootId,
|
|
136
|
+
folded: new Map(),
|
|
137
|
+
...(Number.isNaN(leaseUntilMs)
|
|
138
|
+
? {}
|
|
139
|
+
: { leaseUntilMs, leaseTtlMs: Math.max(leaseUntilMs - Date.now(), 60_000) }),
|
|
140
|
+
};
|
|
141
|
+
for (const ev of res.events ?? []) {
|
|
142
|
+
lane.folded.set(ev.source_id, ev.id);
|
|
143
|
+
}
|
|
144
|
+
this.lanes.set(laneKey, lane);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fold group members the claim didn't cover (they arrived after the
|
|
148
|
+
// claim, or this group reuses an already-active lane). Fail closed on ANY
|
|
149
|
+
// fold failure: an un-folded message dispatched now would not be covered
|
|
150
|
+
// by this lane's reply/complete, so its still-live WorkItem would re-drive
|
|
151
|
+
// later and the model would handle it twice. Releasing hands the folded
|
|
152
|
+
// members back to pending immediately; the re-drive / live hint path
|
|
153
|
+
// re-delivers the whole group under a fresh claim.
|
|
154
|
+
for (const ev of events) {
|
|
155
|
+
if (lane.folded.has(ev.messageId)) continue;
|
|
156
|
+
try {
|
|
157
|
+
const res = await this.opts.client.steerDispatch(this.opts.orgId, {
|
|
158
|
+
lane: lane.lane,
|
|
159
|
+
target_uri: lane.targetUri,
|
|
160
|
+
thread_root_id: lane.threadRootId,
|
|
161
|
+
...(ev.dispatchEventId
|
|
162
|
+
? { dispatch_event_id: ev.dispatchEventId }
|
|
163
|
+
: { source_type: 'message', source_id: ev.messageId }),
|
|
164
|
+
});
|
|
165
|
+
lane.folded.set(ev.messageId, res.dispatch_event_id);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (isStaleLane(err)) {
|
|
168
|
+
// We lost the lane mid-group — drop local state; the takeover
|
|
169
|
+
// owner (or the next claim) picks the members up.
|
|
170
|
+
this.lanes.delete(laneKey);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
this.opts.log?.warn(
|
|
174
|
+
`steer fold failed for ${ev.messageId} — failing closed, releasing lane: ${String(err)}`,
|
|
175
|
+
);
|
|
176
|
+
await this.release(laneKey);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return lane;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Fold a live mid-turn message into its active lane BEFORE injecting it
|
|
185
|
+
* into the running turn. Injection without a successful fold is forbidden —
|
|
186
|
+
* an un-folded injected message would be re-driven after complete and the
|
|
187
|
+
* model would handle it twice.
|
|
188
|
+
*/
|
|
189
|
+
async steerLive(event: ParallEvent): Promise<boolean> {
|
|
190
|
+
const laneKey = this.laneKeyFor(event);
|
|
191
|
+
const lane = this.lanes.get(laneKey);
|
|
192
|
+
if (!lane) return false;
|
|
193
|
+
if (lane.folded.has(event.messageId)) return true;
|
|
194
|
+
try {
|
|
195
|
+
const res = await this.opts.client.steerDispatch(this.opts.orgId, {
|
|
196
|
+
lane: lane.lane,
|
|
197
|
+
target_uri: lane.targetUri,
|
|
198
|
+
thread_root_id: lane.threadRootId,
|
|
199
|
+
...(event.dispatchEventId
|
|
200
|
+
? { dispatch_event_id: event.dispatchEventId }
|
|
201
|
+
: { source_type: 'message', source_id: event.messageId }),
|
|
202
|
+
});
|
|
203
|
+
lane.folded.set(event.messageId, res.dispatch_event_id);
|
|
204
|
+
return true;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (isStaleLane(err)) {
|
|
207
|
+
this.lanes.delete(laneKey);
|
|
208
|
+
} else {
|
|
209
|
+
this.opts.log?.warn(`live steer failed for ${event.messageId}: ${String(err)}`);
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Complete the lane when no local work remains for it: the server sweeps
|
|
217
|
+
* still-leased members as no_action, releases the occupancy row, and
|
|
218
|
+
* re-drives any same-target pending work. A STALE_LANE answer means a
|
|
219
|
+
* takeover already owns the resource — local state is dropped either way.
|
|
220
|
+
*/
|
|
221
|
+
async completeIfIdle(laneKey: string, hasMoreLocal: boolean): Promise<void> {
|
|
222
|
+
const lane = this.lanes.get(laneKey);
|
|
223
|
+
if (!lane || hasMoreLocal) return;
|
|
224
|
+
this.lanes.delete(laneKey);
|
|
225
|
+
this.removeLaneContext(lane);
|
|
226
|
+
try {
|
|
227
|
+
const res = await this.opts.client.completeDispatch(this.opts.orgId, {
|
|
228
|
+
lane: lane.lane,
|
|
229
|
+
target_uri: lane.targetUri,
|
|
230
|
+
thread_root_id: lane.threadRootId,
|
|
231
|
+
});
|
|
232
|
+
if (res.swept_no_action > 0 || res.redriven) {
|
|
233
|
+
this.opts.log?.info(
|
|
234
|
+
`lane complete for ${lane.targetUri}: swept ${res.swept_no_action} no_action, redriven=${res.redriven}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (isStaleLane(err)) {
|
|
239
|
+
this.opts.log?.info(`lane complete skipped for ${lane.targetUri} — taken over`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// Lease expiry recovers the members; complete is not retried here.
|
|
243
|
+
this.opts.log?.warn(`lane complete failed for ${lane.targetUri}: ${String(err)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Long-turn keepalive: renew the lane's lease on runtime activity, throttled
|
|
249
|
+
* so a chatty turn doesn't spam the server. Without this, a legitimately
|
|
250
|
+
* long turn (> lane TTL) would be dethroned mid-flight and every subsequent
|
|
251
|
+
* write misfired with STALE_LANE — the design doc's "long turns renew via
|
|
252
|
+
* step writes". Fire-and-forget: a failed renewal is surfaced by the next
|
|
253
|
+
* write's incumbency check anyway.
|
|
254
|
+
*/
|
|
255
|
+
maybeRenew(lane: ActiveLane): void {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
// Server-driven pacing: renew once less than half the lease TTL remains.
|
|
258
|
+
// Hardcoding a client-side rhythm would silently break every published
|
|
259
|
+
// runtime image the day the server shortens its lane TTL.
|
|
260
|
+
const ttl = lane.leaseTtlMs ?? 10 * 60_000;
|
|
261
|
+
const until = lane.leaseUntilMs ?? now; // unknown lease → renew now
|
|
262
|
+
if (until - now > ttl / 2) return;
|
|
263
|
+
lane.leaseUntilMs = now + ttl; // optimistic; corrected by the response
|
|
264
|
+
void this.opts.client
|
|
265
|
+
.heartbeatDispatchLane(this.opts.orgId, {
|
|
266
|
+
lane: lane.lane,
|
|
267
|
+
target_uri: lane.targetUri,
|
|
268
|
+
thread_root_id: lane.threadRootId,
|
|
269
|
+
})
|
|
270
|
+
.then((res) => {
|
|
271
|
+
const until = Date.parse(res?.lease_until ?? '');
|
|
272
|
+
if (!Number.isNaN(until)) lane.leaseUntilMs = until;
|
|
273
|
+
})
|
|
274
|
+
.catch((err) => {
|
|
275
|
+
if (isStaleLane(err)) {
|
|
276
|
+
this.lanes.delete(lane.laneKey);
|
|
277
|
+
this.opts.log?.warn(`lane ${lane.targetUri} was taken over during the turn`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
this.opts.log?.warn(`lane heartbeat failed for ${lane.targetUri}: ${String(err)}`);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Release a lane's unresolved members back to pending (dispatch error /
|
|
286
|
+
* shutdown) so the next pod re-claims immediately instead of waiting out
|
|
287
|
+
* the lease.
|
|
288
|
+
*/
|
|
289
|
+
async release(laneKey: string): Promise<void> {
|
|
290
|
+
const lane = this.lanes.get(laneKey);
|
|
291
|
+
if (!lane) return;
|
|
292
|
+
this.lanes.delete(laneKey);
|
|
293
|
+
this.removeLaneContext(lane);
|
|
294
|
+
try {
|
|
295
|
+
await this.opts.client.releaseDispatchLane(this.opts.orgId, lane.lane);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
this.opts.log?.warn(`lane release failed for ${lane.targetUri}: ${String(err)}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async releaseAll(): Promise<void> {
|
|
302
|
+
const keys = [...this.lanes.keys()];
|
|
303
|
+
for (const key of keys) {
|
|
304
|
+
await this.release(key);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** True when any lane is currently active (used by shutdown logging). */
|
|
309
|
+
get activeCount(): number {
|
|
310
|
+
return this.lanes.size;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Claim the single-member lane of one typed WorkItem (resource = dsp:<id>),
|
|
315
|
+
* by WorkItem id or by source identity (the live task.assigned event has no
|
|
316
|
+
* WorkItem id). Returns null when a healthy incumbent (another pod) holds
|
|
317
|
+
* it or the WorkItem is already resolved — the caller must skip processing.
|
|
318
|
+
*/
|
|
319
|
+
async claimTyped(ref: {
|
|
320
|
+
dispatchEventId?: string;
|
|
321
|
+
sourceType?: string;
|
|
322
|
+
sourceId?: string;
|
|
323
|
+
}): Promise<ActiveLane | null> {
|
|
324
|
+
let res;
|
|
325
|
+
try {
|
|
326
|
+
res = await this.opts.client.claimDispatch(this.opts.orgId, {
|
|
327
|
+
dispatch_event_id: ref.dispatchEventId,
|
|
328
|
+
source_type: ref.dispatchEventId ? undefined : ref.sourceType,
|
|
329
|
+
source_id: ref.dispatchEventId ? undefined : ref.sourceId,
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (isEndpointMissing(err)) throw new LedgerUnsupportedError('claim endpoint unavailable');
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
if (!res.claimed || !res.lane || !res.events?.length) return null;
|
|
336
|
+
const workItem = res.events[0];
|
|
337
|
+
const targetUri = `dsp:${workItem.id}`;
|
|
338
|
+
const leaseUntilMs = Date.parse(res.lease_until ?? '');
|
|
339
|
+
const lane: ActiveLane = {
|
|
340
|
+
laneKey: laneKeyForTarget(targetUri),
|
|
341
|
+
lane: res.lane,
|
|
342
|
+
targetUri,
|
|
343
|
+
folded: new Map([[workItem.source_id, workItem.id]]),
|
|
344
|
+
typedDispatchEventId: workItem.id,
|
|
345
|
+
...(Number.isNaN(leaseUntilMs)
|
|
346
|
+
? {}
|
|
347
|
+
: { leaseUntilMs, leaseTtlMs: Math.max(leaseUntilMs - Date.now(), 60_000) }),
|
|
348
|
+
};
|
|
349
|
+
this.lanes.set(lane.laneKey, lane);
|
|
350
|
+
return lane;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Remove the per-lane context file (and its CLI sidecar) when the lane
|
|
355
|
+
* ends. A leftover file would make a later cross-context send to the same
|
|
356
|
+
* target bind a dead lane token and misfire with STALE_LANE instead of
|
|
357
|
+
* taking the plain non-ledger path.
|
|
358
|
+
*/
|
|
359
|
+
private removeLaneContext(lane: ActiveLane): void {
|
|
360
|
+
const contextPath = this.laneContextPath(lane);
|
|
361
|
+
for (const p of [contextPath, contextPath.replace(/\.json$/, '.reply-state.json')]) {
|
|
362
|
+
try {
|
|
363
|
+
fs.rmSync(p, { force: true });
|
|
364
|
+
} catch {
|
|
365
|
+
// Best-effort: a stale file only re-surfaces as a server-side
|
|
366
|
+
// STALE_LANE, never as a wrong write.
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -84,6 +84,8 @@ export type ParallEvent = {
|
|
|
84
84
|
| 'external_trigger_run'
|
|
85
85
|
| 'channel_message';
|
|
86
86
|
ackSourceId?: string;
|
|
87
|
+
/** WorkItem id, when known (dispatch catch-up / re-drive hints carry it; live message.new does not). */
|
|
88
|
+
dispatchEventId?: string;
|
|
87
89
|
/** Unread message count in the target chat since agent's last interaction. */
|
|
88
90
|
unreadCount?: number;
|
|
89
91
|
/** Channel cursor: the last message ID the agent read. */
|