@parall/parel-channel 1.50.1 → 1.52.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/catchup.d.ts +79 -0
- package/dist/catchup.d.ts.map +1 -0
- package/dist/catchup.js +296 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +4 -2
- package/dist/fork.d.ts +7 -0
- package/dist/fork.d.ts.map +1 -1
- package/dist/fork.js +14 -2
- package/dist/inbound-channel.d.ts.map +1 -1
- package/dist/inbound-channel.js +55 -38
- package/dist/inbound.d.ts +14 -13
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +142 -180
- package/dist/index.js +2 -2
- package/dist/session.d.ts +37 -9
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +63 -19
- package/dist/typed-dispatch.d.ts +12 -6
- package/dist/typed-dispatch.d.ts.map +1 -1
- package/dist/typed-dispatch.js +65 -53
- package/package.json +1 -1
- package/src/catchup.ts +337 -0
- package/src/connect.ts +4 -2
- package/src/fork.ts +17 -2
- package/src/inbound-channel.ts +59 -44
- package/src/inbound.ts +156 -192
- package/src/index.ts +2 -2
- package/src/session.ts +80 -19
- package/src/typed-dispatch.ts +63 -46
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Incremental, timer-driven dispatch catch-up (execution-model v2 I4:
|
|
4
|
+
* lifecycle hooks are O(1); any O(backlog) work must be a durable, resumable,
|
|
5
|
+
* timer-driven job — parel channel-connector-execution-model.md §2).
|
|
6
|
+
*
|
|
7
|
+
* The 1.48.0 shape — onOpen sweeping up to 40×50 dispatches inline — cannot
|
|
8
|
+
* live under the v2 15s onOpen budget (a backlog would blow the budget, and
|
|
9
|
+
* under first-timeout-terminal semantics every timeout is an epoch rotation:
|
|
10
|
+
* the sweep would convert a reconnect into a rotation storm). Instead:
|
|
11
|
+
*
|
|
12
|
+
* onOpen → reset the sweep state, arm CATCHUP_TIMER_KEY at now (O(1))
|
|
13
|
+
* onTimer → one bounded round per firing:
|
|
14
|
+
* round 0 (phase 'gen'): the New Session generation check — MUST precede
|
|
15
|
+
* any dispatch replay (a rotation that happened while offline would
|
|
16
|
+
* otherwise replay post-reset messages into pre-reset forks), plus the
|
|
17
|
+
* forkDisabled revalidation-latch clear. Best-effort, same posture the
|
|
18
|
+
* onOpen sweep had: a transient failure logs and the sweep proceeds
|
|
19
|
+
* (the cached session id — the generation token — is never dropped).
|
|
20
|
+
* sweep rounds: ONE page of GET /dispatch, items replayed FIFO through
|
|
21
|
+
* the normal dispatch pipeline, then re-arm immediately while pages
|
|
22
|
+
* remain. Cursor lives in ctx.store; it is an optimization, not a
|
|
23
|
+
* correctness source — the per-source attempt fence (shouldSkipLiveRow)
|
|
24
|
+
* makes any re-scan idempotent, so a mid-page bail or a crash simply
|
|
25
|
+
* re-lists the same page and skips what already emitted.
|
|
26
|
+
* drain: a pass that ends (no next_cursor) after emitting anything runs
|
|
27
|
+
* one more pass from the top (rows the cursor walk missed, rows that
|
|
28
|
+
* arrived mid-sweep); the job ends on the first CLEAN pass — no emits,
|
|
29
|
+
* no item failures. Item failures keep the row pending server-side and
|
|
30
|
+
* re-arm the next pass with exponential backoff instead of ending, so
|
|
31
|
+
* a transiently unfetchable source is retried without a reconnect.
|
|
32
|
+
*
|
|
33
|
+
* FIFO across the live socket: while a sweep is in flight, dispatch.new
|
|
34
|
+
* frames are NOT processed inline (they would leapfrog the backlog into the
|
|
35
|
+
* shared main session out of order) — the frame handler just re-arms the
|
|
36
|
+
* timer (armCatchUpEffect) and lets the sweep list the row, which is already
|
|
37
|
+
* durable server-side. That re-arm doubles as the lost-timer self-heal: any
|
|
38
|
+
* new frame revives a stalled sweep. The re-arm honours an in-flight backoff
|
|
39
|
+
* (state.notBefore): a frame arriving while a round is backed off on a 5xx
|
|
40
|
+
* source re-arms at the remaining delay, not 0, so steady traffic against a
|
|
41
|
+
* failing source cannot tight-poll it.
|
|
42
|
+
*
|
|
43
|
+
* Runs identically under the v1 host (flag off): timers and effects work the
|
|
44
|
+
* same there, rounds serialize through the connection hook chain, and the
|
|
45
|
+
* bounded round size keeps each onTimer call small.
|
|
46
|
+
*/
|
|
47
|
+
export declare const CATCHUP_TIMER_KEY = "__parall_catchup__";
|
|
48
|
+
/** The idempotent "make sure the sweep timer is armed" effect (setTimer is
|
|
49
|
+
* keyed — re-arming replaces). Also what the frame handler returns while a
|
|
50
|
+
* sweep is in flight. */
|
|
51
|
+
export declare function armCatchUpEffect(ctx: ConnectorContext, delayMs?: number): ConnectorEffect;
|
|
52
|
+
/**
|
|
53
|
+
* onOpen: O(1) by contract — reset the sweep to a fresh pass (a reconnect is
|
|
54
|
+
* the revalidation point, so the generation check must run again) and arm the
|
|
55
|
+
* timer. All real work happens in runCatchUpRound.
|
|
56
|
+
*/
|
|
57
|
+
export declare function beginCatchUp(ctx: ConnectorContext): Promise<ConnectorEffect[]>;
|
|
58
|
+
/**
|
|
59
|
+
* The frame handler's FIFO gate: while a sweep is in flight (state present),
|
|
60
|
+
* a live dispatch.new must not leapfrog the backlog. Returns the delay (ms)
|
|
61
|
+
* the caller should re-arm the sweep timer with — honouring any in-flight
|
|
62
|
+
* backoff (notBefore) so a live frame cannot reset a backed-off round to fire
|
|
63
|
+
* immediately — or null when no sweep is in flight (process the frame inline).
|
|
64
|
+
* Also marks the pass dirty so the job runs another from-the-top pass before
|
|
65
|
+
* ending. The dirty mark is load-bearing, not just ordering hygiene: a server
|
|
66
|
+
* REDRIVE hint can point at a row BEHIND the sweep cursor (e.g. during the
|
|
67
|
+
* final re-check pass), and deferring the frame consumes that hint — without
|
|
68
|
+
* forcing one more full pass, the job could end cleanly with the row
|
|
69
|
+
* stranded until the next reconnect. Runs inside the serialized chain, so
|
|
70
|
+
* the read-modify-write cannot interleave with a round's own state write.
|
|
71
|
+
*/
|
|
72
|
+
export declare function deferToCatchUp(ctx: ConnectorContext): Promise<number | null>;
|
|
73
|
+
/**
|
|
74
|
+
* One timer round. `planDispatch` is the normal inbound dispatch pipeline
|
|
75
|
+
* (inbound.ts effectsForDispatch), injected to keep this module's imports
|
|
76
|
+
* acyclic. Every path either re-arms the timer or deliberately ends the job.
|
|
77
|
+
*/
|
|
78
|
+
export declare function runCatchUpRound(ctx: ConnectorContext, planDispatch: (data: Record<string, unknown>) => Promise<ConnectorEffect[]>): Promise<ConnectorEffect[]>;
|
|
79
|
+
//# sourceMappingURL=catchup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catchup.d.ts","sourceRoot":"","sources":["../src/catchup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAK3E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AA0FtD;;yBAEyB;AACzB,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,EAAE,OAAO,SAAI,GAAG,eAAe,CAEpF;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAGpF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOlF;AAqCD;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,gBAAgB,EACrB,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,eAAe,EAAE,CAAC,GAC1E,OAAO,CAAC,eAAe,EAAE,CAAC,CAgH5B"}
|
package/dist/catchup.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
2
|
+
import { clearForkState, FORK_DISABLED_KEY } from './fork.js';
|
|
3
|
+
import { fetchMainSessionFresh, invalidateChildSession, SESSION_CACHE_KEY } from './session.js';
|
|
4
|
+
/**
|
|
5
|
+
* Incremental, timer-driven dispatch catch-up (execution-model v2 I4:
|
|
6
|
+
* lifecycle hooks are O(1); any O(backlog) work must be a durable, resumable,
|
|
7
|
+
* timer-driven job — parel channel-connector-execution-model.md §2).
|
|
8
|
+
*
|
|
9
|
+
* The 1.48.0 shape — onOpen sweeping up to 40×50 dispatches inline — cannot
|
|
10
|
+
* live under the v2 15s onOpen budget (a backlog would blow the budget, and
|
|
11
|
+
* under first-timeout-terminal semantics every timeout is an epoch rotation:
|
|
12
|
+
* the sweep would convert a reconnect into a rotation storm). Instead:
|
|
13
|
+
*
|
|
14
|
+
* onOpen → reset the sweep state, arm CATCHUP_TIMER_KEY at now (O(1))
|
|
15
|
+
* onTimer → one bounded round per firing:
|
|
16
|
+
* round 0 (phase 'gen'): the New Session generation check — MUST precede
|
|
17
|
+
* any dispatch replay (a rotation that happened while offline would
|
|
18
|
+
* otherwise replay post-reset messages into pre-reset forks), plus the
|
|
19
|
+
* forkDisabled revalidation-latch clear. Best-effort, same posture the
|
|
20
|
+
* onOpen sweep had: a transient failure logs and the sweep proceeds
|
|
21
|
+
* (the cached session id — the generation token — is never dropped).
|
|
22
|
+
* sweep rounds: ONE page of GET /dispatch, items replayed FIFO through
|
|
23
|
+
* the normal dispatch pipeline, then re-arm immediately while pages
|
|
24
|
+
* remain. Cursor lives in ctx.store; it is an optimization, not a
|
|
25
|
+
* correctness source — the per-source attempt fence (shouldSkipLiveRow)
|
|
26
|
+
* makes any re-scan idempotent, so a mid-page bail or a crash simply
|
|
27
|
+
* re-lists the same page and skips what already emitted.
|
|
28
|
+
* drain: a pass that ends (no next_cursor) after emitting anything runs
|
|
29
|
+
* one more pass from the top (rows the cursor walk missed, rows that
|
|
30
|
+
* arrived mid-sweep); the job ends on the first CLEAN pass — no emits,
|
|
31
|
+
* no item failures. Item failures keep the row pending server-side and
|
|
32
|
+
* re-arm the next pass with exponential backoff instead of ending, so
|
|
33
|
+
* a transiently unfetchable source is retried without a reconnect.
|
|
34
|
+
*
|
|
35
|
+
* FIFO across the live socket: while a sweep is in flight, dispatch.new
|
|
36
|
+
* frames are NOT processed inline (they would leapfrog the backlog into the
|
|
37
|
+
* shared main session out of order) — the frame handler just re-arms the
|
|
38
|
+
* timer (armCatchUpEffect) and lets the sweep list the row, which is already
|
|
39
|
+
* durable server-side. That re-arm doubles as the lost-timer self-heal: any
|
|
40
|
+
* new frame revives a stalled sweep. The re-arm honours an in-flight backoff
|
|
41
|
+
* (state.notBefore): a frame arriving while a round is backed off on a 5xx
|
|
42
|
+
* source re-arms at the remaining delay, not 0, so steady traffic against a
|
|
43
|
+
* failing source cannot tight-poll it.
|
|
44
|
+
*
|
|
45
|
+
* Runs identically under the v1 host (flag off): timers and effects work the
|
|
46
|
+
* same there, rounds serialize through the connection hook chain, and the
|
|
47
|
+
* bounded round size keeps each onTimer call small.
|
|
48
|
+
*/
|
|
49
|
+
export const CATCHUP_TIMER_KEY = '__parall_catchup__';
|
|
50
|
+
/** Store key — deliberately OUTSIDE the `fork` prefix family so the
|
|
51
|
+
* agent.new_session purge (clearForkState) never wipes sweep state. */
|
|
52
|
+
const CATCHUP_STATE_KEY = 'catchup';
|
|
53
|
+
/** One page per round; page size == round item bound so a clean round always
|
|
54
|
+
* advances the cursor. Small on purpose: each item can fan out several inline
|
|
55
|
+
* fetches and 1-2 effects, and the round's effects execute while the v2
|
|
56
|
+
* execution slot is held — a big page would occupy it for the whole replay. */
|
|
57
|
+
const CATCHUP_PAGE_LIMIT = 10;
|
|
58
|
+
/** Soft wall-clock bound per round, checked BETWEEN items (an in-flight item
|
|
59
|
+
* finishes). 4s: the binding constraint is the LEGACY (flag-off) host, whose
|
|
60
|
+
* hook budget is 5s and whose timeout discards the round's effects — re-arm
|
|
61
|
+
* included — rather than cancelling the work; staying under it keeps the
|
|
62
|
+
* common case safe on both hosts (v2's onTimer budget is 60s). A single
|
|
63
|
+
* pathological item can still blow past any bound (its own fetch caps are
|
|
64
|
+
* the 5s REQUEST_TIMEOUT_MS); the self-heal for a discarded re-arm is the
|
|
65
|
+
* frame handler's defer path (any live frame re-arms the timer) plus the
|
|
66
|
+
* next reconnect's beginCatchUp. */
|
|
67
|
+
const ROUND_SOFT_BUDGET_MS = 4_000;
|
|
68
|
+
/** Timeout for the job's OWN fetches (dispatch page list, generation check).
|
|
69
|
+
* Tighter than REQUEST_TIMEOUT_MS: on the v1 host these run inside a 5s
|
|
70
|
+
* onTimer budget whose expiry discards the round's effects AFTER the due
|
|
71
|
+
* timer row was already deleted — a single stalled request at the full 5s
|
|
72
|
+
* cap would strand the job with no timer. 3s leaves re-arm margin. */
|
|
73
|
+
const CATCHUP_FETCH_TIMEOUT_MS = 3_000;
|
|
74
|
+
const BACKOFF_BASE_MS = 5_000;
|
|
75
|
+
const BACKOFF_MAX_MS = 5 * 60 * 1000;
|
|
76
|
+
/** Frozen so the store-less fallback (memoryState, written by reference in
|
|
77
|
+
* beginCatchUp) cannot be mutated into a corrupted shared template — every
|
|
78
|
+
* transition already builds a new object via spread. */
|
|
79
|
+
const FRESH_STATE = Object.freeze({
|
|
80
|
+
phase: 'gen',
|
|
81
|
+
cursor: '',
|
|
82
|
+
dirty: false,
|
|
83
|
+
sawFailure: false,
|
|
84
|
+
failures: 0,
|
|
85
|
+
notBefore: 0,
|
|
86
|
+
});
|
|
87
|
+
/** In-memory fallback for store-less contexts (SDK test mocks) — a module
|
|
88
|
+
* global is a discardable cache, never correctness (v2 I4), which is exactly
|
|
89
|
+
* the durability class a fallback may have. */
|
|
90
|
+
const memoryState = new Map();
|
|
91
|
+
async function readState(ctx) {
|
|
92
|
+
if (!ctx.store)
|
|
93
|
+
return memoryState.get(ctx.connectionId) ?? null;
|
|
94
|
+
const raw = (await ctx.store.get(CATCHUP_STATE_KEY).catch(() => null));
|
|
95
|
+
return raw && (raw.phase === 'gen' || raw.phase === 'sweep') ? raw : null;
|
|
96
|
+
}
|
|
97
|
+
async function writeState(ctx, state) {
|
|
98
|
+
if (!ctx.store) {
|
|
99
|
+
memoryState.set(ctx.connectionId, state);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await ctx.store.set(CATCHUP_STATE_KEY, state).catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
async function clearState(ctx) {
|
|
105
|
+
if (!ctx.store) {
|
|
106
|
+
memoryState.delete(ctx.connectionId);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await ctx.store.delete(CATCHUP_STATE_KEY).catch(() => { });
|
|
110
|
+
}
|
|
111
|
+
/** The idempotent "make sure the sweep timer is armed" effect (setTimer is
|
|
112
|
+
* keyed — re-arming replaces). Also what the frame handler returns while a
|
|
113
|
+
* sweep is in flight. */
|
|
114
|
+
export function armCatchUpEffect(ctx, delayMs = 0) {
|
|
115
|
+
return { type: 'setTimer', key: CATCHUP_TIMER_KEY, at: ctx.now() + delayMs };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* onOpen: O(1) by contract — reset the sweep to a fresh pass (a reconnect is
|
|
119
|
+
* the revalidation point, so the generation check must run again) and arm the
|
|
120
|
+
* timer. All real work happens in runCatchUpRound.
|
|
121
|
+
*/
|
|
122
|
+
export async function beginCatchUp(ctx) {
|
|
123
|
+
await writeState(ctx, FRESH_STATE);
|
|
124
|
+
return [armCatchUpEffect(ctx)];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The frame handler's FIFO gate: while a sweep is in flight (state present),
|
|
128
|
+
* a live dispatch.new must not leapfrog the backlog. Returns the delay (ms)
|
|
129
|
+
* the caller should re-arm the sweep timer with — honouring any in-flight
|
|
130
|
+
* backoff (notBefore) so a live frame cannot reset a backed-off round to fire
|
|
131
|
+
* immediately — or null when no sweep is in flight (process the frame inline).
|
|
132
|
+
* Also marks the pass dirty so the job runs another from-the-top pass before
|
|
133
|
+
* ending. The dirty mark is load-bearing, not just ordering hygiene: a server
|
|
134
|
+
* REDRIVE hint can point at a row BEHIND the sweep cursor (e.g. during the
|
|
135
|
+
* final re-check pass), and deferring the frame consumes that hint — without
|
|
136
|
+
* forcing one more full pass, the job could end cleanly with the row
|
|
137
|
+
* stranded until the next reconnect. Runs inside the serialized chain, so
|
|
138
|
+
* the read-modify-write cannot interleave with a round's own state write.
|
|
139
|
+
*/
|
|
140
|
+
export async function deferToCatchUp(ctx) {
|
|
141
|
+
const state = await readState(ctx);
|
|
142
|
+
if (!state)
|
|
143
|
+
return null;
|
|
144
|
+
if (!state.dirty)
|
|
145
|
+
await writeState(ctx, { ...state, dirty: true });
|
|
146
|
+
// Honour an in-flight backoff: re-arming the keyed timer at delay 0 would
|
|
147
|
+
// overwrite a backoffDelay-armed round and tight-poll a failing source.
|
|
148
|
+
return Math.max(0, state.notBefore - ctx.now());
|
|
149
|
+
}
|
|
150
|
+
function backoffDelay(failures) {
|
|
151
|
+
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1), BACKOFF_MAX_MS);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* New Session generation check — verbatim semantics of the 1.48.0 onOpen
|
|
155
|
+
* preamble (see the session.ts fetchMainSessionFresh doc): the
|
|
156
|
+
* `agent.new_session` WS push is not replayed, so a reset that happened while
|
|
157
|
+
* disconnected leaves stale fork routing that would deliver post-reset
|
|
158
|
+
* messages into pre-reset children. The main ase_ id IS the generation token.
|
|
159
|
+
*/
|
|
160
|
+
async function runGenerationCheck(ctx) {
|
|
161
|
+
try {
|
|
162
|
+
const cachedMain = (await ctx.store?.get(SESSION_CACHE_KEY).catch(() => null));
|
|
163
|
+
if (cachedMain) {
|
|
164
|
+
const freshMain = await fetchMainSessionFresh(ctx, { timeoutMs: CATCHUP_FETCH_TIMEOUT_MS });
|
|
165
|
+
if (freshMain && freshMain !== cachedMain) {
|
|
166
|
+
const refs = await clearForkState(ctx);
|
|
167
|
+
for (const ref of refs) {
|
|
168
|
+
await invalidateChildSession(ctx, ref);
|
|
169
|
+
}
|
|
170
|
+
console.warn('[parel-channel] main session rotated while offline — fork state purged');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Capability revalidation: a parked forkDisabled latch reflects the
|
|
174
|
+
// binding shape at failure time; the fleet sweep converges bindings
|
|
175
|
+
// without restarting the connection, so a reconnect is the natural
|
|
176
|
+
// revalidation point. If forking is truly still disabled, the first
|
|
177
|
+
// spawn attempt re-parks the latch at the cost of one failed effect.
|
|
178
|
+
await ctx.store?.delete(FORK_DISABLED_KEY).catch(() => { });
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
console.error('[parel-channel] new-session generation check failed', err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* One timer round. `planDispatch` is the normal inbound dispatch pipeline
|
|
186
|
+
* (inbound.ts effectsForDispatch), injected to keep this module's imports
|
|
187
|
+
* acyclic. Every path either re-arms the timer or deliberately ends the job.
|
|
188
|
+
*/
|
|
189
|
+
export async function runCatchUpRound(ctx, planDispatch) {
|
|
190
|
+
// A missing state row (timer armed by a deferred frame racing the sweep's
|
|
191
|
+
// clean finish, or state lost) starts a fresh sweep — over-sweeping is
|
|
192
|
+
// idempotent (fences), under-sweeping loses messages.
|
|
193
|
+
const state = (await readState(ctx)) ?? FRESH_STATE;
|
|
194
|
+
if (state.phase === 'gen') {
|
|
195
|
+
await runGenerationCheck(ctx);
|
|
196
|
+
await writeState(ctx, { ...FRESH_STATE, phase: 'sweep' });
|
|
197
|
+
return [armCatchUpEffect(ctx)];
|
|
198
|
+
}
|
|
199
|
+
const roundStart = ctx.now();
|
|
200
|
+
let body = null;
|
|
201
|
+
try {
|
|
202
|
+
const apiUrl = parallApiUrl(ctx);
|
|
203
|
+
const agk = requireAgk(ctx);
|
|
204
|
+
const orgId = parallOrgId(ctx);
|
|
205
|
+
const url = `${apiUrl}/api/v1/orgs/${orgId}/dispatch?limit=${CATCHUP_PAGE_LIMIT}${state.cursor ? `&cursor=${encodeURIComponent(state.cursor)}` : ''}`;
|
|
206
|
+
const res = await fetch(url, {
|
|
207
|
+
headers: { Authorization: `Bearer ${agk}` },
|
|
208
|
+
signal: AbortSignal.timeout(CATCHUP_FETCH_TIMEOUT_MS),
|
|
209
|
+
});
|
|
210
|
+
if (res.ok) {
|
|
211
|
+
body = (await res.json().catch(() => null));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// fall through to the transient-failure backoff below
|
|
216
|
+
}
|
|
217
|
+
if (!body) {
|
|
218
|
+
const failures = state.failures + 1;
|
|
219
|
+
const delay = backoffDelay(failures);
|
|
220
|
+
// Persist notBefore so a live frame's defer re-arm honours the backoff
|
|
221
|
+
// instead of resetting it to fire now (tight-polling the failing API).
|
|
222
|
+
await writeState(ctx, { ...state, failures, notBefore: ctx.now() + delay });
|
|
223
|
+
console.error(`[parel-channel] catch-up page fetch failed (attempt ${failures})`);
|
|
224
|
+
return [armCatchUpEffect(ctx, delay)];
|
|
225
|
+
}
|
|
226
|
+
// Replay SEQUENTIALLY, in page order (FIFO): every envelope feeds the
|
|
227
|
+
// agent's one main session, so cross-chat replay concurrency would
|
|
228
|
+
// scramble the session's input order AND race the agent-global
|
|
229
|
+
// turnEnvelope marker. Transient item failures THROW (the pipeline's
|
|
230
|
+
// transient-vs-deliberate contract: a 5xx/timeout on a source fetch
|
|
231
|
+
// throws; deliberate pendings — unknown types, fence skips, disabled
|
|
232
|
+
// deployments — return []): the row stays pending server-side and the
|
|
233
|
+
// failure flag re-arms the next pass with backoff instead of ending the
|
|
234
|
+
// job with the row stranded on a healthy long-lived socket.
|
|
235
|
+
const rows = body.data ?? [];
|
|
236
|
+
const effects = [];
|
|
237
|
+
let dirty = state.dirty;
|
|
238
|
+
let sawFailure = state.sawFailure;
|
|
239
|
+
let processed = 0;
|
|
240
|
+
for (const d of rows) {
|
|
241
|
+
if (ctx.now() - roundStart > ROUND_SOFT_BUDGET_MS)
|
|
242
|
+
break;
|
|
243
|
+
const out = await planDispatch(d).catch((err) => {
|
|
244
|
+
console.error('[parel-channel] catch-up dispatch failed', err);
|
|
245
|
+
sawFailure = true;
|
|
246
|
+
dirty = true;
|
|
247
|
+
return [];
|
|
248
|
+
});
|
|
249
|
+
// Only MAIN-path emits drive the drain re-check: they arm a fence, so
|
|
250
|
+
// the follow-up pass skips them and the job converges. Fork-owned
|
|
251
|
+
// outputs (spawnChildSession / deliverTo emits) never write a fence —
|
|
252
|
+
// counting them would re-check forever, and their fast re-plan would
|
|
253
|
+
// race the fork verification cadence — their loss recovery belongs to
|
|
254
|
+
// the fork machinery (parked acks, retry timer), not this job.
|
|
255
|
+
if (out.some((e) => e.type === 'emitEvent' && !e.deliverTo)) {
|
|
256
|
+
dirty = true;
|
|
257
|
+
}
|
|
258
|
+
effects.push(...out);
|
|
259
|
+
processed++;
|
|
260
|
+
}
|
|
261
|
+
if (processed < rows.length) {
|
|
262
|
+
// Soft budget hit mid-page: keep the cursor (the refetch re-lists this
|
|
263
|
+
// page; already-emitted rows fence-skip) and continue immediately. The
|
|
264
|
+
// failure streak is NOT reset mid-pass — only a failure-free pass end
|
|
265
|
+
// clears it, or a persistent 5xx in a multi-page backlog would compute
|
|
266
|
+
// a streak of 1 forever and rescan at the base delay instead of backing
|
|
267
|
+
// off. The re-arm timer leads the effect list: v2 replays effects in
|
|
268
|
+
// order and a dead-lettered earlier effect must not also cost the job
|
|
269
|
+
// its progression timer (setTimer is host-owned/idempotent — order is
|
|
270
|
+
// free on v1).
|
|
271
|
+
// Progress this round → no backoff: clear notBefore so a lingering value
|
|
272
|
+
// from an earlier failed round can't make a defer honour a stale backoff.
|
|
273
|
+
await writeState(ctx, { ...state, dirty, sawFailure, notBefore: 0 });
|
|
274
|
+
return [armCatchUpEffect(ctx), ...effects];
|
|
275
|
+
}
|
|
276
|
+
if (body.next_cursor) {
|
|
277
|
+
await writeState(ctx, { ...state, cursor: body.next_cursor, dirty, sawFailure, notBefore: 0 });
|
|
278
|
+
return [armCatchUpEffect(ctx), ...effects];
|
|
279
|
+
}
|
|
280
|
+
// Pass complete. Clean pass (nothing emitted, nothing failed) ends the job;
|
|
281
|
+
// a dirty pass runs another full pass from the top — the re-check that
|
|
282
|
+
// makes the cursor an optimization instead of a correctness source.
|
|
283
|
+
if (!dirty) {
|
|
284
|
+
await clearState(ctx);
|
|
285
|
+
return effects;
|
|
286
|
+
}
|
|
287
|
+
const failures = sawFailure ? state.failures + 1 : 0;
|
|
288
|
+
const delay = sawFailure ? backoffDelay(failures) : 0;
|
|
289
|
+
await writeState(ctx, {
|
|
290
|
+
...FRESH_STATE,
|
|
291
|
+
phase: 'sweep',
|
|
292
|
+
failures,
|
|
293
|
+
notBefore: delay > 0 ? ctx.now() + delay : 0,
|
|
294
|
+
});
|
|
295
|
+
return [armCatchUpEffect(ctx, delay), ...effects];
|
|
296
|
+
}
|
package/dist/connect.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAE1E;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC,
|
|
1
|
+
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAE1E;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC,CA2BlF;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE3D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAE7D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAMxD"}
|
package/dist/connect.js
CHANGED
|
@@ -12,11 +12,13 @@ export async function connectParall(ctx) {
|
|
|
12
12
|
const apiUrl = parallApiUrl(ctx);
|
|
13
13
|
const agk = requireAgk(ctx);
|
|
14
14
|
// connect() runs on every (re)connect — fail fast rather than hang the host's
|
|
15
|
-
// reconnect loop on a wedged ticket endpoint.
|
|
15
|
+
// reconnect loop on a wedged ticket endpoint. 5s: the v2 connect budget is
|
|
16
|
+
// 15s and first-timeout rotates the epoch, so the single mint fetch must
|
|
17
|
+
// leave ample margin (session.ts REQUEST_TIMEOUT_MS rationale).
|
|
16
18
|
const res = await fetch(`${apiUrl}/api/v1/ws/ticket`, {
|
|
17
19
|
method: 'POST',
|
|
18
20
|
headers: { Authorization: `Bearer ${agk}` },
|
|
19
|
-
signal: AbortSignal.timeout(
|
|
21
|
+
signal: AbortSignal.timeout(5_000),
|
|
20
22
|
});
|
|
21
23
|
if (!res.ok) {
|
|
22
24
|
throw new Error(`parel-channel: ws ticket failed (${res.status})`);
|
package/dist/fork.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ type ChildSpawnFailedEvent = Extract<AgentEvent, {
|
|
|
6
6
|
/** Delay before a failed spawn's messages are re-driven through onTimer. */
|
|
7
7
|
export declare const FORK_RETRY_DELAY_MS = 15000;
|
|
8
8
|
export declare const FORK_RETRY_PREFIX = "forkRetry:";
|
|
9
|
+
/** Exported for the catch-up job's reconnect revalidation (catchup.ts). */
|
|
10
|
+
export declare const FORK_DISABLED_KEY = "forkDisabled";
|
|
9
11
|
export interface PendingAck {
|
|
10
12
|
dispatchId: string;
|
|
11
13
|
sourceId: string;
|
|
@@ -85,6 +87,11 @@ export declare function clearForkSubject(ctx: ConnectorContext, subject: string,
|
|
|
85
87
|
/**
|
|
86
88
|
* Bump-and-park a fork retry ledger row (also the spawn-verification
|
|
87
89
|
* coordinate onTimer re-plans). Returns the attempt count INCLUDING this one.
|
|
90
|
+
* Attempts count VERIFICATION ROUNDS on the retry timer's cadence, so a bump
|
|
91
|
+
* younger than FORK_RETRY_DELAY_MS is a fast replay (catch-up re-check pass,
|
|
92
|
+
* rapid reconnect double-sweep) and keeps the prior count — without the
|
|
93
|
+
* floor, back-to-back replays would burn through MAX_SPAWN_VERIFY_ATTEMPTS
|
|
94
|
+
* in milliseconds and black-hole a child that was starting up fine.
|
|
88
95
|
*/
|
|
89
96
|
export declare function bumpForkRetryAttempts(ctx: ConnectorContext, retryKey: string, data: Record<string, unknown>): Promise<number>;
|
|
90
97
|
/** Give up re-verifying a spawn after this many timer rounds. */
|
package/dist/fork.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fork.d.ts","sourceRoot":"","sources":["../src/fork.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAEtE,qEAAqE;AACrE,KAAK,qBAAqB,GAAG,OAAO,CAAC,UAAU,EAAE;IAAE,IAAI,EAAE,oBAAoB,CAAA;CAAE,CAAC,CAAC;AA+CjF,4EAA4E;AAC5E,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAS1C,eAAO,MAAM,iBAAiB,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"fork.d.ts","sourceRoot":"","sources":["../src/fork.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAEtE,qEAAqE;AACrE,KAAK,qBAAqB,GAAG,OAAO,CAAC,UAAU,EAAE;IAAE,IAAI,EAAE,oBAAoB,CAAA;CAAE,CAAC,CAAC;AA+CjF,4EAA4E;AAC5E,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAS1C,eAAO,MAAM,iBAAiB,eAAe,CAAC;AAC9C,2EAA2E;AAC3E,eAAO,MAAM,iBAAiB,iBAAiB,CAAC;AAUhD,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,gFAAgF;IAChF,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;2CAEuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE;AAClB;oEACoE;GAClE;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAMxC;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,YAAY,CAAC,CAwCvB;AAED;;sEAEsE;AACtE,wBAAsB,WAAW,CAC/B,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,IAAI,CAAC,CAQf;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,UAAU,GACd,OAAO,CAAC,OAAO,CAAC,CASlB;AAED;;;+EAG+E;AAC/E,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO7F;AAED,0EAA0E;AAC1E,wBAAsB,cAAc,CAClC,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED,8EAA8E;AAC9E,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAMf;AAED;;;;;;;;GAQG;AACH,wBAAsB,qBAAqB,CACzC,GAAG,EAAE,gBAAgB,EACrB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,OAAO,CAAC,MAAM,CAAC,CAgBjB;AAED,iEAAiE;AACjE,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAE3C;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAoB7E;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,gBAAgB,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAc/F;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,gBAAgB,EACrB,KAAK,EAAE,UAAU,GAChB,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAA;CAAE,GAAG,IAAI,CAAC,CA+BhE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,gBAAgB,EACrB,KAAK,EAAE,qBAAqB,GAC3B,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAA;CAAE,CAAC,CA2B3E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,CAYnF"}
|
package/dist/fork.js
CHANGED
|
@@ -47,7 +47,8 @@ const MAX_PENDING_FOLLOWUPS = 20;
|
|
|
47
47
|
const MAX_ACK_ATTEMPTS = 3;
|
|
48
48
|
const MAIN_TURN_KEY = 'forkMainTurn';
|
|
49
49
|
export const FORK_RETRY_PREFIX = 'forkRetry:';
|
|
50
|
-
|
|
50
|
+
/** Exported for the catch-up job's reconnect revalidation (catchup.ts). */
|
|
51
|
+
export const FORK_DISABLED_KEY = 'forkDisabled';
|
|
51
52
|
const childKey = (subject) => `forkChild:${subject}`;
|
|
52
53
|
const childSubjectKey = (childRef) => `forkChildSubject:${childRef}`;
|
|
53
54
|
function isLive(at, ttl, now) {
|
|
@@ -159,11 +160,22 @@ export async function clearForkSubject(ctx, subject, childRef) {
|
|
|
159
160
|
/**
|
|
160
161
|
* Bump-and-park a fork retry ledger row (also the spawn-verification
|
|
161
162
|
* coordinate onTimer re-plans). Returns the attempt count INCLUDING this one.
|
|
163
|
+
* Attempts count VERIFICATION ROUNDS on the retry timer's cadence, so a bump
|
|
164
|
+
* younger than FORK_RETRY_DELAY_MS is a fast replay (catch-up re-check pass,
|
|
165
|
+
* rapid reconnect double-sweep) and keeps the prior count — without the
|
|
166
|
+
* floor, back-to-back replays would burn through MAX_SPAWN_VERIFY_ATTEMPTS
|
|
167
|
+
* in milliseconds and black-hole a child that was starting up fine.
|
|
162
168
|
*/
|
|
163
169
|
export async function bumpForkRetryAttempts(ctx, retryKey, data) {
|
|
164
170
|
const prior = (await ctx.store?.get(retryKey).catch(() => null));
|
|
171
|
+
const now = ctx.now();
|
|
172
|
+
if (typeof prior?.attempts === 'number' &&
|
|
173
|
+
typeof prior.at === 'number' &&
|
|
174
|
+
now - prior.at < FORK_RETRY_DELAY_MS) {
|
|
175
|
+
return prior.attempts;
|
|
176
|
+
}
|
|
165
177
|
const attempts = (prior?.attempts ?? 0) + 1;
|
|
166
|
-
await ctx.store?.set(retryKey, { ...data, attempts }).catch(() => { });
|
|
178
|
+
await ctx.store?.set(retryKey, { ...data, attempts, at: now }).catch(() => { });
|
|
167
179
|
return attempts;
|
|
168
180
|
}
|
|
169
181
|
/** Give up re-verifying a spawn after this many timer rounds. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbound-channel.d.ts","sourceRoot":"","sources":["../src/inbound-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAkB3E;;;;GAIG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"inbound-channel.d.ts","sourceRoot":"","sources":["../src/inbound-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAkB3E;;;;GAIG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAwL5B"}
|
package/dist/inbound-channel.js
CHANGED
|
@@ -43,7 +43,10 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
|
|
|
43
43
|
// takes) — permanently unusable, drop it.
|
|
44
44
|
return [completeSourceEffect(ctx, 'channel_message', sourceId)];
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
// Transient: keep pending — THROW so the catch-up job counts the row as
|
|
47
|
+
// starving and re-arms with backoff (transient-vs-deliberate contract,
|
|
48
|
+
// see inbound.ts message path).
|
|
49
|
+
throw new Error(`transient channel message fetch failure (${m.status})`);
|
|
47
50
|
}
|
|
48
51
|
const msg = (await m.json().catch(() => null));
|
|
49
52
|
const conversationId = msg?.conversation_id ? String(msg.conversation_id) : undefined;
|
|
@@ -63,7 +66,7 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
|
|
|
63
66
|
if (c.status === 404 || c.status === 403) {
|
|
64
67
|
return [completeSourceEffect(ctx, 'channel_message', sourceId)];
|
|
65
68
|
}
|
|
66
|
-
|
|
69
|
+
throw new Error(`transient channel conversation fetch failure (${c.status})`);
|
|
67
70
|
}
|
|
68
71
|
const conv = (await c.json().catch(() => null));
|
|
69
72
|
// Provider (= clip alias) for prompt labeling + the reply hint.
|
|
@@ -82,6 +85,7 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
|
|
|
82
85
|
const receivedSettled = claimDispatchSource(ctx, 'channel_message', sourceId);
|
|
83
86
|
const priorEmit = await readActiveEmit(ctx, 'channel_message', sourceId);
|
|
84
87
|
const envelopeId = nextEnvelopeId('channel_message', sourceId, priorEmit);
|
|
88
|
+
let mappingEffect = null;
|
|
85
89
|
let sessionId = await ensureSession(ctx);
|
|
86
90
|
if (sessionId) {
|
|
87
91
|
await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => { });
|
|
@@ -100,23 +104,41 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
|
|
|
100
104
|
]);
|
|
101
105
|
const results = await report(sessionId);
|
|
102
106
|
if (results.includes('stale')) {
|
|
107
|
+
// Same stale-repair contract as the chat path — and the same
|
|
108
|
+
// attribution rule: never leave sessionId pointing at the closed
|
|
109
|
+
// session (it feeds the fence below).
|
|
103
110
|
await invalidateSession(ctx);
|
|
111
|
+
sessionId = null;
|
|
104
112
|
const fresh = await ensureSession(ctx);
|
|
105
|
-
if (fresh
|
|
106
|
-
await report(fresh);
|
|
107
|
-
|
|
113
|
+
if (fresh) {
|
|
114
|
+
const retry = await report(fresh);
|
|
115
|
+
if (!retry.includes('stale'))
|
|
116
|
+
sessionId = fresh;
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
// Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
|
|
111
|
-
// conversation into its session; last-write-wins on the server)
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// emit
|
|
115
|
-
//
|
|
116
|
-
|
|
120
|
+
// conversation into its session; last-write-wins on the server) as a
|
|
121
|
+
// FETCH EFFECT: host-executed, so it costs the hook budget nothing on
|
|
122
|
+
// the v1 host (a stalled PATCH awaited in-hook could blow the 5s budget
|
|
123
|
+
// and cost the emit), and the v2 outbox replays it with the idempotency
|
|
124
|
+
// key. The memo is written up front (inside the capture window): a
|
|
125
|
+
// dropped effect leaves the mapping stale until the session id next
|
|
126
|
+
// rotates — acceptable for an ops-observability pointer. Skipped when
|
|
127
|
+
// the stale repair above dropped the session — the next message records
|
|
128
|
+
// the fresh one.
|
|
129
|
+
if (sessionId) {
|
|
130
|
+
const mappedKey = `convSession:${conversationId}`;
|
|
131
|
+
const mapped = await ctx.store?.get(mappedKey).catch(() => null);
|
|
132
|
+
if (mapped !== sessionId) {
|
|
133
|
+
await ctx.store?.set(mappedKey, sessionId).catch(() => { });
|
|
134
|
+
mappingEffect = conversationSessionEffect(ctx, conversationId, sessionId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
117
137
|
}
|
|
118
138
|
await receivedSettled;
|
|
119
|
-
|
|
139
|
+
// Fence carries this attempt's session for turn-end attribution (see
|
|
140
|
+
// ActiveEmit) — same contract as the chat-message path.
|
|
141
|
+
await writeActiveEmit(ctx, 'channel_message', sourceId, envelopeId, sessionId ?? undefined);
|
|
120
142
|
// Live capability grant → this turn's lark-cli auth env (mint gate = the
|
|
121
143
|
// no-drift revocation point) + the hint teaching the single reply path.
|
|
122
144
|
const capabilityEnv = await channelCapabilityEnv(ctx);
|
|
@@ -169,8 +191,29 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
|
|
|
169
191
|
},
|
|
170
192
|
// No ack effect — see the message path (inbound.ts): the row stays
|
|
171
193
|
// received until complete-sources closes it.
|
|
194
|
+
...(mappingEffect ? [mappingEffect] : []),
|
|
172
195
|
];
|
|
173
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The chv_ → ase_ mapping push as a host-executed fetch effect (idempotent
|
|
199
|
+
* last-write-wins PATCH; the key dedupes v2 outbox replays of this exact
|
|
200
|
+
* pairing).
|
|
201
|
+
*/
|
|
202
|
+
function conversationSessionEffect(ctx, conversationId, sessionId) {
|
|
203
|
+
return {
|
|
204
|
+
type: 'fetch',
|
|
205
|
+
request: {
|
|
206
|
+
url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/channel-conversations/${conversationId}/session`,
|
|
207
|
+
method: 'PATCH',
|
|
208
|
+
headers: {
|
|
209
|
+
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
210
|
+
'Content-Type': 'application/json',
|
|
211
|
+
},
|
|
212
|
+
body: JSON.stringify({ agent_session_id: sessionId }),
|
|
213
|
+
},
|
|
214
|
+
idempotencyKey: `convmap:${conversationId}:${sessionId}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
174
217
|
/**
|
|
175
218
|
* connection id → provider alias, cached in the durable store (stable
|
|
176
219
|
* mapping; avoids one connection fetch per inbound message). Cache hits are
|
|
@@ -196,29 +239,3 @@ async function resolveChannelProvider(ctx, connectionId, get) {
|
|
|
196
239
|
return undefined;
|
|
197
240
|
}
|
|
198
241
|
}
|
|
199
|
-
/** Push the chv_ → ase_ pointer when it changed; warn-only on failure. */
|
|
200
|
-
async function recordConversationSession(ctx, conversationId, sessionId) {
|
|
201
|
-
const mappedKey = `convSession:${conversationId}`;
|
|
202
|
-
const mapped = await ctx.store?.get(mappedKey).catch(() => null);
|
|
203
|
-
if (mapped === sessionId)
|
|
204
|
-
return;
|
|
205
|
-
try {
|
|
206
|
-
const res = await fetch(`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/channel-conversations/${conversationId}/session`, {
|
|
207
|
-
method: 'PATCH',
|
|
208
|
-
headers: {
|
|
209
|
-
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
210
|
-
'Content-Type': 'application/json',
|
|
211
|
-
},
|
|
212
|
-
body: JSON.stringify({ agent_session_id: sessionId }),
|
|
213
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
214
|
-
});
|
|
215
|
-
if (!res.ok) {
|
|
216
|
-
console.warn(`[parel-channel] conversation session mapping failed (${res.status})`);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
await ctx.store?.set(mappedKey, sessionId).catch(() => { });
|
|
220
|
-
}
|
|
221
|
-
catch (err) {
|
|
222
|
-
console.warn('[parel-channel] conversation session mapping failed', err);
|
|
223
|
-
}
|
|
224
|
-
}
|