@parall/parel-channel 1.51.0 → 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
package/dist/inbound.d.ts
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
import type { AgentEvent, AgentEventEffect, ConnectorContext, ConnectorEffect, WebSocketFrame } from '@parel/plugin-sdk';
|
|
2
|
-
|
|
3
|
-
* mis-serialization (see the chain doc above). */
|
|
4
|
-
export declare function assertChainConnection(ctx: ConnectorContext): void;
|
|
5
|
-
export declare function serialized<T>(fn: () => Promise<T>): Promise<T>;
|
|
2
|
+
export declare function serialized<T>(ctx: ConnectorContext, fn: () => Promise<T>): Promise<T>;
|
|
6
3
|
export declare function handleParallFrame(frame: WebSocketFrame, ctx: ConnectorContext): Promise<ConnectorEffect[]>;
|
|
7
4
|
/**
|
|
8
|
-
*
|
|
5
|
+
* Timers: the incremental catch-up job's rounds (catchup.ts) and fork retry
|
|
6
|
+
* timers (armed by child_spawn_failed) — the latter re-plan the stranded
|
|
9
7
|
* message through the normal dispatch pipeline. onTimer returns the FULL
|
|
10
|
-
* effect union (unlike onAgentEvent), so
|
|
11
|
-
* The parked row is cleared first — a re-plan that fails transiently
|
|
12
|
-
* the WorkItem in `received` for the
|
|
8
|
+
* effect union (unlike onAgentEvent), so both can emit/spawn/ack.
|
|
9
|
+
* The parked fork row is cleared first — a re-plan that fails transiently
|
|
10
|
+
* leaves the WorkItem in `received` for the catch-up job, same as any
|
|
11
|
+
* dispatch.
|
|
13
12
|
*/
|
|
14
13
|
export declare function handleParallTimer(timer: {
|
|
15
14
|
key: string;
|
|
16
15
|
}, ctx: ConnectorContext): Promise<ConnectorEffect[]>;
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* onOpen: O(1) by the v2 lifecycle contract (I4 — the 15s onOpen budget
|
|
18
|
+
* cannot hold an O(backlog) sweep, and under first-timeout-terminal a slow
|
|
19
|
+
* onOpen is an epoch-rotation storm). Resets the incremental catch-up job
|
|
20
|
+
* and arms its timer; all sweeping — the New Session generation check
|
|
21
|
+
* included — happens in bounded onTimer rounds (catchup.ts). The live WS
|
|
22
|
+
* push still has no replay: the job is the only redelivery path.
|
|
22
23
|
*/
|
|
23
|
-
export declare function
|
|
24
|
+
export declare function handleParallOpen(ctx: ConnectorContext): Promise<ConnectorEffect[]>;
|
|
24
25
|
/**
|
|
25
26
|
* Agent execution events (parel E1+E2, plugin-sdk 0.9.0, opt-in via the
|
|
26
27
|
* channel declaration's `observe: [turn, steps]`).
|
package/dist/inbound.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../src/inbound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACf,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../src/inbound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACf,MAAM,mBAAmB,CAAC;AAyH3B,wBAAgB,UAAU,CAAC,CAAC,EAAE,GAAG,EAAE,gBAAgB,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAWrF;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAE5B;AAoDD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,EACtB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAE5B;AAsCD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAElF;AAwnBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAE7B"}
|
package/dist/inbound.js
CHANGED
|
@@ -1,45 +1,37 @@
|
|
|
1
1
|
import { channelCapabilityEnv } from './channel-capability.js';
|
|
2
2
|
import { buildChatPrompt } from './channel-prompt.js';
|
|
3
|
+
import { armCatchUpEffect, beginCatchUp, CATCHUP_TIMER_KEY, deferToCatchUp, runCatchUpRound, } from './catchup.js';
|
|
3
4
|
import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
|
|
4
5
|
import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
|
|
5
6
|
import { buildForkScopePrefix, bumpForkRetryAttempts, childConfirmed, childRefKnown, clearForkState, clearForkSubject, FORK_RETRY_DELAY_MS, FORK_RETRY_PREFIX, handleChildSpawnFailed, MAX_SPAWN_VERIFY_ATTEMPTS, noteMainTurnEvent, planForkDecision, recordSpawn, settleChildEvent, trackFollowUpAck, } from './fork.js';
|
|
6
|
-
import { ackByIdEffect, claimDispatchSource, completeFencedEnvelopes, completeSourceEffect, conversationKey, createErrorStep,
|
|
7
|
+
import { ackByIdEffect, claimDispatchSource, completeFencedEnvelopes, completeSourceEffect, conversationKey, createErrorStep, createInputStep, createTraceStep, createTypedInputStep, EMIT_TTL_MS, ensureChildSession, ensureSession, invalidateChildSession, invalidateSession, nextEnvelopeId, patchSession, readActiveEmit, reportingEnabled, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
|
|
7
8
|
import { isTypedEventType, planTypedDispatch } from './typed-dispatch.js';
|
|
8
9
|
/**
|
|
9
|
-
* Connection-
|
|
10
|
-
* socket callbacks fire-and-forget (verified against
|
|
11
|
-
* `onMessage: (data) => { this.handleMessage(data).catch(…) }`),
|
|
12
|
-
* frames' async handling can interleave at await points inside one
|
|
13
|
-
* isolate
|
|
10
|
+
* Connection-keyed hook serialization — a v1-host compatibility shim. The v1
|
|
11
|
+
* ConnectorHost dispatches socket callbacks fire-and-forget (verified against
|
|
12
|
+
* connector-host.ts: `onMessage: (data) => { this.handleMessage(data).catch(…) }`),
|
|
13
|
+
* so two frames' async handling can interleave at await points inside one
|
|
14
|
+
* isolate, and every read-then-write protocol here — the turnEnvelope marker,
|
|
14
15
|
* fork planForkDecision→recordSpawn, pendingAck parking — assumes serial
|
|
15
|
-
* execution
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* execution. Under the v2 execution model the HOST owns serialization (I1:
|
|
17
|
+
* one in-flight hook per connection, durable hook queue) and this chain is
|
|
18
|
+
* permanently length ≤1; it must stay until every connection runs v2
|
|
19
|
+
* (executionModelV2 flag), because deleting it on a v1 connection re-exposes
|
|
20
|
+
* the store read-modify-write races. Keyed by connectionId (not module
|
|
21
|
+
* global) so a pooled-isolate host can never cross-serialize two
|
|
22
|
+
* connections — the map is bounded by the isolate's connection count and is
|
|
23
|
+
* a discardable cache like every module global (v2 I4: correctness lives in
|
|
24
|
+
* host serialization + ctx.store). Failures don't break a chain.
|
|
21
25
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (chainConnectionId === undefined) {
|
|
28
|
-
chainConnectionId = ctx.connectionId;
|
|
29
|
-
}
|
|
30
|
-
else if (chainConnectionId !== ctx.connectionId) {
|
|
31
|
-
console.warn(`[parel-channel] hook chain saw a second connection (${chainConnectionId} → ${ctx.connectionId}) — the one-connection-per-isolate assumption broke; key the chain on connectionId`);
|
|
32
|
-
chainConnectionId = ctx.connectionId;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
export function serialized(fn) {
|
|
36
|
-
const next = hookChain.then(fn, fn);
|
|
37
|
-
hookChain = next.then(() => undefined, () => undefined);
|
|
26
|
+
const hookChains = new Map();
|
|
27
|
+
export function serialized(ctx, fn) {
|
|
28
|
+
const chain = hookChains.get(ctx.connectionId) ?? Promise.resolve();
|
|
29
|
+
const next = chain.then(fn, fn);
|
|
30
|
+
hookChains.set(ctx.connectionId, next.then(() => undefined, () => undefined));
|
|
38
31
|
return next;
|
|
39
32
|
}
|
|
40
33
|
export function handleParallFrame(frame, ctx) {
|
|
41
|
-
|
|
42
|
-
return serialized(() => handleParallFrameInner(frame, ctx));
|
|
34
|
+
return serialized(ctx, () => handleParallFrameInner(frame, ctx));
|
|
43
35
|
}
|
|
44
36
|
async function handleParallFrameInner(frame, ctx) {
|
|
45
37
|
let event;
|
|
@@ -69,27 +61,44 @@ async function handleParallFrameInner(frame, ctx) {
|
|
|
69
61
|
}
|
|
70
62
|
if (event.type !== 'dispatch.new' || !event.data)
|
|
71
63
|
return [];
|
|
64
|
+
// FIFO gate: while the incremental catch-up job is draining backlog, a live
|
|
65
|
+
// frame must not leapfrog it into the shared main session (cross-ordering
|
|
66
|
+
// would scramble session input order exactly like concurrent replay). The
|
|
67
|
+
// dispatch row is already durable server-side — deferToCatchUp marks the
|
|
68
|
+
// pass dirty (so the job's next from-the-top pass lists the row even when
|
|
69
|
+
// its cursor already moved past it — a redrive hint must survive being
|
|
70
|
+
// consumed here) and the re-arm doubles as the lost-timer self-heal: any
|
|
71
|
+
// live frame revives a stalled sweep.
|
|
72
|
+
const deferDelay = await deferToCatchUp(ctx);
|
|
73
|
+
if (deferDelay !== null) {
|
|
74
|
+
return [armCatchUpEffect(ctx, deferDelay)];
|
|
75
|
+
}
|
|
72
76
|
try {
|
|
73
77
|
return await effectsForDispatch(event.data, ctx);
|
|
74
78
|
}
|
|
75
79
|
catch (err) {
|
|
76
80
|
// A transient fetch failure on one frame must not fail the frame handler —
|
|
77
|
-
// the dispatch stays pending and the
|
|
81
|
+
// the dispatch stays pending and the catch-up job replays it on reconnect.
|
|
78
82
|
console.error('[parel-channel] dispatch handling failed', err);
|
|
79
83
|
return [];
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
/**
|
|
83
|
-
*
|
|
87
|
+
* Timers: the incremental catch-up job's rounds (catchup.ts) and fork retry
|
|
88
|
+
* timers (armed by child_spawn_failed) — the latter re-plan the stranded
|
|
84
89
|
* message through the normal dispatch pipeline. onTimer returns the FULL
|
|
85
|
-
* effect union (unlike onAgentEvent), so
|
|
86
|
-
* The parked row is cleared first — a re-plan that fails transiently
|
|
87
|
-
* the WorkItem in `received` for the
|
|
90
|
+
* effect union (unlike onAgentEvent), so both can emit/spawn/ack.
|
|
91
|
+
* The parked fork row is cleared first — a re-plan that fails transiently
|
|
92
|
+
* leaves the WorkItem in `received` for the catch-up job, same as any
|
|
93
|
+
* dispatch.
|
|
88
94
|
*/
|
|
89
95
|
export function handleParallTimer(timer, ctx) {
|
|
90
|
-
return serialized(() => handleParallTimerInner(timer, ctx));
|
|
96
|
+
return serialized(ctx, () => handleParallTimerInner(timer, ctx));
|
|
91
97
|
}
|
|
92
98
|
async function handleParallTimerInner(timer, ctx) {
|
|
99
|
+
if (timer.key === CATCHUP_TIMER_KEY) {
|
|
100
|
+
return runCatchUpRound(ctx, (data) => effectsForDispatch(data, ctx));
|
|
101
|
+
}
|
|
93
102
|
if (!timer.key.startsWith(FORK_RETRY_PREFIX))
|
|
94
103
|
return [];
|
|
95
104
|
const parked = (await ctx.store?.get(timer.key).catch(() => null));
|
|
@@ -111,106 +120,25 @@ async function handleParallTimerInner(timer, ctx) {
|
|
|
111
120
|
return effects;
|
|
112
121
|
}
|
|
113
122
|
catch (err) {
|
|
123
|
+
// Transient re-plan failure (source fetch now throws on 5xx): drop the
|
|
124
|
+
// parked row like any non-rearmed outcome — the WorkItem stays received
|
|
125
|
+
// and the catch-up TTL re-emit self-heals, same as before the
|
|
126
|
+
// transient-throw contract.
|
|
114
127
|
console.error('[parel-channel] fork retry re-plan failed', err);
|
|
128
|
+
await ctx.store?.delete(timer.key).catch(() => { });
|
|
115
129
|
return [];
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
132
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
133
|
+
* onOpen: O(1) by the v2 lifecycle contract (I4 — the 15s onOpen budget
|
|
134
|
+
* cannot hold an O(backlog) sweep, and under first-timeout-terminal a slow
|
|
135
|
+
* onOpen is an epoch-rotation storm). Resets the incremental catch-up job
|
|
136
|
+
* and arms its timer; all sweeping — the New Session generation check
|
|
137
|
+
* included — happens in bounded onTimer rounds (catchup.ts). The live WS
|
|
138
|
+
* push still has no replay: the job is the only redelivery path.
|
|
123
139
|
*/
|
|
124
|
-
export function
|
|
125
|
-
return serialized(() =>
|
|
126
|
-
}
|
|
127
|
-
async function catchUpParallInner(ctx) {
|
|
128
|
-
// New Session generation check (must run before any dispatch replay): the
|
|
129
|
-
// `agent.new_session` WS push is not replayed, so a reset that happened
|
|
130
|
-
// while this connection was down would leave stale fork routing behind —
|
|
131
|
-
// post-reset messages would land in pre-reset children. The main ase_ IS
|
|
132
|
-
// the generation: New Session closes it and the next get-or-create mints a
|
|
133
|
-
// fresh id, so compare the cached id against the server's current one and
|
|
134
|
-
// purge fork state on rotation. Every missed-event scenario passes through
|
|
135
|
-
// onOpen (a live socket delivers the event; a dead one reconnects here).
|
|
136
|
-
// The cached id is never dropped before the fresh (bypass-cache) read
|
|
137
|
-
// succeeds — it is the only generation token, and losing it to a transient
|
|
138
|
-
// /sessions failure would silently skip the purge until the fork TTLs
|
|
139
|
-
// expire.
|
|
140
|
-
try {
|
|
141
|
-
const cachedMain = (await ctx.store?.get(SESSION_CACHE_KEY).catch(() => null));
|
|
142
|
-
if (cachedMain) {
|
|
143
|
-
const freshMain = await fetchMainSessionFresh(ctx);
|
|
144
|
-
if (freshMain && freshMain !== cachedMain) {
|
|
145
|
-
const refs = await clearForkState(ctx);
|
|
146
|
-
for (const ref of refs) {
|
|
147
|
-
await invalidateChildSession(ctx, ref);
|
|
148
|
-
}
|
|
149
|
-
console.warn('[parel-channel] main session rotated while offline — fork state purged');
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
// Capability revalidation: a parked forkDisabled latch reflects the
|
|
153
|
-
// binding shape at failure time; the fleet sweep converges bindings
|
|
154
|
-
// without restarting the connection, so a reconnect is the natural
|
|
155
|
-
// revalidation point. If forking is truly still disabled, the first
|
|
156
|
-
// spawn attempt re-parks the latch at the cost of one failed effect.
|
|
157
|
-
await ctx.store?.delete('forkDisabled').catch(() => { });
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
console.error('[parel-channel] new-session generation check failed', err);
|
|
161
|
-
}
|
|
162
|
-
const effects = [];
|
|
163
|
-
try {
|
|
164
|
-
const apiUrl = parallApiUrl(ctx);
|
|
165
|
-
const agk = requireAgk(ctx);
|
|
166
|
-
const orgId = parallOrgId(ctx);
|
|
167
|
-
let cursor = '';
|
|
168
|
-
// Page bound is a wedge guard, not a coverage target. Unknown dispatch
|
|
169
|
-
// types stay pending (deliberately — see effectsForDispatch) and the
|
|
170
|
-
// list is FIFO, so the bound must comfortably exceed any realistic
|
|
171
|
-
// unsupported backlog or old rows could starve newer DMs out of the sweep.
|
|
172
|
-
// 40 pages ≈ 2000 dispatches per sweep; log when we hit the ceiling so a
|
|
173
|
-
// pathological backlog is visible instead of silent.
|
|
174
|
-
const maxPages = 40;
|
|
175
|
-
let page = 0;
|
|
176
|
-
for (; page < maxPages; page++) {
|
|
177
|
-
const url = `${apiUrl}/api/v1/orgs/${orgId}/dispatch?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
|
178
|
-
const res = await fetch(url, {
|
|
179
|
-
headers: { Authorization: `Bearer ${agk}` },
|
|
180
|
-
signal: AbortSignal.timeout(10_000),
|
|
181
|
-
});
|
|
182
|
-
if (!res.ok)
|
|
183
|
-
break;
|
|
184
|
-
const body = (await res.json().catch(() => null));
|
|
185
|
-
// ALL dispatches replay SEQUENTIALLY, in page order (FIFO): every
|
|
186
|
-
// envelope feeds the agent's one main session, so cross-chat replay
|
|
187
|
-
// concurrency would scramble the session's input order AND race the
|
|
188
|
-
// agent-global turnEnvelope marker (a slower old dispatch could
|
|
189
|
-
// overwrite a newer one's marker and mis-arm the done-signal guard).
|
|
190
|
-
// Reconnect backlogs are small; serialization is the correctness
|
|
191
|
-
// posture, not a throughput concern. A failed dispatch resolves to []
|
|
192
|
-
// and stays pending for the next sweep.
|
|
193
|
-
for (const d of body?.data ?? []) {
|
|
194
|
-
const out = await effectsForDispatch(d, ctx).catch((err) => {
|
|
195
|
-
console.error('[parel-channel] catch-up dispatch failed', err);
|
|
196
|
-
return [];
|
|
197
|
-
});
|
|
198
|
-
effects.push(...out);
|
|
199
|
-
}
|
|
200
|
-
if (!body?.next_cursor)
|
|
201
|
-
break;
|
|
202
|
-
cursor = body.next_cursor;
|
|
203
|
-
}
|
|
204
|
-
if (page === maxPages) {
|
|
205
|
-
console.warn('[parel-channel] catch-up hit the page ceiling; remainder deferred to the next reconnect sweep');
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
catch (err) {
|
|
209
|
-
// Catch-up is best-effort: keep whatever pages completed and never fail
|
|
210
|
-
// the connection open over it.
|
|
211
|
-
console.error('[parel-channel] dispatch catch-up failed', err);
|
|
212
|
-
}
|
|
213
|
-
return effects;
|
|
140
|
+
export function handleParallOpen(ctx) {
|
|
141
|
+
return serialized(ctx, () => beginCatchUp(ctx));
|
|
214
142
|
}
|
|
215
143
|
/**
|
|
216
144
|
* Turn-in-flight guard for redelivery (catch-up sweep AND repeated
|
|
@@ -284,15 +212,18 @@ async function effectsForDispatch(data, ctx) {
|
|
|
284
212
|
// Pull the source message text (global by-id read; ACL = chat membership).
|
|
285
213
|
const m = await fetch(`${apiUrl}/api/v1/messages/${sourceId}`, {
|
|
286
214
|
headers: { Authorization: `Bearer ${agk}` },
|
|
287
|
-
signal: AbortSignal.timeout(
|
|
215
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
288
216
|
});
|
|
289
217
|
if (!m.ok) {
|
|
290
218
|
if (m.status === 404 || m.status === 403) {
|
|
291
219
|
// Deleted message / lost chat access — permanently unusable, drop it.
|
|
292
220
|
return [completeSourceEffect(ctx, sourceType, sourceId)];
|
|
293
221
|
}
|
|
294
|
-
// Transient failure: keep pending
|
|
295
|
-
|
|
222
|
+
// Transient failure: keep pending — and THROW rather than return [], so
|
|
223
|
+
// the catch-up job can tell a starving row from a deliberately-pending
|
|
224
|
+
// one and re-arm with backoff (the live frame path catches at
|
|
225
|
+
// handleParallFrameInner; same visible outcome there).
|
|
226
|
+
throw new Error(`transient message fetch failure (${m.status})`);
|
|
296
227
|
}
|
|
297
228
|
const msg = (await m.json().catch(() => null));
|
|
298
229
|
const text = msg?.content?.text ?? '';
|
|
@@ -317,11 +248,11 @@ async function effectsForDispatch(data, ctx) {
|
|
|
317
248
|
// Frame the message with its chat of origin: all chats share the agent's
|
|
318
249
|
// one main session, so the model must see WHERE each message comes from or
|
|
319
250
|
// it answers chat B inside chat A's train of thought. The name is cosmetic
|
|
320
|
-
// garnish
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
251
|
+
// garnish resolved inline under the tight miss-path timeout (cached
|
|
252
|
+
// per-chat afterward; a degraded lookup frames id-only — the id alone
|
|
253
|
+
// already marks the context switch). See cachedChatName for why inline:
|
|
254
|
+
// the v2 capture window drops post-hook store writes, so a background
|
|
255
|
+
// prefetch could never persist its cache row.
|
|
325
256
|
const chatName = await cachedChatName(ctx, chatId);
|
|
326
257
|
const senderId = msg?.sender_id ? String(msg.sender_id) : undefined;
|
|
327
258
|
const framedText = buildChatPrompt({
|
|
@@ -410,12 +341,22 @@ async function effectsForDispatch(data, ctx) {
|
|
|
410
341
|
messageId: sourceId,
|
|
411
342
|
senderId,
|
|
412
343
|
text: stepText,
|
|
344
|
+
threadRootId,
|
|
413
345
|
}),
|
|
414
346
|
]);
|
|
415
347
|
// A closed child ase_ (New Session closed the whole family) just drops
|
|
416
348
|
// the cache; the next event rebuilds it.
|
|
417
|
-
if (results.includes('stale'))
|
|
349
|
+
if (results.includes('stale')) {
|
|
418
350
|
await invalidateChildSession(ctx, decision.childRef);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// Child turns get the same message↔session linking as main: the CLI
|
|
354
|
+
// reads PRLL_SESSION_ID into its sends' agent_session_id, and the
|
|
355
|
+
// childEnvelope below shares this context object. A stale childAse
|
|
356
|
+
// must never be stamped — attribution must not point at a closed
|
|
357
|
+
// session (same rule as the main path's repair branch).
|
|
358
|
+
invocationContext.PRLL_SESSION_ID = childAse;
|
|
359
|
+
}
|
|
419
360
|
}
|
|
420
361
|
await receivedSettled;
|
|
421
362
|
const childEnvelope = {
|
|
@@ -518,6 +459,7 @@ async function effectsForDispatch(data, ctx) {
|
|
|
518
459
|
messageId: sourceId,
|
|
519
460
|
senderId,
|
|
520
461
|
text: stepText,
|
|
462
|
+
threadRootId,
|
|
521
463
|
}),
|
|
522
464
|
]);
|
|
523
465
|
if (results.includes('stale'))
|
|
@@ -525,13 +467,14 @@ async function effectsForDispatch(data, ctx) {
|
|
|
525
467
|
}
|
|
526
468
|
await recordSpawn(ctx, subject, decision.childRef, { dispatchId, sourceId });
|
|
527
469
|
await receivedSettled;
|
|
528
|
-
// Known gap: SpawnChildSessionEffect (
|
|
529
|
-
// context, so the fork's OPENING turn runs without the
|
|
530
|
-
// (lark-cli workspace ops would need a follow-up turn
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
// parel
|
|
470
|
+
// Known gap: SpawnChildSessionEffect (plugin-sdk 0.12) carries no
|
|
471
|
+
// invocation context, so the fork's OPENING turn runs without the
|
|
472
|
+
// capability env (lark-cli workspace ops would need a follow-up turn)
|
|
473
|
+
// AND without PRLL_SESSION_ID (its reply links via dispatch provenance
|
|
474
|
+
// instead) — deliverTo follow-ups inherit the full context, including
|
|
475
|
+
// the session stamp. Channel replies are unaffected: channel_message
|
|
476
|
+
// dispatches always ride main. Fix belongs parel-side (context on
|
|
477
|
+
// spawnChildSession) — tracked as a parel ask.
|
|
535
478
|
return [
|
|
536
479
|
{
|
|
537
480
|
type: 'spawnChildSession',
|
|
@@ -575,32 +518,46 @@ async function effectsForDispatch(data, ctx) {
|
|
|
575
518
|
messageId: sourceId,
|
|
576
519
|
senderId,
|
|
577
520
|
text: stepText,
|
|
521
|
+
threadRootId,
|
|
578
522
|
}),
|
|
579
523
|
]);
|
|
580
524
|
const results = await report(sessionId);
|
|
581
525
|
if (results.includes('stale')) {
|
|
582
526
|
// The cached session was closed under us (e.g. New Session). The
|
|
583
527
|
// get-or-create falls through terminal rows, so one rebuild + retry
|
|
584
|
-
// repairs reporting for this and every later dispatch.
|
|
528
|
+
// repairs reporting for this and every later dispatch. Attribution
|
|
529
|
+
// (PRLL_SESSION_ID + the fence) must never keep pointing at the closed
|
|
530
|
+
// session — clear it, and restore only from a successful fresh report.
|
|
585
531
|
await invalidateSession(ctx);
|
|
532
|
+
sessionId = null;
|
|
586
533
|
const fresh = await ensureSession(ctx);
|
|
587
|
-
if (fresh
|
|
588
|
-
await report(fresh);
|
|
589
|
-
|
|
534
|
+
if (fresh) {
|
|
535
|
+
const retry = await report(fresh);
|
|
536
|
+
if (!retry.includes('stale'))
|
|
537
|
+
sessionId = fresh;
|
|
590
538
|
}
|
|
591
539
|
}
|
|
592
540
|
}
|
|
541
|
+
// The CLI reads PRLL_SESSION_ID into the send's agent_session_id, giving
|
|
542
|
+
// the reply message its ase_ provenance (message ↔ session linking; the
|
|
543
|
+
// server validates ownership). Injected after the session resolves — the
|
|
544
|
+
// envelope's context object is still ours until the emit effect returns.
|
|
545
|
+
if (sessionId) {
|
|
546
|
+
invocationContext.PRLL_SESSION_ID = sessionId;
|
|
547
|
+
}
|
|
593
548
|
await receivedSettled;
|
|
594
549
|
// Arm the attempt fence BEFORE the emit effect is returned: turn events
|
|
595
550
|
// complete a source only while `active:{source}` matches their envelope id
|
|
596
551
|
// exactly, and the redelivery-skip window starts at this timestamp. Only
|
|
597
552
|
// the main path writes it — fork paths keep their own ack machinery.
|
|
553
|
+
// The fence also carries this attempt's session (attribution for the
|
|
554
|
+
// turn-end ledger close — see ActiveEmit).
|
|
598
555
|
// Deliberate ordering tradeoff: a crash between this write and the host
|
|
599
556
|
// executing the emit leaves an armed fence for an un-emitted source, so
|
|
600
557
|
// the reconnect sweep hides it until the TTL re-emit (≤30min delay). The
|
|
601
558
|
// reverse order (fence after emit) would instead double-emit on a crash —
|
|
602
559
|
// a duplicate agent turn. Delay heals; duplicates don't.
|
|
603
|
-
await writeActiveEmit(ctx, sourceType, sourceId, envelopeId);
|
|
560
|
+
await writeActiveEmit(ctx, sourceType, sourceId, envelopeId, sessionId ?? undefined);
|
|
604
561
|
// No ack effect: the row stays 'received' until the reply (dispatch
|
|
605
562
|
// _source) or the turn event (complete-sources) resolves it — the
|
|
606
563
|
// indicator's whole lifecycle rides on that.
|
|
@@ -712,60 +669,65 @@ async function effectsForTypedDispatch(data, ctx) {
|
|
|
712
669
|
}
|
|
713
670
|
/**
|
|
714
671
|
* How long a cached chat name is served without revalidation. Chat renames
|
|
715
|
-
* are rare and the frame is the model's cross-chat context anchor, so
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
* the row for later messages.
|
|
672
|
+
* are rare and the frame is the model's cross-chat context anchor, so past
|
|
673
|
+
* the TTL the row is refreshed inline (bounded) and the old name serves as
|
|
674
|
+
* the fallback when the refresh degrades.
|
|
719
675
|
*/
|
|
720
676
|
const CHAT_NAME_TTL_MS = 60 * 60 * 1000;
|
|
721
677
|
/**
|
|
722
|
-
* chat id → display name for prompt framing
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
678
|
+
* chat id → display name for prompt framing. Resolved INLINE on a miss or a
|
|
679
|
+
* TTL-stale row, under the tight miss-path timeout (3s): the v2 capture
|
|
680
|
+
* window drops any store write landing after the hook returns, so the old
|
|
681
|
+
* fire-and-forget prefetch would never persist its cache row — every message
|
|
682
|
+
* would re-prefetch and the name would never appear. Inline is bounded and
|
|
683
|
+
* self-limiting (per-chat cached after the first resolve; the catch-up job's
|
|
684
|
+
* round budget accounts for cold items). Failures degrade: a miss frames
|
|
685
|
+
* id-only (the id is the correctness anchor, the name is garnish), a stale
|
|
686
|
+
* row serves the old name. DMs have no name — the empty name is cached as a
|
|
687
|
+
* "resolved, nameless" sentinel so they don't re-fetch on every message.
|
|
688
|
+
* (Pre-TTL rows were plain strings — served once and upgraded by the same
|
|
689
|
+
* inline refresh.)
|
|
734
690
|
*/
|
|
735
691
|
async function cachedChatName(ctx, chatId) {
|
|
736
692
|
const cacheKey = `chatName:${chatId}`;
|
|
737
693
|
const cached = await ctx.store?.get(cacheKey).catch(() => null);
|
|
694
|
+
let staleName;
|
|
738
695
|
if (cached &&
|
|
739
696
|
typeof cached === 'object' &&
|
|
740
697
|
typeof cached.name === 'string') {
|
|
741
698
|
const { name, at } = cached;
|
|
742
|
-
if (typeof at
|
|
743
|
-
|
|
699
|
+
if (typeof at === 'number' && ctx.now() - at <= CHAT_NAME_TTL_MS) {
|
|
700
|
+
return name || undefined;
|
|
744
701
|
}
|
|
745
|
-
|
|
702
|
+
staleName = name || undefined;
|
|
746
703
|
}
|
|
747
|
-
if (typeof cached === 'string') {
|
|
748
|
-
// Legacy plain-string row:
|
|
749
|
-
|
|
750
|
-
return cached || undefined;
|
|
704
|
+
else if (typeof cached === 'string') {
|
|
705
|
+
// Legacy plain-string row: refresh inline below, serve it as fallback.
|
|
706
|
+
staleName = cached || undefined;
|
|
751
707
|
}
|
|
752
|
-
|
|
753
|
-
|
|
708
|
+
const fetched = await fetchChatName(ctx, chatId, cacheKey);
|
|
709
|
+
// null means the lookup degraded (serve the stale name if we have one);
|
|
710
|
+
// undefined means resolved-and-nameless (DM sentinel / cleared name) and
|
|
711
|
+
// must NOT fall back to a stale name — `??` would conflate the two.
|
|
712
|
+
return fetched === null ? staleName : fetched;
|
|
754
713
|
}
|
|
755
|
-
|
|
714
|
+
/** Resolve + cache one chat's name; null when the lookup degrades. */
|
|
715
|
+
async function fetchChatName(ctx, chatId, cacheKey) {
|
|
756
716
|
try {
|
|
757
717
|
const res = await fetch(`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/chats/${chatId}`, {
|
|
758
718
|
headers: { Authorization: `Bearer ${requireAgk(ctx)}` },
|
|
759
719
|
signal: AbortSignal.timeout(PROVIDER_LOOKUP_TIMEOUT_MS),
|
|
760
720
|
});
|
|
761
721
|
if (!res.ok)
|
|
762
|
-
return;
|
|
722
|
+
return null;
|
|
763
723
|
const body = (await res.json().catch(() => null));
|
|
764
724
|
const name = typeof body?.name === 'string' ? body.name : '';
|
|
765
725
|
await ctx.store?.set(cacheKey, { name, at: ctx.now() }).catch(() => { });
|
|
726
|
+
return name || undefined;
|
|
766
727
|
}
|
|
767
728
|
catch {
|
|
768
|
-
// Cosmetic
|
|
729
|
+
// Cosmetic lookup — the next message from this chat retries.
|
|
730
|
+
return null;
|
|
769
731
|
}
|
|
770
732
|
}
|
|
771
733
|
/**
|
|
@@ -786,7 +748,7 @@ async function prefetchChatName(ctx, chatId, cacheKey) {
|
|
|
786
748
|
* missed trace step is a gap in the panel, nothing more.
|
|
787
749
|
*/
|
|
788
750
|
export function handleParallAgentEvent(event, ctx) {
|
|
789
|
-
return serialized(() => handleParallAgentEventInner(event, ctx));
|
|
751
|
+
return serialized(ctx, () => handleParallAgentEventInner(event, ctx));
|
|
790
752
|
}
|
|
791
753
|
async function handleParallAgentEventInner(event, ctx) {
|
|
792
754
|
// spawnChildSession's asynchronous error channel — not turn-scoped and
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { connectParall } from './connect.js';
|
|
2
2
|
import { buildParallDelivery } from './delivery.js';
|
|
3
|
-
import {
|
|
3
|
+
import { handleParallAgentEvent, handleParallFrame, handleParallOpen, handleParallTimer, } from './inbound.js';
|
|
4
4
|
/**
|
|
5
5
|
* Parall channel plugin for the parel runtime.
|
|
6
6
|
*
|
|
@@ -17,7 +17,7 @@ import { catchUpParall, handleParallAgentEvent, handleParallFrame, handleParallT
|
|
|
17
17
|
*/
|
|
18
18
|
const connector = {
|
|
19
19
|
connect: connectParall,
|
|
20
|
-
onOpen:
|
|
20
|
+
onOpen: handleParallOpen,
|
|
21
21
|
onMessage: handleParallFrame,
|
|
22
22
|
onTimer: handleParallTimer,
|
|
23
23
|
onAgentEvent: handleParallAgentEvent,
|