@parall/agent-core 1.43.0 → 1.45.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/gateway-base.d.ts +33 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +283 -82
- package/dist/gateway-lane-flow.d.ts +78 -6
- package/dist/gateway-lane-flow.d.ts.map +1 -1
- package/dist/gateway-lane-flow.js +277 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lane-ledger.d.ts +30 -0
- package/dist/lane-ledger.d.ts.map +1 -1
- package/dist/lane-ledger.js +50 -1
- package/dist/skills/parall-schedules.d.ts +1 -1
- package/dist/skills/parall-schedules.d.ts.map +1 -1
- package/dist/skills/parall-schedules.js +1 -1
- package/dist/skills/parall-tasks.d.ts +1 -1
- package/dist/skills/parall-tasks.d.ts.map +1 -1
- package/dist/skills/parall-tasks.js +21 -5
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/gateway-base.ts +372 -104
- package/src/gateway-lane-flow.ts +313 -19
- package/src/index.ts +1 -1
- package/src/lane-ledger.ts +60 -3
- package/src/skills/parall-schedules.ts +1 -1
- package/src/skills/parall-tasks.ts +21 -5
- package/src/types.ts +2 -1
package/src/gateway-lane-flow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ApiError } from '@parall/sdk';
|
|
1
2
|
import type { ParallClient } from '@parall/sdk';
|
|
2
3
|
import type { DispatchAdapter, GatewayLogger } from './dispatch-adapter.js';
|
|
3
4
|
import type { DispatchableMessage, MessageDispatchDecision } from './gateway-base.js';
|
|
@@ -16,8 +17,15 @@ import type { ParallEvent } from './types.js';
|
|
|
16
17
|
export interface LaneFlowHost {
|
|
17
18
|
laneLedger?: LaneLedger;
|
|
18
19
|
ledgerDisabled: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Sticky: the server answered a by-id complete with 400 (predates the
|
|
22
|
+
* form, v1.44). Typed resolution falls back to the legacy ack for the
|
|
23
|
+
* rest of the process; claims stay on the ledger (that face is older).
|
|
24
|
+
*/
|
|
25
|
+
typedByIdCompleteUnsupported: boolean;
|
|
19
26
|
shuttingDown: boolean;
|
|
20
27
|
dispatchedMessages: Set<string>;
|
|
28
|
+
dispatchedTasks: Set<string>;
|
|
21
29
|
/**
|
|
22
30
|
* Per-WorkItem failure backoff for typed dispatch consumption. A consume
|
|
23
31
|
* that ends without an ack re-arms the entry; the next attempt for the
|
|
@@ -37,6 +45,8 @@ export interface LaneFlowHost {
|
|
|
37
45
|
disableLedger(reason: string): void;
|
|
38
46
|
usesLaneLedger(event: ParallEvent): boolean;
|
|
39
47
|
emitDispatchReceived(event: ParallEvent): Promise<void>;
|
|
48
|
+
consumeTurnError(sessionKey: string): boolean;
|
|
49
|
+
noteSessionLane(sessionKey: string, laneKey: string | null): void;
|
|
40
50
|
runDispatch(
|
|
41
51
|
event: ParallEvent,
|
|
42
52
|
sessionKey: string,
|
|
@@ -68,7 +78,7 @@ export async function dispatchLaneGroup(
|
|
|
68
78
|
captureText?: string[];
|
|
69
79
|
hasMoreLocal: () => boolean;
|
|
70
80
|
},
|
|
71
|
-
): Promise<'dispatched' | 'foreign' | 'shutdown'> {
|
|
81
|
+
): Promise<'dispatched' | 'foreign' | 'shutdown' | 'failed'> {
|
|
72
82
|
const ledger = host.laneLedger!;
|
|
73
83
|
const event = opts.events[opts.events.length - 1];
|
|
74
84
|
let lane: Awaited<ReturnType<LaneLedger['ensureLane']>>;
|
|
@@ -106,6 +116,9 @@ export async function dispatchLaneGroup(
|
|
|
106
116
|
}
|
|
107
117
|
return 'foreign';
|
|
108
118
|
}
|
|
119
|
+
// Register the session's active lane so external activity signals
|
|
120
|
+
// (touchRuntimeActivity from adapter hooks) renew exactly this lane.
|
|
121
|
+
host.noteSessionLane(opts.sessionKey, lane.laneKey);
|
|
109
122
|
let dispatched = false;
|
|
110
123
|
try {
|
|
111
124
|
dispatched = await host.runDispatch(
|
|
@@ -118,32 +131,253 @@ export async function dispatchLaneGroup(
|
|
|
118
131
|
} catch (err) {
|
|
119
132
|
// Failed turn: hand the members back so the retry (this pod or the
|
|
120
133
|
// next) re-claims immediately instead of waiting out the lease.
|
|
134
|
+
host.noteSessionLane(opts.sessionKey, null);
|
|
121
135
|
await ledger.release(lane.laneKey).catch(() => {});
|
|
122
136
|
throw err;
|
|
137
|
+
} finally {
|
|
138
|
+
if (dispatched) host.noteSessionLane(opts.sessionKey, null);
|
|
123
139
|
}
|
|
124
140
|
if (!dispatched) {
|
|
125
141
|
// Shutdown short-circuit — shutdown() releases all active lanes.
|
|
126
142
|
return 'shutdown';
|
|
127
143
|
}
|
|
144
|
+
if (host.consumeTurnError(opts.sessionKey)) {
|
|
145
|
+
// An error turn must not no_action-sweep its members — settle the lane
|
|
146
|
+
// NOW with an error complete so they release for retry on the redrive
|
|
147
|
+
// budget (dispatch-convergence-design.md §3). Settling immediately (even
|
|
148
|
+
// with same-lane work still buffered) is deliberate: carrying the error
|
|
149
|
+
// across buffered turns would let a later reply broad-cover the failed
|
|
150
|
+
// member, or requeue the later turn's successful work. Released members
|
|
151
|
+
// rejoin the next claim, merged with whatever was buffered. Dropping the
|
|
152
|
+
// local dedupe claims lets the server's re-drive hint retrigger the
|
|
153
|
+
// messages immediately instead of waiting out the renotify pacing.
|
|
154
|
+
ledger.markTurnError(lane.laneKey);
|
|
155
|
+
// Clear dedupe for EVERY lane member, not just this batch: an earlier
|
|
156
|
+
// successful batch may have deferred its complete via hasMoreLocal, so
|
|
157
|
+
// the error complete below releases those members too — their redrive
|
|
158
|
+
// would be permanently blocked by a stale local claim.
|
|
159
|
+
for (const msgId of lane.folded.keys()) {
|
|
160
|
+
host.dispatchedMessages.delete(msgId);
|
|
161
|
+
}
|
|
162
|
+
// Invalidate any armed steer state before settling: a pending injection
|
|
163
|
+
// left over from the failed turn would make the retry claim enter the
|
|
164
|
+
// "already injected" path, produce an empty turn, and no_action-sweep the
|
|
165
|
+
// released members without ever retrying them. abortDispatch is the
|
|
166
|
+
// adapter contract's idempotent "clear pending steer state" hook.
|
|
167
|
+
try {
|
|
168
|
+
host.opts.dispatchAdapter.abortDispatch?.(opts.sessionKey);
|
|
169
|
+
} catch {
|
|
170
|
+
// best-effort — a throwing abort must not block the error settlement
|
|
171
|
+
}
|
|
172
|
+
await ledger.completeIfIdle(lane.laneKey, false);
|
|
173
|
+
// 'failed' — callers must NOT record these events as handled: the server
|
|
174
|
+
// just released them for redelivery, and an "already handled" fork prefix
|
|
175
|
+
// (or a consumed fork summary) on the redrive would be a lie.
|
|
176
|
+
return 'failed';
|
|
177
|
+
}
|
|
128
178
|
const pendingInjections =
|
|
129
179
|
host.opts.dispatchAdapter.hasPendingInjections?.(opts.sessionKey) ?? false;
|
|
130
180
|
await ledger.completeIfIdle(lane.laneKey, pendingInjections || opts.hasMoreLocal());
|
|
131
181
|
return 'dispatched';
|
|
132
182
|
}
|
|
133
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The WorkItem ids of a typed event group whose lifecycle the ledger owns
|
|
186
|
+
* (claimed into dsp lanes by consumeTypedDispatch), or null when the group
|
|
187
|
+
* must stay on the legacy received/ack surface — ledger unavailable, an id
|
|
188
|
+
* missing, or a member that rides a message lane. Non-null means: skip
|
|
189
|
+
* legacy received+ack, resolve via the by-id complete, and fold the
|
|
190
|
+
* turn-error signal into the outcome.
|
|
191
|
+
*/
|
|
192
|
+
export function typedLedgerEventIds(host: LaneFlowHost, events: ParallEvent[]): string[] | null {
|
|
193
|
+
if (!host.laneLedger || host.ledgerDisabled) return null;
|
|
194
|
+
const ids: string[] = [];
|
|
195
|
+
for (const ev of events) {
|
|
196
|
+
if (!ev.dispatchEventId || host.usesLaneLedger(ev)) return null;
|
|
197
|
+
ids.push(ev.dispatchEventId);
|
|
198
|
+
}
|
|
199
|
+
return ids.length > 0 ? ids : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Outcome of a by-id complete attempt. */
|
|
203
|
+
export type TypedResolveOutcome = 'ok' | 'stale' | 'unsupported' | 'failed';
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A pre-by-id server (v1.44) routes a by-id body into its lane-form
|
|
207
|
+
* validation and answers 400 INVALID_REQUEST. Any 400 here means the server
|
|
208
|
+
* did not understand the form — a well-formed by-id call never 400s on a
|
|
209
|
+
* current server — so the caller falls back to the legacy typed ack for the
|
|
210
|
+
* rest of the process (sticky; the bridge restarts on the next update).
|
|
211
|
+
*/
|
|
212
|
+
function isByIDCompleteUnsupported(err: unknown): boolean {
|
|
213
|
+
return err instanceof ApiError && err.status === 400;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* By-id complete (turn_outcome ok): terminal resolution of ONE WorkItem —
|
|
218
|
+
* exact where the source pair is ambiguous across task siblings. `lane`
|
|
219
|
+
* fences the call while the row is claim-owned (a dethroned caller gets
|
|
220
|
+
* 'stale' and must leave the row to its successor); omit it for the
|
|
221
|
+
* wrapper-less pending close (buffered drain, administrative drops).
|
|
222
|
+
*/
|
|
223
|
+
export async function resolveDispatchByID(
|
|
224
|
+
host: LaneFlowHost,
|
|
225
|
+
dispatchEventId: string,
|
|
226
|
+
lane?: string,
|
|
227
|
+
): Promise<TypedResolveOutcome> {
|
|
228
|
+
try {
|
|
229
|
+
await host.opts.client.completeDispatch(host.opts.config.org_id, {
|
|
230
|
+
dispatch_event_id: dispatchEventId,
|
|
231
|
+
...(lane ? { lane } : {}),
|
|
232
|
+
turn_outcome: 'ok',
|
|
233
|
+
});
|
|
234
|
+
return 'ok';
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (err instanceof ApiError && err.status === 409) return 'stale';
|
|
237
|
+
if (isByIDCompleteUnsupported(err)) return 'unsupported';
|
|
238
|
+
host.opts.log?.warn(
|
|
239
|
+
`by-id complete failed for ${dispatchEventId} — leaving for re-drive: ${String(err)}`,
|
|
240
|
+
);
|
|
241
|
+
return 'failed';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Free a typed event's in-memory hot-path dedupe claim by its source pair —
|
|
247
|
+
* the ParallEvent-keyed mirror of the gateway's clearTypedDispatchDedupe
|
|
248
|
+
* (which keys on the wire DispatchNewData). Used when a buffered typed
|
|
249
|
+
* dispatch ran but its resolution failed: the member re-drives, and a stale
|
|
250
|
+
* local claim would make this pod reject the retry forever.
|
|
251
|
+
*
|
|
252
|
+
* PARITY: the switch below and clearTypedDispatchDedupe's must handle the
|
|
253
|
+
* same typed source families — when adding a new typed event type, extend
|
|
254
|
+
* BOTH (they key the same dedupe entries from different event shapes).
|
|
255
|
+
*/
|
|
256
|
+
export function clearTypedDedupeForEvent(host: LaneFlowHost, event: ParallEvent): void {
|
|
257
|
+
const sourceId = event.ackSourceId;
|
|
258
|
+
if (!sourceId) return;
|
|
259
|
+
switch (event.ackSourceType) {
|
|
260
|
+
case 'task_activity': {
|
|
261
|
+
// Task dedupe keys are `${task_id}:${updated_at}`; the task id is the
|
|
262
|
+
// event target. Prefix-clear mirrors clearTypedDispatchDedupe.
|
|
263
|
+
const prefix = `${event.targetId}:`;
|
|
264
|
+
for (const key of host.dispatchedTasks) {
|
|
265
|
+
if (key.startsWith(prefix)) host.dispatchedTasks.delete(key);
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case 'comment':
|
|
270
|
+
host.dispatchedTasks.delete(`comment:${sourceId}`);
|
|
271
|
+
break;
|
|
272
|
+
case 'schedule_run':
|
|
273
|
+
host.dispatchedTasks.delete(`schedule_run:${sourceId}`);
|
|
274
|
+
break;
|
|
275
|
+
case 'external_trigger_run':
|
|
276
|
+
host.dispatchedTasks.delete(`external_trigger_run:${sourceId}`);
|
|
277
|
+
break;
|
|
278
|
+
case 'channel_message':
|
|
279
|
+
host.dispatchedMessages.delete(`channel_message:${sourceId}`);
|
|
280
|
+
break;
|
|
281
|
+
case 'approval':
|
|
282
|
+
host.dispatchedTasks.delete(`approval:${sourceId}`);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Settle a drained typed group — the one wrapper-less dispatch path: these
|
|
289
|
+
* events buffered behind a busy main (fork-less adapter), their consume
|
|
290
|
+
* guards returned false and released the claims, so the drain site owns the
|
|
291
|
+
* terminal resolution. Rows are pending — the by-id close resolves them
|
|
292
|
+
* without a fence; a row a successor lane meanwhile claimed answers stale and
|
|
293
|
+
* is that owner's to finish. `turnErrored` (consumed at the call site before
|
|
294
|
+
* the drain can start another turn) leaves the rows for the renotify pacing
|
|
295
|
+
* instead. Either way a row left live gets its local dedupe claim freed, or
|
|
296
|
+
* this pod would reject the retry forever.
|
|
297
|
+
*/
|
|
298
|
+
export async function settleDrainedTypedGroup(
|
|
299
|
+
host: LaneFlowHost,
|
|
300
|
+
events: ParallEvent[],
|
|
301
|
+
ids: string[],
|
|
302
|
+
turnErrored: boolean,
|
|
303
|
+
): Promise<void> {
|
|
304
|
+
if (turnErrored) {
|
|
305
|
+
host.opts.log?.info(
|
|
306
|
+
`buffered typed turn for ${events[events.length - 1]?.messageId} surfaced a runtime error — leaving for re-drive`,
|
|
307
|
+
);
|
|
308
|
+
for (const event of events) clearTypedDedupeForEvent(host, event);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// The legacy ack BY ID, never by source — task siblings share a source
|
|
312
|
+
// pair, and a by-source ack would sweep the undispatched one. Awaited:
|
|
313
|
+
// these rows are already pending (released at buffer time), so a failed
|
|
314
|
+
// ack must free the local dedupe claim or the pending retry would be
|
|
315
|
+
// self-rejected by this pod forever.
|
|
316
|
+
const legacyAckFrom = async (start: number) => {
|
|
317
|
+
for (const [j, id] of ids.slice(start).entries()) {
|
|
318
|
+
try {
|
|
319
|
+
await host.opts.client.ackDispatchByID(host.opts.config.org_id, id);
|
|
320
|
+
} catch {
|
|
321
|
+
clearTypedDedupeForEvent(host, events[start + j]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
if (host.typedByIdCompleteUnsupported) {
|
|
326
|
+
// Sticky: the server already answered one by-id probe with 400 — go
|
|
327
|
+
// straight to the legacy acks instead of re-probing per group.
|
|
328
|
+
await legacyAckFrom(0);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
for (const [i, id] of ids.entries()) {
|
|
332
|
+
const outcome = await resolveDispatchByID(host, id);
|
|
333
|
+
if (outcome === 'unsupported') {
|
|
334
|
+
host.typedByIdCompleteUnsupported = true;
|
|
335
|
+
host.opts.log?.warn(
|
|
336
|
+
'server predates the by-id dispatch complete — falling back to legacy typed acks',
|
|
337
|
+
);
|
|
338
|
+
await legacyAckFrom(i);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (outcome !== 'ok') {
|
|
342
|
+
clearTypedDedupeForEvent(host, events[i]);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
134
347
|
/** Failure backoff pacing for typed dispatch retries (base 2s, cap 5min). */
|
|
135
348
|
const TYPED_BACKOFF_BASE_MS = 2_000;
|
|
136
349
|
const TYPED_BACKOFF_CAP_MS = 5 * 60_000;
|
|
137
350
|
const TYPED_BACKOFF_MAP_CAP = 512;
|
|
138
351
|
|
|
352
|
+
/** Consumption hooks for one typed dispatch. */
|
|
353
|
+
export interface TypedConsumeHooks {
|
|
354
|
+
/**
|
|
355
|
+
* Legacy administrative ack — invoked ONLY on the ledger-disabled fallback
|
|
356
|
+
* path (old server). On the ledger path the WorkItem resolves through the
|
|
357
|
+
* sources-form complete instead; this callback never fires there.
|
|
358
|
+
*/
|
|
359
|
+
legacyAck: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>;
|
|
360
|
+
/**
|
|
361
|
+
* Free the item's in-memory hot-path dedupe claim. Invoked when the handler
|
|
362
|
+
* ran but the resolution could not commit (sources-form complete failed) —
|
|
363
|
+
* the member is released for re-drive, and a stale local claim would make
|
|
364
|
+
* this pod reject its own retry forever.
|
|
365
|
+
*/
|
|
366
|
+
clearDedupe?: () => void;
|
|
367
|
+
}
|
|
368
|
+
|
|
139
369
|
/**
|
|
140
370
|
* Consume one typed dispatch (task/comment/schedule/trigger/approval) under
|
|
141
371
|
* its typed-lane occupancy guard: claim the dsp:<id> lane (skip when another
|
|
142
|
-
* pod holds it or the WorkItem is already resolved), run the handler,
|
|
143
|
-
*
|
|
144
|
-
*
|
|
372
|
+
* pod holds it or the WorkItem is already resolved), run the handler, then
|
|
373
|
+
* settle. A successful run resolves the WorkItem terminally via the
|
|
374
|
+
* sources-form complete (turn_outcome ok — the server sweeps it, or lets a
|
|
375
|
+
* typed Effect committed during the turn stand, and drops the vacated lane);
|
|
376
|
+
* a failed / errored / unresolved run releases the member back to pending on
|
|
377
|
+
* the redrive budget via the lane complete. Legacy run+ack flow when the
|
|
378
|
+
* ledger is unavailable.
|
|
145
379
|
*
|
|
146
|
-
* Repeated failures back off: a consume that ends
|
|
380
|
+
* Repeated failures back off: a consume that ends unresolved re-arms the
|
|
147
381
|
* WorkItem's backoff entry, and the next attempt sleeps out the remaining
|
|
148
382
|
* window before claiming. Without this, complete's release re-drives the
|
|
149
383
|
* item instantly and a persistently-failing consume (e.g. buffered behind a
|
|
@@ -154,7 +388,7 @@ export async function consumeTypedDispatch(
|
|
|
154
388
|
host: LaneFlowHost,
|
|
155
389
|
ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
|
|
156
390
|
run: (dispatchEventId?: string) => Promise<boolean>,
|
|
157
|
-
|
|
391
|
+
hooks: TypedConsumeHooks,
|
|
158
392
|
): Promise<void> {
|
|
159
393
|
const backoffKey = ref.dispatchEventId ?? `${ref.sourceType}:${ref.sourceId}`;
|
|
160
394
|
const armed = host.typedRedriveBackoff.get(backoffKey);
|
|
@@ -179,8 +413,8 @@ export async function consumeTypedDispatch(
|
|
|
179
413
|
// consume — clearing backoff there would let an ack outage re-create the
|
|
180
414
|
// wire-speed release/re-drive loop against a pre-budget server.
|
|
181
415
|
const settleAck = (ackResult: boolean | void) => ackResult !== false;
|
|
182
|
-
const settle = (
|
|
183
|
-
if (
|
|
416
|
+
const settle = (resolved: boolean) => {
|
|
417
|
+
if (resolved) {
|
|
184
418
|
host.typedRedriveBackoff.delete(backoffKey);
|
|
185
419
|
return;
|
|
186
420
|
}
|
|
@@ -207,7 +441,7 @@ export async function consumeTypedDispatch(
|
|
|
207
441
|
let acked = false;
|
|
208
442
|
try {
|
|
209
443
|
if (await run(ref.dispatchEventId)) {
|
|
210
|
-
acked = settleAck(await
|
|
444
|
+
acked = settleAck(await hooks.legacyAck(ref.dispatchEventId));
|
|
211
445
|
}
|
|
212
446
|
} finally {
|
|
213
447
|
settle(acked);
|
|
@@ -237,20 +471,65 @@ export async function consumeTypedDispatch(
|
|
|
237
471
|
);
|
|
238
472
|
return;
|
|
239
473
|
}
|
|
240
|
-
let
|
|
474
|
+
let resolved = false;
|
|
475
|
+
let viaLegacyAck = false;
|
|
241
476
|
try {
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
477
|
+
// A successful run resolves the member terminally through the by-id
|
|
478
|
+
// complete — one fenced call that sweeps exactly this WorkItem (a typed
|
|
479
|
+
// Effect committed during the turn already made it terminal — idempotent
|
|
480
|
+
// no-op) AND drops the vacated lane. Resolution must settle inside this
|
|
481
|
+
// guard: a fire-and-forget resolve would race the finally's release and
|
|
482
|
+
// spuriously re-drive handled work.
|
|
245
483
|
if (await run(lane.typedDispatchEventId)) {
|
|
246
|
-
|
|
484
|
+
if (host.typedByIdCompleteUnsupported || !lane.typedDispatchEventId) {
|
|
485
|
+
// Pre-by-id server (or a defensive claim shape without the id):
|
|
486
|
+
// the legacy administrative ack is still live there.
|
|
487
|
+
viaLegacyAck = true;
|
|
488
|
+
resolved = settleAck(await hooks.legacyAck(lane.typedDispatchEventId));
|
|
489
|
+
} else {
|
|
490
|
+
const outcome = await resolveDispatchByID(host, lane.typedDispatchEventId, lane.lane);
|
|
491
|
+
if (outcome === 'unsupported') {
|
|
492
|
+
host.typedByIdCompleteUnsupported = true;
|
|
493
|
+
host.opts.log?.warn(
|
|
494
|
+
'server predates the by-id dispatch complete — falling back to the legacy typed ack',
|
|
495
|
+
);
|
|
496
|
+
viaLegacyAck = true;
|
|
497
|
+
resolved = settleAck(await hooks.legacyAck(lane.typedDispatchEventId));
|
|
498
|
+
} else if (outcome === 'stale') {
|
|
499
|
+
// A successor lane owns the row — its turn resolves it. Nothing to
|
|
500
|
+
// spin on locally; our claim record is already dethroned. Free the
|
|
501
|
+
// local dedupe claim though: if the successor later FAILS and
|
|
502
|
+
// releases the row, the re-drive may land back on this pod, and a
|
|
503
|
+
// stale local claim would self-reject the retry into the budget.
|
|
504
|
+
hooks.clearDedupe?.();
|
|
505
|
+
resolved = true;
|
|
506
|
+
} else {
|
|
507
|
+
resolved = outcome === 'ok';
|
|
508
|
+
if (!resolved) {
|
|
509
|
+
// The member is about to be released for re-drive — free its
|
|
510
|
+
// hot-path dedupe claim first, or this pod rejects its own retry
|
|
511
|
+
// forever (same contract as the legacy ack-failure path).
|
|
512
|
+
hooks.clearDedupe?.();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
247
516
|
}
|
|
248
517
|
} finally {
|
|
249
|
-
settle(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
518
|
+
settle(resolved);
|
|
519
|
+
if (resolved && !viaLegacyAck) {
|
|
520
|
+
// The by-id complete already dropped (or a takeover already owns) the
|
|
521
|
+
// server-side lane row; only the local record and context file remain.
|
|
522
|
+
host.laneLedger.dropLocal(lane.laneKey);
|
|
523
|
+
} else {
|
|
524
|
+
// Two jobs, one call: an unresolved turn (unconsumed / failed /
|
|
525
|
+
// errored / resolution-failed) releases the member back to pending on
|
|
526
|
+
// the redrive budget; a legacy-acked turn still needs its server-side
|
|
527
|
+
// lane row dropped (the ack resolves the row but not the occupancy).
|
|
528
|
+
// A buffered dispatch may outlive this guard (lane TTL) — acceptable
|
|
529
|
+
// at-least-once; the ledger's resolution paths still dedupe the
|
|
530
|
+
// persistent side effects.
|
|
531
|
+
await host.laneLedger.completeIfIdle(lane.laneKey, false).catch(() => {});
|
|
532
|
+
}
|
|
254
533
|
}
|
|
255
534
|
}
|
|
256
535
|
|
|
@@ -268,7 +547,22 @@ export async function consumeMessageWorkItem(
|
|
|
268
547
|
): Promise<void> {
|
|
269
548
|
if (host.shuttingDown) return;
|
|
270
549
|
if (!host.tryClaimMessage(item.source_id)) return;
|
|
550
|
+
// Administrative drop (deleted source / self-sender / skip decision): on
|
|
551
|
+
// the ledger path the by-id complete closes the pending row terminally and
|
|
552
|
+
// refuses (409) a row a live lane owns — the owner's turn resolves it. The
|
|
553
|
+
// legacy ack remains the ledger-disabled / pre-by-id fallback.
|
|
554
|
+
// Fire-and-forget either way: a failed drop leaves the row live and the
|
|
555
|
+
// next re-drive converges on the same drop.
|
|
271
556
|
const ackItem = () => {
|
|
557
|
+
if (host.laneLedger && !host.ledgerDisabled && !host.typedByIdCompleteUnsupported) {
|
|
558
|
+
void resolveDispatchByID(host, item.id).then((outcome) => {
|
|
559
|
+
if (outcome === 'unsupported') {
|
|
560
|
+
host.typedByIdCompleteUnsupported = true;
|
|
561
|
+
host.opts.client.ackDispatchByID(host.opts.config.org_id, item.id).catch(() => {});
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
272
566
|
host.opts.client.ackDispatchByID(host.opts.config.org_id, item.id).catch(() => {});
|
|
273
567
|
};
|
|
274
568
|
|
package/src/index.ts
CHANGED
|
@@ -2,7 +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
|
+
export { consumeTypedDispatch, settleDrainedTypedGroup } from './gateway-lane-flow.js';
|
|
6
6
|
export * from './session-state.js';
|
|
7
7
|
export * from './routing.js';
|
|
8
8
|
export * from './event-format.js';
|
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
|
|
@@ -350,6 +393,20 @@ export class LaneLedger {
|
|
|
350
393
|
return lane;
|
|
351
394
|
}
|
|
352
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Drop a lane's local record without any server call — for a typed lane
|
|
398
|
+
* whose member the by-id complete just resolved (the server dropped the
|
|
399
|
+
* vacated lane row in the same transaction). Calling completeIfIdle
|
|
400
|
+
* instead would fire a lane-form Complete at a lane that no longer exists
|
|
401
|
+
* and burn an RPC on the guaranteed STALE_LANE answer.
|
|
402
|
+
*/
|
|
403
|
+
dropLocal(laneKey: string): void {
|
|
404
|
+
const lane = this.lanes.get(laneKey);
|
|
405
|
+
if (!lane) return;
|
|
406
|
+
this.lanes.delete(laneKey);
|
|
407
|
+
this.removeLaneContext(lane);
|
|
408
|
+
}
|
|
409
|
+
|
|
353
410
|
/**
|
|
354
411
|
* Remove the per-lane context file (and its CLI sidecar) when the lane
|
|
355
412
|
* ends. A leftover file would make a later cross-context send to the same
|
|
@@ -35,7 +35,7 @@ parall schedules create \\
|
|
|
35
35
|
--run-at <FUTURE_RFC3339_TIME>
|
|
36
36
|
\`\`\`
|
|
37
37
|
|
|
38
|
-
\`--target-ids\` is who receives the fire (usually yourself when you're self-scheduling; another agent or human when delegating). \`--attached-to-uri\` optionally anchors the schedule to a task / chat / project / wiki page — when that resource is archived or deleted, the schedule auto-cancels (\`
|
|
38
|
+
\`--target-ids\` is who receives the fire (usually yourself when you're self-scheduling; another agent or human when delegating). \`--attached-to-uri\` optionally anchors the schedule to a task / chat / project / wiki page — when that resource is archived or deleted, the schedule auto-cancels (\`status_reason=attached_gone\`). A schedule whose agent targets all sit on a terminated machine is auto-paused by the platform (\`status_reason=attendee_unreachable\`) instead of firing into a void; resuming a recurring schedule while the machine is still terminated just pauses it again on the next slot (a one-shot resumed past its catch-up window instead follows the normal missed semantics and completes).
|
|
39
39
|
|
|
40
40
|
### Reminders for someone else
|
|
41
41
|
|
|
@@ -34,16 +34,32 @@ parall tasks list --assignee-id prll://usr_xxx # first page only (default 20)
|
|
|
34
34
|
parall tasks subtasks prll://tsk_xxx # children of a single parent task
|
|
35
35
|
|
|
36
36
|
# Create a task (add --parent-id to make it a SUBTASK of another task)
|
|
37
|
-
parall tasks create --title "Task title" [--assignee-id prll://usr_xxx] [--parent-id prll://tsk_xxx] [--project-id prll://prj_xxx]
|
|
37
|
+
parall tasks create --title "Task title" [--assignee-id prll://usr_xxx] [--parent-id prll://tsk_xxx] [--project-id prll://prj_xxx] [--due-date 2026-08-01]
|
|
38
38
|
|
|
39
|
-
# Update task status
|
|
40
|
-
|
|
41
|
-
parall tasks update prll://tsk_xxx --status
|
|
39
|
+
# Update task status — add --placement end so the task lands at the end of
|
|
40
|
+
# its NEW status column (a bare --status keeps the old column's sort_order)
|
|
41
|
+
parall tasks update prll://tsk_xxx --status in_progress --placement end
|
|
42
|
+
parall tasks update prll://tsk_xxx --status done --placement end
|
|
43
|
+
|
|
44
|
+
# Due date — a plain YYYY-MM-DD date (no timestamps); "none" clears it
|
|
45
|
+
parall tasks update prll://tsk_xxx --due-date 2026-08-01
|
|
46
|
+
parall tasks update prll://tsk_xxx --due-date none
|
|
47
|
+
|
|
48
|
+
# Move a task to the end of its status column
|
|
49
|
+
parall tasks update prll://tsk_xxx --placement end
|
|
42
50
|
|
|
43
51
|
# Add a comment
|
|
44
52
|
parall tasks comments add prll://tsk_xxx --body "Progress update..."
|
|
45
53
|
\`\`\`
|
|
46
54
|
|
|
55
|
+
Ordering: to append a task to the end of a status column, always use
|
|
56
|
+
\`--placement end\` — the server resolves the position atomically. This
|
|
57
|
+
includes status changes: a bare \`--status\` keeps the task's old
|
|
58
|
+
\`sort_order\`, which may collide inside the new column. Do NOT compute a
|
|
59
|
+
\`sort_order\` value yourself from listed tasks (your view may be stale or
|
|
60
|
+
partial). \`--sort-order\` is only for pinpoint insertion between two cards
|
|
61
|
+
you just listed, and it cannot be combined with \`--placement\`.
|
|
62
|
+
|
|
47
63
|
Subtasks are just tasks with a parent: create one with \`tasks create --parent-id\`,
|
|
48
64
|
re-parent with \`tasks update --parent-id\`, list a parent's children with
|
|
49
65
|
\`tasks subtasks\`. \`tasks list\` without \`--parent-id\` already returns both
|
|
@@ -78,7 +94,7 @@ watcher.
|
|
|
78
94
|
When you receive \`[Event: task.assigned]\`:
|
|
79
95
|
|
|
80
96
|
1. Acknowledge with a comment: \`tasks comments add prll://tsk_xxx --body "On it"\`
|
|
81
|
-
2. Update status: \`tasks update prll://tsk_xxx --status in_progress\`
|
|
97
|
+
2. Update status: \`tasks update prll://tsk_xxx --status in_progress --placement end\`
|
|
82
98
|
3. Do the work
|
|
83
99
|
4. Report results in a comment. If a gate remains — review, merge, deploy,
|
|
84
100
|
requester acceptance — set \`in_review\` and name the gate; set \`done\`
|
package/src/types.ts
CHANGED
|
@@ -86,7 +86,8 @@ export type ParallEvent = {
|
|
|
86
86
|
| 'comment'
|
|
87
87
|
| 'schedule_run'
|
|
88
88
|
| 'external_trigger_run'
|
|
89
|
-
| 'channel_message'
|
|
89
|
+
| 'channel_message'
|
|
90
|
+
| 'approval';
|
|
90
91
|
ackSourceId?: string;
|
|
91
92
|
/** WorkItem id, when known (dispatch catch-up / re-drive hints carry it; live message.new does not). */
|
|
92
93
|
dispatchEventId?: string;
|