@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.
@@ -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';
@@ -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. */