@parall/parel-channel 1.40.0 → 1.41.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/channel-prompt.d.ts +9 -1
- package/dist/channel-prompt.d.ts.map +1 -1
- package/dist/channel-prompt.js +17 -0
- package/dist/connect.d.ts +7 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +9 -0
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +38 -10
- package/dist/fork.d.ts +145 -0
- package/dist/fork.d.ts.map +1 -0
- package/dist/fork.js +320 -0
- package/dist/inbound-channel.d.ts +29 -0
- package/dist/inbound-channel.d.ts.map +1 -0
- package/dist/inbound-channel.js +213 -0
- package/dist/inbound.d.ts +14 -0
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +571 -271
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/parel-sdk-compat.d.ts +63 -0
- package/dist/parel-sdk-compat.d.ts.map +1 -0
- package/dist/parel-sdk-compat.js +16 -0
- package/dist/session.d.ts +88 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +225 -11
- package/dist/typed-dispatch.d.ts +76 -0
- package/dist/typed-dispatch.d.ts.map +1 -0
- package/dist/typed-dispatch.js +345 -0
- package/package.json +1 -1
- package/src/channel-prompt.ts +28 -1
- package/src/connect.ts +10 -0
- package/src/delivery.ts +38 -9
- package/src/fork.ts +432 -0
- package/src/inbound-channel.ts +256 -0
- package/src/inbound.ts +634 -303
- package/src/index.ts +7 -1
- package/src/parel-sdk-compat.ts +77 -0
- package/src/session.ts +247 -12
- package/src/typed-dispatch.ts +521 -0
package/src/inbound.ts
CHANGED
|
@@ -5,20 +5,53 @@ import type {
|
|
|
5
5
|
ConnectorEffect,
|
|
6
6
|
WebSocketFrame,
|
|
7
7
|
} from '@parel/plugin-sdk';
|
|
8
|
-
import {
|
|
9
|
-
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
8
|
+
import { buildChatPrompt, type PromptAttachment } from './channel-prompt.js';
|
|
9
|
+
import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
|
|
10
|
+
import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
|
|
10
11
|
import {
|
|
11
|
-
|
|
12
|
+
buildForkScopePrefix,
|
|
13
|
+
bumpForkRetryAttempts,
|
|
14
|
+
childConfirmed,
|
|
15
|
+
childRefKnown,
|
|
16
|
+
clearForkState,
|
|
17
|
+
clearForkSubject,
|
|
18
|
+
FORK_RETRY_DELAY_MS,
|
|
19
|
+
FORK_RETRY_PREFIX,
|
|
20
|
+
handleChildSpawnFailed,
|
|
21
|
+
MAX_SPAWN_VERIFY_ATTEMPTS,
|
|
22
|
+
noteMainTurnEvent,
|
|
23
|
+
planForkDecision,
|
|
24
|
+
recordSpawn,
|
|
25
|
+
settleChildEvent,
|
|
26
|
+
trackFollowUpAck,
|
|
27
|
+
} from './fork.js';
|
|
28
|
+
import {
|
|
29
|
+
type AgentEventWithChild,
|
|
30
|
+
asChildSpawnFailed,
|
|
31
|
+
asConnectorEffects,
|
|
32
|
+
type ExtendedConnectorEffect,
|
|
33
|
+
} from './parel-sdk-compat.js';
|
|
34
|
+
import {
|
|
35
|
+
ackByIdEffect,
|
|
36
|
+
ackEffect,
|
|
37
|
+
conversationKey,
|
|
12
38
|
createErrorStep,
|
|
39
|
+
fetchMainSessionFresh,
|
|
13
40
|
createInputStep,
|
|
14
41
|
createTraceStep,
|
|
42
|
+
createTypedInputStep,
|
|
43
|
+
ensureChildSession,
|
|
15
44
|
ensureSession,
|
|
45
|
+
invalidateChildSession,
|
|
16
46
|
invalidateSession,
|
|
17
47
|
markDispatchReceived,
|
|
18
48
|
patchSession,
|
|
49
|
+
reportingEnabled,
|
|
19
50
|
REQUEST_TIMEOUT_MS,
|
|
51
|
+
SESSION_CACHE_KEY,
|
|
20
52
|
TURN_ENVELOPE_KEY,
|
|
21
53
|
} from './session.js';
|
|
54
|
+
import { isTypedEventType, planTypedDispatch } from './typed-dispatch.js';
|
|
22
55
|
|
|
23
56
|
/**
|
|
24
57
|
* Inbound path: Parall dispatches → agent.
|
|
@@ -39,21 +72,72 @@ import {
|
|
|
39
72
|
* conversations) and mirrors it on agent events for per-chat correlation.
|
|
40
73
|
* Ack-on-emit matches channel-slack-socket.
|
|
41
74
|
*
|
|
42
|
-
* Scope: `source_type === "message"` dispatches (DMs and mentions)
|
|
75
|
+
* Scope: `source_type === "message"` dispatches (DMs and mentions),
|
|
43
76
|
* `source_type === "channel_message"` dispatches (external IM — Feishu/Slack
|
|
44
|
-
* via the platform-mediated channel, keyed by their ChannelConversation)
|
|
45
|
-
*
|
|
46
|
-
*
|
|
77
|
+
* via the platform-mediated channel, keyed by their ChannelConversation),
|
|
78
|
+
* and the typed dispatch family (task_assign / task_update / task_comment /
|
|
79
|
+
* wiki_comment / schedule.fire / external_trigger / approval_decided — see
|
|
80
|
+
* typed-dispatch.ts) are all consumed. Unknown future event types stay
|
|
81
|
+
* pending rather than being acked-and-dropped.
|
|
47
82
|
*/
|
|
48
83
|
|
|
49
84
|
interface DispatchData {
|
|
50
85
|
id?: unknown;
|
|
86
|
+
event_type?: unknown;
|
|
51
87
|
source_type?: unknown;
|
|
52
88
|
source_id?: unknown;
|
|
53
89
|
chat_id?: unknown;
|
|
90
|
+
task_id?: unknown;
|
|
91
|
+
actor_id?: unknown;
|
|
92
|
+
delivery_reason?: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Connection-local hook serialization. The parel ConnectorHost dispatches
|
|
97
|
+
* socket callbacks fire-and-forget (verified against connector-host.ts:
|
|
98
|
+
* `onMessage: (data) => { this.handleMessage(data).catch(…) }`), so two
|
|
99
|
+
* frames' async handling can interleave at await points inside one DO
|
|
100
|
+
* isolate. Every read-then-write protocol here — the turnEnvelope marker,
|
|
101
|
+
* fork planForkDecision→recordSpawn, pendingAck parking — assumes serial
|
|
102
|
+
* execution, so ALL hook bodies run through this one promise chain. Same
|
|
103
|
+
* connection ⇒ same DO isolate ⇒ same module instance, so a module-level
|
|
104
|
+
* chain is exactly connection-scoped TODAY — if parel ever pools multiple
|
|
105
|
+
* connections into one isolate, key this chain on ctx.connectionId before
|
|
106
|
+
* anything else (cross-connection head-of-line blocking would be silent).
|
|
107
|
+
* Failures don't break the chain.
|
|
108
|
+
*/
|
|
109
|
+
let hookChain: Promise<unknown> = Promise.resolve();
|
|
110
|
+
let chainConnectionId: string | undefined;
|
|
111
|
+
/** Surface a violated isolation assumption as a log line, not silent
|
|
112
|
+
* mis-serialization (see the chain doc above). */
|
|
113
|
+
export function assertChainConnection(ctx: ConnectorContext): void {
|
|
114
|
+
if (chainConnectionId === undefined) {
|
|
115
|
+
chainConnectionId = ctx.connectionId;
|
|
116
|
+
} else if (chainConnectionId !== ctx.connectionId) {
|
|
117
|
+
console.warn(
|
|
118
|
+
`[parel-channel] hook chain saw a second connection (${chainConnectionId} → ${ctx.connectionId}) — the one-connection-per-isolate assumption broke; key the chain on connectionId`,
|
|
119
|
+
);
|
|
120
|
+
chainConnectionId = ctx.connectionId;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function serialized<T>(fn: () => Promise<T>): Promise<T> {
|
|
124
|
+
const next = hookChain.then(fn, fn);
|
|
125
|
+
hookChain = next.then(
|
|
126
|
+
() => undefined,
|
|
127
|
+
() => undefined,
|
|
128
|
+
);
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function handleParallFrame(
|
|
133
|
+
frame: WebSocketFrame,
|
|
134
|
+
ctx: ConnectorContext,
|
|
135
|
+
): Promise<ConnectorEffect[]> {
|
|
136
|
+
assertChainConnection(ctx);
|
|
137
|
+
return serialized(() => handleParallFrameInner(frame, ctx));
|
|
54
138
|
}
|
|
55
139
|
|
|
56
|
-
|
|
140
|
+
async function handleParallFrameInner(
|
|
57
141
|
frame: WebSocketFrame,
|
|
58
142
|
ctx: ConnectorContext,
|
|
59
143
|
): Promise<ConnectorEffect[]> {
|
|
@@ -64,6 +148,22 @@ export async function handleParallFrame(
|
|
|
64
148
|
} catch {
|
|
65
149
|
return [];
|
|
66
150
|
}
|
|
151
|
+
if (event.type === 'agent.new_session') {
|
|
152
|
+
// New Session rotated the main conversation line (server-driven binding
|
|
153
|
+
// reset). Stale fork routing surviving it would deliver post-reset
|
|
154
|
+
// messages into pre-reset children (old context) or fork off a busy
|
|
155
|
+
// marker for a session that no longer exists — purge it all, and drop
|
|
156
|
+
// the cached fork ase_ rows so the next event resolves fresh ones.
|
|
157
|
+
try {
|
|
158
|
+
const refs = await clearForkState(ctx);
|
|
159
|
+
for (const ref of refs) {
|
|
160
|
+
await invalidateChildSession(ctx, ref);
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('[parel-channel] new-session fork purge failed', err);
|
|
164
|
+
}
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
67
167
|
if (event.type !== 'dispatch.new' || !event.data) return [];
|
|
68
168
|
try {
|
|
69
169
|
return await effectsForDispatch(event.data, ctx);
|
|
@@ -75,21 +175,100 @@ export async function handleParallFrame(
|
|
|
75
175
|
}
|
|
76
176
|
}
|
|
77
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Fork retry timers (armed by child_spawn_failed): re-plan the stranded
|
|
180
|
+
* message through the normal dispatch pipeline. onTimer returns the FULL
|
|
181
|
+
* effect union (unlike onAgentEvent), so the re-plan can emit/spawn/ack.
|
|
182
|
+
* The parked row is cleared first — a re-plan that fails transiently leaves
|
|
183
|
+
* the WorkItem in `received` for the reconnect sweep, same as any dispatch.
|
|
184
|
+
*/
|
|
185
|
+
export function handleParallTimer(
|
|
186
|
+
timer: { key: string },
|
|
187
|
+
ctx: ConnectorContext,
|
|
188
|
+
): Promise<ConnectorEffect[]> {
|
|
189
|
+
return serialized(() => handleParallTimerInner(timer, ctx));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function handleParallTimerInner(
|
|
193
|
+
timer: { key: string },
|
|
194
|
+
ctx: ConnectorContext,
|
|
195
|
+
): Promise<ConnectorEffect[]> {
|
|
196
|
+
if (!timer.key.startsWith(FORK_RETRY_PREFIX)) return [];
|
|
197
|
+
const parked = (await ctx.store?.get(timer.key).catch(() => null)) as DispatchData | null;
|
|
198
|
+
if (!parked?.source_id) return [];
|
|
199
|
+
// Re-planning is idempotent on every path — an already-acked opening hits
|
|
200
|
+
// the confirmed branch (ack re-send, harmless), an unconfirmed one bumps
|
|
201
|
+
// the bounded verification counter, deliverTo replays dedupe on envelope
|
|
202
|
+
// id — so the timer re-drives unconditionally.
|
|
203
|
+
try {
|
|
204
|
+
const effects = await effectsForDispatch(parked, ctx);
|
|
205
|
+
// The parked row's only live purpose is the attempts counter across
|
|
206
|
+
// verification rounds — signaled by the re-plan re-arming THIS key. Any
|
|
207
|
+
// other outcome (confirmed ack / deliverTo / main / give-up) resolved
|
|
208
|
+
// the message, and the row would otherwise leak forever (no TTL).
|
|
209
|
+
const rearmed = effects.some(
|
|
210
|
+
(e) => e.type === 'setTimer' && (e as { key?: string }).key === timer.key,
|
|
211
|
+
);
|
|
212
|
+
if (!rearmed) await ctx.store?.delete(timer.key).catch(() => {});
|
|
213
|
+
return effects;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error('[parel-channel] fork retry re-plan failed', err);
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
78
220
|
/**
|
|
79
221
|
* Sweep pending dispatches on (re)connect — the live WS push has no replay.
|
|
80
222
|
* Paginates with the server cursor so a long outage's backlog is not silently
|
|
81
223
|
* truncated (bounded to keep a pathological backlog from wedging onOpen; the
|
|
82
224
|
* remainder is picked up on the next reconnect sweep).
|
|
83
225
|
*/
|
|
84
|
-
export
|
|
226
|
+
export function catchUpParall(ctx: ConnectorContext): Promise<ConnectorEffect[]> {
|
|
227
|
+
return serialized(() => catchUpParallInner(ctx));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function catchUpParallInner(ctx: ConnectorContext): Promise<ConnectorEffect[]> {
|
|
231
|
+
// New Session generation check (must run before any dispatch replay): the
|
|
232
|
+
// `agent.new_session` WS push is not replayed, so a reset that happened
|
|
233
|
+
// while this connection was down would leave stale fork routing behind —
|
|
234
|
+
// post-reset messages would land in pre-reset children. The main ase_ IS
|
|
235
|
+
// the generation: New Session closes it and the next get-or-create mints a
|
|
236
|
+
// fresh id, so compare the cached id against the server's current one and
|
|
237
|
+
// purge fork state on rotation. Every missed-event scenario passes through
|
|
238
|
+
// onOpen (a live socket delivers the event; a dead one reconnects here).
|
|
239
|
+
// The cached id is never dropped before the fresh (bypass-cache) read
|
|
240
|
+
// succeeds — it is the only generation token, and losing it to a transient
|
|
241
|
+
// /sessions failure would silently skip the purge until the fork TTLs
|
|
242
|
+
// expire.
|
|
243
|
+
try {
|
|
244
|
+
const cachedMain = (await ctx.store?.get(SESSION_CACHE_KEY).catch(() => null)) as string | null;
|
|
245
|
+
if (cachedMain) {
|
|
246
|
+
const freshMain = await fetchMainSessionFresh(ctx);
|
|
247
|
+
if (freshMain && freshMain !== cachedMain) {
|
|
248
|
+
const refs = await clearForkState(ctx);
|
|
249
|
+
for (const ref of refs) {
|
|
250
|
+
await invalidateChildSession(ctx, ref);
|
|
251
|
+
}
|
|
252
|
+
console.warn('[parel-channel] main session rotated while offline — fork state purged');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Capability revalidation: a parked forkDisabled latch reflects the
|
|
256
|
+
// binding shape at failure time; the fleet sweep converges bindings
|
|
257
|
+
// without restarting the connection, so a reconnect is the natural
|
|
258
|
+
// revalidation point. If forking is truly still disabled, the first
|
|
259
|
+
// spawn attempt re-parks the latch at the cost of one failed effect.
|
|
260
|
+
await ctx.store?.delete('forkDisabled').catch(() => {});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error('[parel-channel] new-session generation check failed', err);
|
|
263
|
+
}
|
|
85
264
|
const effects: ConnectorEffect[] = [];
|
|
86
265
|
try {
|
|
87
266
|
const apiUrl = parallApiUrl(ctx);
|
|
88
267
|
const agk = requireAgk(ctx);
|
|
89
268
|
const orgId = parallOrgId(ctx);
|
|
90
269
|
let cursor = '';
|
|
91
|
-
// Page bound is a wedge guard, not a coverage target.
|
|
92
|
-
//
|
|
270
|
+
// Page bound is a wedge guard, not a coverage target. Unknown dispatch
|
|
271
|
+
// types stay pending (deliberately — see effectsForDispatch) and the
|
|
93
272
|
// list is FIFO, so the bound must comfortably exceed any realistic
|
|
94
273
|
// unsupported backlog or old rows could starve newer DMs out of the sweep.
|
|
95
274
|
// 40 pages ≈ 2000 dispatches per sweep; log when we hit the ceiling so a
|
|
@@ -151,11 +330,30 @@ async function effectsForDispatch(
|
|
|
151
330
|
// lives on the provider, not in a Parall chat).
|
|
152
331
|
return effectsForChannelDispatch(sourceId, dispatchId, ctx);
|
|
153
332
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
333
|
+
const eventType = String(data.event_type ?? '');
|
|
334
|
+
if (sourceId && isTypedEventType(eventType)) {
|
|
335
|
+
// Legacy configs without parallAgentId predate the whole reporting
|
|
336
|
+
// surface AND the typed handlers' stale guards (planTask compares the
|
|
337
|
+
// assignee against it) — emitting there would deliver reassigned tasks
|
|
338
|
+
// and record nothing. Keep typed work pending until the connection is
|
|
339
|
+
// redeployed on the current shape (same posture as unknown types).
|
|
340
|
+
if (!reportingEnabled(ctx)) {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
// Conversational-only deployments (no sandbox → no `parall` CLI) cannot
|
|
344
|
+
// act on CLI-only typed work — emitting AND acking it would silently
|
|
345
|
+
// consume real work. Keep it pending (same posture as unknown types);
|
|
346
|
+
// approval_decided is exempt (it lives in a chat, replies auto-deliver).
|
|
347
|
+
if (!sandboxEnabled(ctx) && eventType !== 'approval_decided') {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
return effectsForTypedDispatch(data, ctx);
|
|
351
|
+
}
|
|
352
|
+
// Unknown non-message dispatches stay PENDING on purpose: they are
|
|
353
|
+
// meaningful work a future connector version will handle, and acking would
|
|
354
|
+
// silently drop it. Message dispatches that can NEVER become processable
|
|
355
|
+
// are acked-and-dropped below instead, so the pending queue (and the
|
|
356
|
+
// reconnect catch-up sweep) is bounded by real future work.
|
|
159
357
|
if (sourceType !== 'message' || !sourceId) return [];
|
|
160
358
|
if (!chatId) {
|
|
161
359
|
// A message dispatch with no chat can never be routed or replied to.
|
|
@@ -183,27 +381,252 @@ async function effectsForDispatch(
|
|
|
183
381
|
content?: { text?: string };
|
|
184
382
|
thread_root_id?: string;
|
|
185
383
|
sender_id?: string;
|
|
384
|
+
attachments?: Array<{
|
|
385
|
+
id?: string;
|
|
386
|
+
file_name?: string;
|
|
387
|
+
file_size?: number;
|
|
388
|
+
mime_type?: string;
|
|
389
|
+
}>;
|
|
186
390
|
} | null;
|
|
187
391
|
const text = msg?.content?.text ?? '';
|
|
188
392
|
// A thread reply anchors the agent's answer to the same thread.
|
|
189
393
|
const threadRootId = msg?.thread_root_id || undefined;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
394
|
+
// Attachments render as reference lines — the agent knows they exist and
|
|
395
|
+
// pulls the bytes itself via its sandbox CLI (standard-runtime contract).
|
|
396
|
+
const attachments: PromptAttachment[] = (msg?.attachments ?? [])
|
|
397
|
+
.filter((a) => typeof a?.id === 'string' && a.id)
|
|
398
|
+
.map((a) => ({
|
|
399
|
+
id: String(a.id),
|
|
400
|
+
fileName: String(a.file_name ?? ''),
|
|
401
|
+
fileSize: typeof a.file_size === 'number' ? a.file_size : 0,
|
|
402
|
+
mimeType: String(a.mime_type ?? ''),
|
|
403
|
+
}));
|
|
404
|
+
if (!text && attachments.length === 0) {
|
|
405
|
+
// Truly empty messages never grow content — ack-and-drop rather than let
|
|
406
|
+
// them pile up in the pending queue forever. (Attachment-only messages
|
|
407
|
+
// used to take this path; they now ride the attachment lines instead.)
|
|
194
408
|
return [ackEffect(ctx, sourceType, sourceId)];
|
|
195
409
|
}
|
|
196
410
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// the
|
|
204
|
-
//
|
|
205
|
-
const
|
|
411
|
+
// Frame the message with its chat of origin: all chats share the agent's
|
|
412
|
+
// one main session, so the model must see WHERE each message comes from or
|
|
413
|
+
// it answers chat B inside chat A's train of thought. The name is cosmetic
|
|
414
|
+
// garnish and must never block the emit/ack hot path (the catch-up sweep is
|
|
415
|
+
// fully serialized — one slow lookup would stall the whole backlog): only
|
|
416
|
+
// the cached name is used, and a miss kicks off a fire-and-forget prefetch
|
|
417
|
+
// so the NEXT message from this chat gets the name. The id alone already
|
|
418
|
+
// marks the context switch.
|
|
419
|
+
const chatName = await cachedChatName(ctx, chatId);
|
|
420
|
+
const senderId = msg?.sender_id ? String(msg.sender_id) : undefined;
|
|
421
|
+
const framedText = buildChatPrompt({
|
|
422
|
+
chatId,
|
|
423
|
+
chatName,
|
|
424
|
+
senderId,
|
|
425
|
+
threadRootId,
|
|
426
|
+
text,
|
|
427
|
+
attachments,
|
|
428
|
+
});
|
|
206
429
|
const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
|
|
430
|
+
// Input-step summary must never be empty (attachment-only messages).
|
|
431
|
+
const stepText = text || `[${attachments.length} attachment(s)]`;
|
|
432
|
+
// Thread-aware conversation identity: F2 grouping / F3 injection / fork
|
|
433
|
+
// routing all key on the subject, and a turn carries ONE reply route — a
|
|
434
|
+
// thread must never be absorbed into its sibling's turn (session.ts
|
|
435
|
+
// conversationKey doc).
|
|
436
|
+
const subject = conversationKey(chatId, threadRootId);
|
|
437
|
+
|
|
438
|
+
// Fork-on-busy scheduling (fork.ts): a live child owns its conversation;
|
|
439
|
+
// main mid-turn on ANOTHER conversation forks a new child; everything else
|
|
440
|
+
// rides main. Typed and channel_message dispatches always ride main.
|
|
441
|
+
const decision = await planForkDecision(ctx, subject, sourceId);
|
|
442
|
+
|
|
443
|
+
// Per-turn invocation context: parel snapshots this at turn start and
|
|
444
|
+
// consume-gated plugins receive it — sandbox-e2b flattens it into per-exec
|
|
445
|
+
// env, so `parall` CLI calls inside the sandbox land on the right chat.
|
|
446
|
+
const invocationContext: Record<string, string> = {
|
|
447
|
+
PRLL_CHAT_ID: chatId,
|
|
448
|
+
PRLL_TRIGGER_MESSAGE_ID: sourceId,
|
|
449
|
+
...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const envelope = {
|
|
453
|
+
id: envelopeId,
|
|
454
|
+
orgId,
|
|
455
|
+
connectionId: ctx.connectionId,
|
|
456
|
+
source: 'parall',
|
|
457
|
+
type: 'message' as const,
|
|
458
|
+
// The binding's `main` routing sends every envelope to the agent's
|
|
459
|
+
// one session; subject still carries the conversation (chat, or
|
|
460
|
+
// chat~thread) so parel's turn input grouping (F2) and mid-turn
|
|
461
|
+
// injection (F3) never mix conversations in one turn, and agent
|
|
462
|
+
// events mirror it back for per-conversation correlation.
|
|
463
|
+
subject,
|
|
464
|
+
data: { text: framedText, chatId, messageId: sourceId, threadRootId },
|
|
465
|
+
// chat id + thread anchor ride replyRoute.data so deliver() knows
|
|
466
|
+
// where to post back (same shape channel-slack-socket uses for its
|
|
467
|
+
// channel id).
|
|
468
|
+
replyRoute: {
|
|
469
|
+
kind: 'provider_http' as const,
|
|
470
|
+
connectionId: ctx.connectionId,
|
|
471
|
+
// envelopeId lets deliver() run the same staleness guard as
|
|
472
|
+
// onAgentEvent against the shared turnEnvelope marker.
|
|
473
|
+
data: { chatId, messageId: sourceId, threadRootId, envelopeId },
|
|
474
|
+
},
|
|
475
|
+
context: invocationContext,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (decision.mode === 'deliverTo') {
|
|
479
|
+
// Follow-up for a conversation a live child owns: the envelope rides the
|
|
480
|
+
// full ingress contract but session resolution goes to the child (§18.2).
|
|
481
|
+
// Progress reporting lands on the CHILD's ase_ — its turn does the work,
|
|
482
|
+
// and the replyRoute carries childRef so deliver() links the reply to the
|
|
483
|
+
// child session (and idles IT, not main).
|
|
484
|
+
const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
|
|
485
|
+
const childAse = await ensureChildSession(ctx, decision.childRef);
|
|
486
|
+
if (childAse) {
|
|
487
|
+
const results = await Promise.all([
|
|
488
|
+
patchSession(ctx, childAse, { status: 'active', trigger_message_id: sourceId }),
|
|
489
|
+
createInputStep(ctx, childAse, {
|
|
490
|
+
chatId,
|
|
491
|
+
messageId: sourceId,
|
|
492
|
+
senderId,
|
|
493
|
+
text: stepText,
|
|
494
|
+
}),
|
|
495
|
+
]);
|
|
496
|
+
// A closed child ase_ (New Session closed the whole family) just drops
|
|
497
|
+
// the cache; the next event rebuilds it.
|
|
498
|
+
if (results.includes('stale')) await invalidateChildSession(ctx, decision.childRef);
|
|
499
|
+
}
|
|
500
|
+
await receivedSettled;
|
|
501
|
+
const childEnvelope = {
|
|
502
|
+
...envelope,
|
|
503
|
+
replyRoute: {
|
|
504
|
+
...envelope.replyRoute,
|
|
505
|
+
data: { ...envelope.replyRoute.data, childRef: decision.childRef },
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
// Until the child's first observed event confirms provisioning, a
|
|
509
|
+
// deliverTo can still land in a spawn that fails terminally (the host
|
|
510
|
+
// ignores its queued events) — park the WorkItem ack on the bookkeeping
|
|
511
|
+
// row instead of consuming it on emit. Ingress dedupe (same envelope id)
|
|
512
|
+
// makes the eventual replay safe. A full parking list (cap) does NOT
|
|
513
|
+
// degrade to ack-on-emit — that would trade bounded storage for message
|
|
514
|
+
// loss; the overflow stays pending un-emitted and a later replay finds
|
|
515
|
+
// either a confirmed child (normal ack-on-emit) or a fresh decision.
|
|
516
|
+
if (decision.trackAck && dispatchId) {
|
|
517
|
+
const parked = await trackFollowUpAck(ctx, subject, decision.childRef, {
|
|
518
|
+
dispatchId,
|
|
519
|
+
sourceId,
|
|
520
|
+
});
|
|
521
|
+
if (!parked) {
|
|
522
|
+
// Parking cap hit: stay pending, but arm a local retry — a healthy
|
|
523
|
+
// long-lived socket never re-runs the reconnect sweep, so without a
|
|
524
|
+
// timer the overflow follow-up would strand until the next
|
|
525
|
+
// disconnect. By the retry the child is usually confirmed (normal
|
|
526
|
+
// ack-on-emit) or gone (fresh decision).
|
|
527
|
+
const retryKey = `${FORK_RETRY_PREFIX}${dispatchId}`;
|
|
528
|
+
await bumpForkRetryAttempts(ctx, retryKey, {
|
|
529
|
+
id: dispatchId,
|
|
530
|
+
source_type: sourceType,
|
|
531
|
+
source_id: sourceId,
|
|
532
|
+
chat_id: chatId,
|
|
533
|
+
});
|
|
534
|
+
return asConnectorEffects([
|
|
535
|
+
{ type: 'setTimer', key: retryKey, at: ctx.now() + FORK_RETRY_DELAY_MS },
|
|
536
|
+
]);
|
|
537
|
+
}
|
|
538
|
+
return asConnectorEffects([
|
|
539
|
+
{ type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
|
|
540
|
+
]);
|
|
541
|
+
}
|
|
542
|
+
return asConnectorEffects([
|
|
543
|
+
{ type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
|
|
544
|
+
ackEffect(ctx, sourceType, sourceId),
|
|
545
|
+
]);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (decision.mode === 'spawn') {
|
|
549
|
+
// Main is mid-turn on another conversation — fork. The opening message
|
|
550
|
+
// IS the spawn input (one turn, no idle warm-up); its dispatch is NOT
|
|
551
|
+
// acked here: the pending ack (by WorkItem id) rides the fork bookkeeping
|
|
552
|
+
// and fires on the child's first observed event, so a spawn failure
|
|
553
|
+
// leaves the message recoverable instead of silently lost. A REPLAY of
|
|
554
|
+
// this same message re-enters here with the recorded childRef (fork.ts
|
|
555
|
+
// branch 1a): the platform spawn is idempotent on the ref, so re-issuing
|
|
556
|
+
// is both the transient-failure retry and the duplicate-processing guard.
|
|
557
|
+
// If the child is already CONFIRMED (events observed) the replay means
|
|
558
|
+
// only the ACK went missing — a re-spawn is a silent no-op that emits no
|
|
559
|
+
// new event, so ack directly instead of waiting for one.
|
|
560
|
+
const replayed = await childConfirmed(ctx, subject, decision.childRef);
|
|
561
|
+
if (replayed && dispatchId) {
|
|
562
|
+
return [ackByIdEffect(ctx, dispatchId)];
|
|
563
|
+
}
|
|
564
|
+
// Verification-cap check FIRST, before any side effect: onAgentEvent is
|
|
565
|
+
// best-effort — if every child event is lost, a replayed spawn is a
|
|
566
|
+
// silent idempotent no-op that never produces a new event, so each spawn
|
|
567
|
+
// pass bumps a bounded counter and re-arms a timer. Once the cap is
|
|
568
|
+
// exceeded the ref is a black hole: clear the fork bookkeeping and FALL
|
|
569
|
+
// THROUGH to the main path in THIS pass (emitting yet another spawn
|
|
570
|
+
// would leave the message stranded with no timer and no bookkeeping
|
|
571
|
+
// until a reconnect).
|
|
572
|
+
let spawnBlackHoled = false;
|
|
573
|
+
const timerEffects: ExtendedConnectorEffect[] = [];
|
|
574
|
+
if (dispatchId) {
|
|
575
|
+
const retryKey = `${FORK_RETRY_PREFIX}${dispatchId}`;
|
|
576
|
+
const attempts = await bumpForkRetryAttempts(ctx, retryKey, {
|
|
577
|
+
id: dispatchId,
|
|
578
|
+
source_type: sourceType,
|
|
579
|
+
source_id: sourceId,
|
|
580
|
+
chat_id: chatId,
|
|
581
|
+
});
|
|
582
|
+
if (attempts <= MAX_SPAWN_VERIFY_ATTEMPTS) {
|
|
583
|
+
timerEffects.push({ type: 'setTimer', key: retryKey, at: ctx.now() + FORK_RETRY_DELAY_MS });
|
|
584
|
+
} else {
|
|
585
|
+
spawnBlackHoled = true;
|
|
586
|
+
await clearForkSubject(ctx, subject, decision.childRef);
|
|
587
|
+
await ctx.store?.delete(retryKey).catch(() => {});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (!spawnBlackHoled) {
|
|
591
|
+
const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
|
|
592
|
+
const childAse = await ensureChildSession(ctx, decision.childRef);
|
|
593
|
+
if (childAse) {
|
|
594
|
+
const results = await Promise.all([
|
|
595
|
+
patchSession(ctx, childAse, { status: 'active', trigger_message_id: sourceId }),
|
|
596
|
+
createInputStep(ctx, childAse, {
|
|
597
|
+
chatId,
|
|
598
|
+
messageId: sourceId,
|
|
599
|
+
senderId,
|
|
600
|
+
text: stepText,
|
|
601
|
+
}),
|
|
602
|
+
]);
|
|
603
|
+
if (results.includes('stale')) await invalidateChildSession(ctx, decision.childRef);
|
|
604
|
+
}
|
|
605
|
+
await recordSpawn(ctx, subject, decision.childRef, { dispatchId, sourceId });
|
|
606
|
+
await receivedSettled;
|
|
607
|
+
return asConnectorEffects([
|
|
608
|
+
{
|
|
609
|
+
type: 'spawnChildSession',
|
|
610
|
+
childRef: decision.childRef,
|
|
611
|
+
input: buildForkScopePrefix(chatId, threadRootId) + framedText,
|
|
612
|
+
subject,
|
|
613
|
+
},
|
|
614
|
+
...timerEffects,
|
|
615
|
+
]);
|
|
616
|
+
}
|
|
617
|
+
// fall through: black-holed spawn rides main below.
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Main path. Progress reporting (best-effort,
|
|
621
|
+
// parall-runtime-indicator-plan.md §3): received lights the pending ring,
|
|
622
|
+
// the active+trigger patch arms the done signal, the input step flips the
|
|
623
|
+
// ring to processing. received MUST settle before this function returns —
|
|
624
|
+
// the ack effect below moves the dispatch row out of 'pending', and
|
|
625
|
+
// MarkReceived only transitions pending rows. It is deliberately NOT gated
|
|
626
|
+
// on the session resolving: it only touches the dispatch row, and a
|
|
627
|
+
// transient session failure must not also cost the pending ring (a later
|
|
628
|
+
// successful turn's idle clears agent-level pendings).
|
|
629
|
+
const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
|
|
207
630
|
let sessionId = await ensureSession(ctx);
|
|
208
631
|
if (sessionId) {
|
|
209
632
|
// The guard marker goes down BEFORE any session write: a stale
|
|
@@ -213,6 +636,7 @@ async function effectsForDispatch(
|
|
|
213
636
|
// envelope id and idle the session out from under this message. The
|
|
214
637
|
// marker is agent-global: chats share the one main session, so a chat-A
|
|
215
638
|
// done signal must not idle it while this (later) message is pending.
|
|
639
|
+
// Child turns never touch it — only main-bound envelopes arm it.
|
|
216
640
|
// (Residual read-then-patch overlap is zero when the host runs a
|
|
217
641
|
// connection's hooks serially, per the C3 contract / C3b confirm item.)
|
|
218
642
|
await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => {});
|
|
@@ -222,8 +646,8 @@ async function effectsForDispatch(
|
|
|
222
646
|
createInputStep(ctx, sid, {
|
|
223
647
|
chatId,
|
|
224
648
|
messageId: sourceId,
|
|
225
|
-
senderId
|
|
226
|
-
text,
|
|
649
|
+
senderId,
|
|
650
|
+
text: stepText,
|
|
227
651
|
}),
|
|
228
652
|
]);
|
|
229
653
|
const results = await report(sessionId);
|
|
@@ -241,167 +665,55 @@ async function effectsForDispatch(
|
|
|
241
665
|
}
|
|
242
666
|
await receivedSettled;
|
|
243
667
|
|
|
244
|
-
|
|
245
|
-
// consume-gated plugins receive it — sandbox-e2b flattens it into per-exec
|
|
246
|
-
// env, so `parall` CLI calls inside the sandbox land on the right chat.
|
|
247
|
-
const invocationContext: Record<string, string> = {
|
|
248
|
-
PRLL_CHAT_ID: chatId,
|
|
249
|
-
PRLL_TRIGGER_MESSAGE_ID: sourceId,
|
|
250
|
-
...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
// Frame the message with its chat of origin: all chats share the agent's
|
|
254
|
-
// one main session, so the model must see WHERE each message comes from or
|
|
255
|
-
// it answers chat B inside chat A's train of thought. The name is cosmetic
|
|
256
|
-
// garnish and must never block the emit/ack hot path (the catch-up sweep is
|
|
257
|
-
// fully serialized — one slow lookup would stall the whole backlog): only
|
|
258
|
-
// the cached name is used, and a miss kicks off a fire-and-forget prefetch
|
|
259
|
-
// so the NEXT message from this chat gets the name. The id alone already
|
|
260
|
-
// marks the context switch.
|
|
261
|
-
const chatName = await cachedChatName(ctx, chatId);
|
|
262
|
-
const framedText = buildChatPrompt({
|
|
263
|
-
chatId,
|
|
264
|
-
chatName,
|
|
265
|
-
senderId: msg?.sender_id ? String(msg.sender_id) : undefined,
|
|
266
|
-
threadRootId,
|
|
267
|
-
text,
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
const effects: ConnectorEffect[] = [
|
|
271
|
-
{
|
|
272
|
-
type: 'emitEvent',
|
|
273
|
-
event: {
|
|
274
|
-
id: envelopeId,
|
|
275
|
-
orgId,
|
|
276
|
-
connectionId: ctx.connectionId,
|
|
277
|
-
source: 'parall',
|
|
278
|
-
type: 'message',
|
|
279
|
-
// The binding's `main` routing sends every envelope to the agent's
|
|
280
|
-
// one session; subject still carries the chat so parel's turn input
|
|
281
|
-
// grouping (F2) never mixes conversations in one turn and agent
|
|
282
|
-
// events mirror it back for per-chat correlation.
|
|
283
|
-
subject: chatId,
|
|
284
|
-
data: { text: framedText, chatId, messageId: sourceId, threadRootId },
|
|
285
|
-
// chat id + thread anchor ride replyRoute.data so deliver() knows
|
|
286
|
-
// where to post back (same shape channel-slack-socket uses for its
|
|
287
|
-
// channel id).
|
|
288
|
-
replyRoute: {
|
|
289
|
-
kind: 'provider_http',
|
|
290
|
-
connectionId: ctx.connectionId,
|
|
291
|
-
// envelopeId lets deliver() run the same staleness guard as
|
|
292
|
-
// onAgentEvent against the shared turnEnvelope marker.
|
|
293
|
-
data: { chatId, messageId: sourceId, threadRootId, envelopeId },
|
|
294
|
-
},
|
|
295
|
-
context: invocationContext,
|
|
296
|
-
},
|
|
297
|
-
},
|
|
298
|
-
ackEffect(ctx, sourceType, sourceId),
|
|
299
|
-
];
|
|
300
|
-
return effects;
|
|
668
|
+
return [{ type: 'emitEvent', event: envelope }, ackEffect(ctx, sourceType, sourceId)];
|
|
301
669
|
}
|
|
302
670
|
|
|
303
671
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* resolve the provider for prompt labeling (cached — the connection→provider
|
|
309
|
-
* mapping is stable), frame the prompt for the EXTERNAL audience, report
|
|
310
|
-
* progress against the conversation-keyed session, and ack by source.
|
|
672
|
+
* Typed (non-message) dispatch → main-session envelope. The heavy lifting
|
|
673
|
+
* (entity fetch + event-body rendering + stale/pending classification) lives
|
|
674
|
+
* in typed-dispatch.ts; this function owns the shared emit pipeline: session
|
|
675
|
+
* bookkeeping, the turnEnvelope marker, the envelope, and the ack.
|
|
311
676
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* dispatch pending for the catch-up sweep.
|
|
677
|
+
* Typed events always ride MAIN (never fork): nobody is watching a chat
|
|
678
|
+
* indicator for them, so head-of-line latency costs nothing, and keeping
|
|
679
|
+
* them on the orchestrator preserves task/schedule context continuity.
|
|
316
680
|
*/
|
|
317
|
-
async function
|
|
318
|
-
|
|
319
|
-
dispatchId: string,
|
|
681
|
+
async function effectsForTypedDispatch(
|
|
682
|
+
data: DispatchData,
|
|
320
683
|
ctx: ConnectorContext,
|
|
321
684
|
): Promise<ConnectorEffect[]> {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
return []; // transient: keep pending, the catch-up sweep retries
|
|
339
|
-
}
|
|
340
|
-
const msg = (await m.json().catch(() => null)) as {
|
|
341
|
-
conversation_id?: string;
|
|
342
|
-
external_message_id?: string;
|
|
343
|
-
text?: string;
|
|
344
|
-
external_user_id?: string;
|
|
345
|
-
external_user_name?: string;
|
|
346
|
-
received_at?: string;
|
|
347
|
-
} | null;
|
|
348
|
-
const conversationId = msg?.conversation_id ? String(msg.conversation_id) : undefined;
|
|
349
|
-
const text = msg?.text ?? '';
|
|
350
|
-
if (!conversationId) {
|
|
351
|
-
// No conversation → no session key and no reply target: unusable.
|
|
352
|
-
return [ackEffect(ctx, 'channel_message', sourceId)];
|
|
353
|
-
}
|
|
354
|
-
if (!text) {
|
|
355
|
-
// The ingress adapter only persists mechanically parsed text; a row with
|
|
356
|
-
// none will never grow any (same reasoning as attachment-only chat
|
|
357
|
-
// messages) — drop rather than let it pile up in the pending queue.
|
|
358
|
-
return [ackEffect(ctx, 'channel_message', sourceId)];
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const c = await get(`/channel-conversations/${conversationId}`);
|
|
362
|
-
if (!c.ok) {
|
|
363
|
-
if (c.status === 404 || c.status === 403) {
|
|
364
|
-
return [ackEffect(ctx, 'channel_message', sourceId)];
|
|
365
|
-
}
|
|
366
|
-
return [];
|
|
367
|
-
}
|
|
368
|
-
const conv = (await c.json().catch(() => null)) as {
|
|
369
|
-
connection_id?: string;
|
|
370
|
-
external_conversation_id?: string;
|
|
371
|
-
conversation_type?: string;
|
|
372
|
-
} | null;
|
|
373
|
-
|
|
374
|
-
// Provider (= clip alias) for prompt labeling + the reply hint.
|
|
375
|
-
// Best-effort: an unresolved provider degrades the wording, never blocks.
|
|
376
|
-
const connectionId = conv?.connection_id ? String(conv.connection_id) : undefined;
|
|
377
|
-
const provider = connectionId ? await resolveChannelProvider(ctx, connectionId, get) : undefined;
|
|
378
|
-
|
|
379
|
-
const senderName = msg?.external_user_name || msg?.external_user_id || 'external user';
|
|
380
|
-
const externalConversationId = conv?.external_conversation_id
|
|
381
|
-
? String(conv.external_conversation_id)
|
|
382
|
-
: undefined;
|
|
383
|
-
const externalMessageId = msg?.external_message_id ? String(msg.external_message_id) : undefined;
|
|
685
|
+
// No markDispatchReceived here ON PURPOSE: typed dispatches have no
|
|
686
|
+
// message-level activity indicator (DispatchResolvedData: "typed
|
|
687
|
+
// dispatches have no indicator"), so there is no pending ring to light —
|
|
688
|
+
// the typed input step below is the session panel's timeline entry.
|
|
689
|
+
const sourceType = String(data.source_type ?? '');
|
|
690
|
+
const sourceId = String(data.source_id ?? '');
|
|
691
|
+
const dispatchId = String(data.id ?? '');
|
|
692
|
+
// Typed WorkItems ack by id, never by source: sibling rows (a task
|
|
693
|
+
// mutation's task_assign + task_update) share (source_type, source_id),
|
|
694
|
+
// and the source ack would terminally resolve work that was never emitted.
|
|
695
|
+
const ack = () =>
|
|
696
|
+
dispatchId ? ackByIdEffect(ctx, dispatchId) : ackEffect(ctx, sourceType, sourceId);
|
|
697
|
+
const plan = await planTypedDispatch(data, ctx);
|
|
698
|
+
if (plan.outcome === 'pending') return [];
|
|
699
|
+
if (plan.outcome === 'stale') return [ack()];
|
|
384
700
|
|
|
385
|
-
|
|
386
|
-
// (see the comments there): received settles before the ack effect is
|
|
387
|
-
// returned; the turnEnvelope marker goes down before any session write.
|
|
388
|
-
const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
|
|
389
|
-
const envelopeId = dispatchId || `channel_message:${sourceId}`;
|
|
701
|
+
const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
|
|
390
702
|
let sessionId = await ensureSession(ctx);
|
|
391
703
|
if (sessionId) {
|
|
704
|
+
// Same marker contract as the message path: a typed envelope is now the
|
|
705
|
+
// agent's latest emitted input, so older turns' done signals must not
|
|
706
|
+
// idle the session out from under it.
|
|
392
707
|
await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => {});
|
|
393
708
|
const report = (sid: string) =>
|
|
394
709
|
Promise.all([
|
|
395
|
-
patchSession(ctx, sid, { status: 'active'
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
senderName,
|
|
403
|
-
text,
|
|
404
|
-
sentAt: msg?.received_at ? String(msg.received_at) : undefined,
|
|
710
|
+
patchSession(ctx, sid, { status: 'active' }),
|
|
711
|
+
createTypedInputStep(ctx, sid, {
|
|
712
|
+
target: plan.stepTarget,
|
|
713
|
+
triggerType: plan.triggerType,
|
|
714
|
+
dispatchId,
|
|
715
|
+
sourceId,
|
|
716
|
+
summary: plan.summary,
|
|
405
717
|
}),
|
|
406
718
|
]);
|
|
407
719
|
const results = await report(sessionId);
|
|
@@ -413,75 +725,46 @@ async function effectsForChannelDispatch(
|
|
|
413
725
|
sessionId = fresh;
|
|
414
726
|
}
|
|
415
727
|
}
|
|
416
|
-
// Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
|
|
417
|
-
// conversation into its session; last-write-wins on the server). Only on
|
|
418
|
-
// change — the store remembers what was last pushed — and fully
|
|
419
|
-
// fire-and-forget: pure bookkeeping must never add latency to the
|
|
420
|
-
// emit/ack path (a lost attempt is retried on the next message because
|
|
421
|
-
// the store memo only updates on success).
|
|
422
|
-
void recordConversationSession(ctx, conversationId, sessionId).catch(() => {});
|
|
423
728
|
}
|
|
424
|
-
await receivedSettled;
|
|
425
729
|
|
|
426
730
|
return [
|
|
427
731
|
{
|
|
428
732
|
type: 'emitEvent',
|
|
429
733
|
event: {
|
|
430
734
|
id: envelopeId,
|
|
431
|
-
orgId,
|
|
735
|
+
orgId: parallOrgId(ctx),
|
|
432
736
|
connectionId: ctx.connectionId,
|
|
433
737
|
source: 'parall',
|
|
434
|
-
// The host's message intake path turns data.text into the user
|
|
435
|
-
// message — the external-audience framing rides inside it (parel has
|
|
436
|
-
// no separate prompt channel), so 'message' is the right type here.
|
|
437
738
|
type: 'message',
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
subject:
|
|
442
|
-
data: {
|
|
443
|
-
text: buildChannelPrompt({
|
|
444
|
-
provider,
|
|
445
|
-
conversationType: conv?.conversation_type ? String(conv.conversation_type) : undefined,
|
|
446
|
-
externalConversationId,
|
|
447
|
-
externalMessageId,
|
|
448
|
-
senderName,
|
|
449
|
-
text,
|
|
450
|
-
}),
|
|
451
|
-
conversationId,
|
|
452
|
-
channelMessageId: sourceId,
|
|
453
|
-
provider,
|
|
454
|
-
externalConversationId,
|
|
455
|
-
externalMessageId,
|
|
456
|
-
},
|
|
457
|
-
// No chatId on purpose: the reply goes out through the provider clip
|
|
458
|
-
// (agent-invoked), NOT deliver() — deliver only runs the redundant
|
|
459
|
-
// session idle for channel routes (see delivery.ts).
|
|
739
|
+
// Subject = the source entity (task/schedule/trigger/…): F2 groups
|
|
740
|
+
// consecutive events for one entity into one turn and never mixes
|
|
741
|
+
// them with chat conversations.
|
|
742
|
+
subject: plan.subject,
|
|
743
|
+
data: { text: plan.body, sourceType, sourceId },
|
|
460
744
|
replyRoute: {
|
|
461
745
|
kind: 'provider_http',
|
|
462
746
|
connectionId: ctx.connectionId,
|
|
463
|
-
|
|
747
|
+
// approval_decided replies deliver to its chat; every other typed
|
|
748
|
+
// event has no delivery target (deliver() only runs the redundant
|
|
749
|
+
// idle off envelopeId) — the agent acts via its sandbox CLI.
|
|
750
|
+
data: plan.chatId
|
|
751
|
+
? { chatId: plan.chatId, messageId: sourceId, envelopeId }
|
|
752
|
+
: { envelopeId },
|
|
464
753
|
},
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
754
|
+
...(plan.chatId
|
|
755
|
+
? {
|
|
756
|
+
context: {
|
|
757
|
+
PRLL_CHAT_ID: plan.chatId,
|
|
758
|
+
PRLL_TRIGGER_MESSAGE_ID: sourceId,
|
|
759
|
+
},
|
|
760
|
+
}
|
|
761
|
+
: {}),
|
|
468
762
|
},
|
|
469
763
|
},
|
|
470
|
-
|
|
764
|
+
ack(),
|
|
471
765
|
];
|
|
472
766
|
}
|
|
473
767
|
|
|
474
|
-
/**
|
|
475
|
-
* connection id → provider alias, cached in the durable store (stable
|
|
476
|
-
* mapping; avoids one connection fetch per inbound message). Cache hits are
|
|
477
|
-
* a store read; the miss-path fetch is cosmetic (prompt labeling only), so
|
|
478
|
-
* it gets a tight timeout — same 3s budget as deliver's front-of-reply
|
|
479
|
-
* session lookup — rather than holding the message's emit/ack behind the
|
|
480
|
-
* full REQUEST_TIMEOUT_MS. Any failure returns undefined — prompt wording
|
|
481
|
-
* degrades to neutral rather than inventing a clip alias.
|
|
482
|
-
*/
|
|
483
|
-
const PROVIDER_LOOKUP_TIMEOUT_MS = 3_000;
|
|
484
|
-
|
|
485
768
|
/**
|
|
486
769
|
* How long a cached chat name is served without revalidation. Chat renames
|
|
487
770
|
* are rare and the frame is the model's cross-chat context anchor, so serve
|
|
@@ -550,58 +833,6 @@ async function prefetchChatName(
|
|
|
550
833
|
}
|
|
551
834
|
}
|
|
552
835
|
|
|
553
|
-
async function resolveChannelProvider(
|
|
554
|
-
ctx: ConnectorContext,
|
|
555
|
-
connectionId: string,
|
|
556
|
-
get: (path: string, timeoutMs?: number) => Promise<Response>,
|
|
557
|
-
): Promise<string | undefined> {
|
|
558
|
-
const cacheKey = `channelProvider:${connectionId}`;
|
|
559
|
-
const cached = await ctx.store?.get(cacheKey).catch(() => null);
|
|
560
|
-
if (typeof cached === 'string' && cached) return cached;
|
|
561
|
-
try {
|
|
562
|
-
const res = await get(`/channel-connections/${connectionId}`, PROVIDER_LOOKUP_TIMEOUT_MS);
|
|
563
|
-
if (!res.ok) return undefined;
|
|
564
|
-
const body = (await res.json().catch(() => null)) as { provider?: string } | null;
|
|
565
|
-
const provider = body?.provider ? String(body.provider) : undefined;
|
|
566
|
-
if (provider) await ctx.store?.set(cacheKey, provider).catch(() => {});
|
|
567
|
-
return provider;
|
|
568
|
-
} catch {
|
|
569
|
-
return undefined;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/** Push the chv_ → ase_ pointer when it changed; warn-only on failure. */
|
|
574
|
-
async function recordConversationSession(
|
|
575
|
-
ctx: ConnectorContext,
|
|
576
|
-
conversationId: string,
|
|
577
|
-
sessionId: string,
|
|
578
|
-
): Promise<void> {
|
|
579
|
-
const mappedKey = `convSession:${conversationId}`;
|
|
580
|
-
const mapped = await ctx.store?.get(mappedKey).catch(() => null);
|
|
581
|
-
if (mapped === sessionId) return;
|
|
582
|
-
try {
|
|
583
|
-
const res = await fetch(
|
|
584
|
-
`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/channel-conversations/${conversationId}/session`,
|
|
585
|
-
{
|
|
586
|
-
method: 'PATCH',
|
|
587
|
-
headers: {
|
|
588
|
-
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
589
|
-
'Content-Type': 'application/json',
|
|
590
|
-
},
|
|
591
|
-
body: JSON.stringify({ agent_session_id: sessionId }),
|
|
592
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
593
|
-
},
|
|
594
|
-
);
|
|
595
|
-
if (!res.ok) {
|
|
596
|
-
console.warn(`[parel-channel] conversation session mapping failed (${res.status})`);
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
await ctx.store?.set(mappedKey, sessionId).catch(() => {});
|
|
600
|
-
} catch (err) {
|
|
601
|
-
console.warn('[parel-channel] conversation session mapping failed', err);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
836
|
/**
|
|
606
837
|
* Agent execution events (parel E1+E2, plugin-sdk 0.9.0, opt-in via the
|
|
607
838
|
* channel declaration's `observe: [turn, steps]`).
|
|
@@ -619,20 +850,76 @@ async function recordConversationSession(
|
|
|
619
850
|
* done event just means the ring clears on the next turn's trigger change; a
|
|
620
851
|
* missed trace step is a gap in the panel, nothing more.
|
|
621
852
|
*/
|
|
622
|
-
export
|
|
853
|
+
export function handleParallAgentEvent(
|
|
623
854
|
event: AgentEvent,
|
|
624
855
|
ctx: ConnectorContext,
|
|
625
856
|
): Promise<AgentEventEffect[]> {
|
|
857
|
+
return serialized(() => handleParallAgentEventInner(event, ctx));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function handleParallAgentEventInner(
|
|
861
|
+
event: AgentEvent,
|
|
862
|
+
ctx: ConnectorContext,
|
|
863
|
+
): Promise<AgentEventEffect[]> {
|
|
864
|
+
// spawnChildSession's asynchronous error channel — not turn-scoped, pushed
|
|
865
|
+
// regardless of observe scopes, and shaped outside the published AgentEvent
|
|
866
|
+
// union (see parel-sdk-compat). Handle it before anything subject-gated.
|
|
867
|
+
const spawnFailed = asChildSpawnFailed(event);
|
|
868
|
+
if (spawnFailed) {
|
|
869
|
+
const effects: AgentEventEffect[] = [];
|
|
870
|
+
try {
|
|
871
|
+
const outcome = await handleChildSpawnFailed(ctx, spawnFailed);
|
|
872
|
+
if (outcome.cleared) {
|
|
873
|
+
// The spawn's ase_ was already created active with an input step; no
|
|
874
|
+
// child turn will ever emit a terminal event for it — settle it here
|
|
875
|
+
// or the session (and its processing indicator) stays active forever.
|
|
876
|
+
const orphanAse = await ensureChildSession(ctx, spawnFailed.childRef);
|
|
877
|
+
if (orphanAse) await patchSession(ctx, orphanAse, { status: 'idle' });
|
|
878
|
+
await invalidateChildSession(ctx, spawnFailed.childRef);
|
|
879
|
+
}
|
|
880
|
+
// Re-drive the stranded messages WITHOUT waiting for a reconnect sweep
|
|
881
|
+
// (a healthy long-lived socket never re-runs onOpen): park their
|
|
882
|
+
// coordinates and arm a timer — onTimer re-plans them through the
|
|
883
|
+
// normal dispatch pipeline (transient failures re-enter branch 1a with
|
|
884
|
+
// the same ref; terminal ones re-plan from scratch).
|
|
885
|
+
if (outcome.subject && outcome.pendingAcks.length > 0) {
|
|
886
|
+
const chatId = outcome.subject.split('~')[0];
|
|
887
|
+
for (const ack of outcome.pendingAcks) {
|
|
888
|
+
const key = `${FORK_RETRY_PREFIX}${ack.dispatchId}`;
|
|
889
|
+
await bumpForkRetryAttempts(ctx, key, {
|
|
890
|
+
id: ack.dispatchId,
|
|
891
|
+
source_type: 'message',
|
|
892
|
+
source_id: ack.sourceId,
|
|
893
|
+
chat_id: chatId,
|
|
894
|
+
});
|
|
895
|
+
effects.push({ type: 'setTimer', key, at: ctx.now() + FORK_RETRY_DELAY_MS });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
} catch (err) {
|
|
899
|
+
console.error('[parel-channel] child spawn failure handling failed', err);
|
|
900
|
+
}
|
|
901
|
+
return effects;
|
|
902
|
+
}
|
|
903
|
+
const withChild = event as AgentEventWithChild;
|
|
904
|
+
if (withChild.childRef) {
|
|
905
|
+
return handleChildAgentEvent(withChild, ctx);
|
|
906
|
+
}
|
|
626
907
|
const chatId = event.subject;
|
|
627
|
-
if (!chatId) return [];
|
|
628
908
|
try {
|
|
909
|
+
// Best-effort busy classifier for fork-on-busy: only MAIN turns feed it
|
|
910
|
+
// (child events took the branch above).
|
|
911
|
+
await noteMainTurnEvent(ctx, withChild).catch(() => {});
|
|
912
|
+
if (!chatId) return [];
|
|
629
913
|
if (event.type === 'turn_completed' || event.type === 'turn_failed') {
|
|
630
914
|
// Staleness guard: only idle if this event's turn covers the AGENT'S
|
|
631
915
|
// latest emitted envelope (the marker is agent-global — all chats share
|
|
632
916
|
// the one main session, so a done signal for chat A's turn must not
|
|
633
917
|
// clear the indicator while a later chat-B message is queued/running).
|
|
634
|
-
//
|
|
635
|
-
//
|
|
918
|
+
// With injectInFlight (parel F3), an absorbed envelope has no turn of
|
|
919
|
+
// its own — its id is appended to the absorbing turn's envelopeIds, so
|
|
920
|
+
// this membership check settles it naturally. A late/retried event for
|
|
921
|
+
// a superseded turn is dropped. Fail-open when the marker is
|
|
922
|
+
// unavailable (a stuck ring is worse than a blink).
|
|
636
923
|
const current = await ctx.store?.get(TURN_ENVELOPE_KEY).catch(() => null);
|
|
637
924
|
if (typeof current === 'string' && current && !event.envelopeIds.includes(current)) {
|
|
638
925
|
return [];
|
|
@@ -672,28 +959,72 @@ export async function handleParallAgentEvent(
|
|
|
672
959
|
}
|
|
673
960
|
return [];
|
|
674
961
|
}
|
|
675
|
-
// turn_started (processing is
|
|
676
|
-
// (E3 — no resolve loop wired yet,
|
|
677
|
-
//
|
|
962
|
+
// turn_started feeds only the busy classifier above (processing is
|
|
963
|
+
// input-step-driven); execution_paused (E3 — no resolve loop wired yet,
|
|
964
|
+
// HITL deliberately deferred) is ignored.
|
|
678
965
|
} catch (err) {
|
|
679
966
|
console.error('[parel-channel] agent event handling failed', err);
|
|
680
967
|
}
|
|
681
968
|
return [];
|
|
682
969
|
}
|
|
683
970
|
|
|
684
|
-
/**
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
971
|
+
/**
|
|
972
|
+
* Agent events from a connector-spawned fork child (they carry childRef).
|
|
973
|
+
* Mirrors the main-session handling against the CHILD's ase_ instead:
|
|
974
|
+
* turn done → idle the child session (its indicator/panel), turn_failed →
|
|
975
|
+
* error step first, E2 → trace steps. Additionally settles the fork's
|
|
976
|
+
* pending spawn ack: the opening dispatch is acked on the child's first
|
|
977
|
+
* observed event (a spawn that never produces one leaves it pending for the
|
|
978
|
+
* catch-up sweep — see fork.ts). Child turns never touch the main session's
|
|
979
|
+
* turnEnvelope marker or the busy classifier.
|
|
980
|
+
*/
|
|
981
|
+
async function handleChildAgentEvent(
|
|
982
|
+
event: AgentEventWithChild,
|
|
983
|
+
ctx: ConnectorContext,
|
|
984
|
+
): Promise<AgentEventEffect[]> {
|
|
985
|
+
const childRef = event.childRef ?? '';
|
|
986
|
+
const effects: AgentEventEffect[] = [];
|
|
987
|
+
try {
|
|
988
|
+
const settled = await settleChildEvent(ctx, event).catch(() => null);
|
|
989
|
+
for (const ack of settled?.pendingAcks ?? []) {
|
|
990
|
+
// Ack the fork's parked WorkItems (by id) — the child demonstrably
|
|
991
|
+
// exists and holds them. Re-sent on later events (bounded) because the
|
|
992
|
+
// fetch effect is fire-and-forget; the ack endpoint is idempotent.
|
|
993
|
+
effects.push(ackByIdEffect(ctx, ack.dispatchId));
|
|
994
|
+
}
|
|
995
|
+
const subject = event.subject || settled?.subject || '';
|
|
996
|
+
if (!subject) return effects;
|
|
997
|
+
// A childRef outside live bookkeeping is a LATE callback (post-reset or
|
|
998
|
+
// post-give-up): resolving a session here would blind-recreate a fork
|
|
999
|
+
// ase_ under the new conversation line — drop the event instead.
|
|
1000
|
+
if (!(await childRefKnown(ctx, childRef))) return effects;
|
|
1001
|
+
const sessionId = await ensureChildSession(ctx, childRef);
|
|
1002
|
+
if (!sessionId) return effects;
|
|
1003
|
+
if (event.type === 'turn_completed' || event.type === 'turn_failed') {
|
|
1004
|
+
let stale = false;
|
|
1005
|
+
if (event.type === 'turn_failed') {
|
|
1006
|
+
const stepResult = await createErrorStep(ctx, sessionId, {
|
|
1007
|
+
subject,
|
|
1008
|
+
turnId: event.turnId,
|
|
1009
|
+
error: event.error,
|
|
1010
|
+
});
|
|
1011
|
+
stale = stepResult === 'stale';
|
|
1012
|
+
}
|
|
1013
|
+
const result = await patchSession(ctx, sessionId, { status: 'idle' });
|
|
1014
|
+
if (stale || result === 'stale') await invalidateChildSession(ctx, childRef);
|
|
1015
|
+
return effects;
|
|
1016
|
+
}
|
|
1017
|
+
if (
|
|
1018
|
+
event.type === 'model_reasoning' ||
|
|
1019
|
+
event.type === 'tool_call' ||
|
|
1020
|
+
event.type === 'tool_result'
|
|
1021
|
+
) {
|
|
1022
|
+
const result = await createTraceStep(ctx, sessionId, subject, event);
|
|
1023
|
+
if (result === 'stale') await invalidateChildSession(ctx, childRef);
|
|
1024
|
+
return effects;
|
|
1025
|
+
}
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
console.error('[parel-channel] child agent event handling failed', err);
|
|
1028
|
+
}
|
|
1029
|
+
return effects;
|
|
699
1030
|
}
|