@parall/parel-channel 1.41.0 → 1.42.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 +4 -3
- package/dist/channel-prompt.d.ts.map +1 -1
- package/dist/channel-prompt.js +4 -3
- package/dist/delivery.d.ts +23 -16
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +85 -116
- package/dist/fork.d.ts +8 -5
- package/dist/fork.d.ts.map +1 -1
- package/dist/fork.js +9 -6
- package/dist/inbound-channel.d.ts +1 -1
- package/dist/inbound-channel.d.ts.map +1 -1
- package/dist/inbound-channel.js +13 -7
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +87 -20
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +166 -2
- package/dist/typed-dispatch.d.ts +14 -8
- package/dist/typed-dispatch.d.ts.map +1 -1
- package/dist/typed-dispatch.js +5 -3
- package/package.json +1 -1
- package/src/channel-prompt.ts +4 -3
- package/src/delivery.ts +84 -114
- package/src/fork.ts +9 -6
- package/src/inbound-channel.ts +14 -6
- package/src/inbound.ts +109 -23
- package/src/session.ts +201 -1
- package/src/typed-dispatch.ts +19 -11
package/src/delivery.ts
CHANGED
|
@@ -1,44 +1,48 @@
|
|
|
1
1
|
import type { ChannelDelivery, ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
|
|
2
|
-
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
3
2
|
import { childRefKnown } from './fork.js';
|
|
4
3
|
import {
|
|
4
|
+
completeFencedEnvelopes,
|
|
5
|
+
createSuppressedTextStep,
|
|
5
6
|
ensureChildSession,
|
|
6
7
|
ensureSession,
|
|
7
8
|
invalidateChildSession,
|
|
8
9
|
invalidateSession,
|
|
9
10
|
patchSession,
|
|
10
|
-
REQUEST_TIMEOUT_MS,
|
|
11
11
|
TURN_ENVELOPE_KEY,
|
|
12
12
|
} from './session.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Turn-end delivery — which, under the platform reply contract, delivers
|
|
16
|
+
* NOTHING to the chat.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* The runtime-standard output contract (agent-dm-loop-prevention.md Layer 0,
|
|
19
|
+
* parel-reply-contract-alignment.md): plain turn output is never projected as
|
|
20
|
+
* a chat message — the agent replies explicitly via `parall messages send`
|
|
21
|
+
* from its sandbox, and that send carries the dispatch_source ledger binding.
|
|
22
|
+
* This hook records the turn-end text as a suppressed audit step on the
|
|
23
|
+
* session (the same posture the claude-agent / openclaw bridges enforce),
|
|
24
|
+
* runs a fence-checked ledger close, and a redundant session idle; nothing
|
|
25
|
+
* else.
|
|
21
26
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* by delivery id + message idempotency_key). The turn's PRIMARY done signal
|
|
26
|
-
* lives in onAgentEvent (turn_completed); the idle here is a deliberate
|
|
27
|
-
* redundant close for bindings without `observe: [turn]` — see the inline
|
|
28
|
-
* comment at the bottom.
|
|
27
|
+
* parel still auto-invokes deliver() at turn end for provider_http reply
|
|
28
|
+
* routes (C4) — ignoring the payload here is the connector's half of the
|
|
29
|
+
* contract; no parel-side change is needed.
|
|
29
30
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* A fork-owned turn (replyRoute carries childRef) records its audit step on
|
|
32
|
+
* the CHILD's ase_ and idles IT — its session panel owns the turn, not
|
|
33
|
+
* main's. A stale childRef (fork purged by New Session / given up on) drops
|
|
34
|
+
* the text entirely: blind-recreating a fork ase_ under the new conversation
|
|
35
|
+
* line would resurrect pre-reset state.
|
|
36
|
+
*
|
|
37
|
+
* External IM turns (channel_message dispatches) route here too with a
|
|
38
|
+
* conversationId and no chatId: their reply goes out through the provider
|
|
39
|
+
* clip, agent-invoked, and their turn-end text was already deliberately
|
|
40
|
+
* undelivered — only the ledger close + redundant idle run for them.
|
|
34
41
|
*/
|
|
35
42
|
export async function buildParallDelivery(
|
|
36
43
|
delivery: ChannelDelivery,
|
|
37
44
|
ctx: ConnectorContext,
|
|
38
45
|
): Promise<ConnectorEffect[]> {
|
|
39
|
-
const apiUrl = parallApiUrl(ctx);
|
|
40
|
-
const agk = requireAgk(ctx);
|
|
41
|
-
const orgId = parallOrgId(ctx);
|
|
42
46
|
const route = (delivery.replyRoute?.data ?? {}) as {
|
|
43
47
|
chatId?: string;
|
|
44
48
|
messageId?: string;
|
|
@@ -47,108 +51,74 @@ export async function buildParallDelivery(
|
|
|
47
51
|
conversationId?: string;
|
|
48
52
|
childRef?: string;
|
|
49
53
|
};
|
|
50
|
-
const chatId = route.chatId;
|
|
51
|
-
if (!chatId) {
|
|
52
|
-
// No delivery target: external IM turns (channel_message — the reply
|
|
53
|
-
// already went out through the provider clip, agent-invoked) and typed
|
|
54
|
-
// dispatch turns (task/schedule/trigger/wiki — the agent acts via its
|
|
55
|
-
// sandbox CLI). The turn-end text is deliberately NOT delivered anywhere
|
|
56
|
-
// (same contract as agent-core: plain output is not the reply). Only run
|
|
57
|
-
// the redundant idle so a binding without `observe: [turn]` still closes
|
|
58
|
-
// the session.
|
|
59
|
-
if (route.envelopeId) {
|
|
60
|
-
const sessionId = await ensureSession(ctx, { timeoutMs: 3_000 });
|
|
61
|
-
if (sessionId) {
|
|
62
|
-
await idleUnlessSuperseded(ctx, route.envelopeId, sessionId);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return [];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const payload = delivery.payload as { text?: string } | string | undefined;
|
|
69
|
-
const text = typeof payload === 'string' ? payload : (payload?.text ?? '');
|
|
70
|
-
if (!text) return [];
|
|
71
54
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// miss we degrade to a reply without the session link rather than delay it.
|
|
75
|
-
// A fork-owned envelope (replyRoute carries childRef) links to the CHILD's
|
|
76
|
-
// ase_ — its session panel owns this turn, not main's. Same short timeout
|
|
77
|
-
// as the main path: this sits in front of the user-visible reply POST, so
|
|
78
|
-
// a slow resolve degrades to a reply without the session link, never a
|
|
79
|
-
// 10s-delayed message.
|
|
55
|
+
// A fork-owned envelope whose bookkeeping is gone is a late/stale delivery
|
|
56
|
+
// (post-reset or post-give-up) — drop it entirely, audit step included.
|
|
80
57
|
const childLive = route.childRef ? await childRefKnown(ctx, route.childRef) : false;
|
|
81
58
|
if (route.childRef && !childLive) {
|
|
82
|
-
|
|
83
|
-
// given up on (black hole → the opening already re-planned onto main).
|
|
84
|
-
// Posting would land pre-reset output in the fresh conversation line, or
|
|
85
|
-
// duplicate a reply main already produced — drop it entirely (no POST,
|
|
86
|
-
// no retry effect).
|
|
87
|
-
console.warn('[parel-channel] dropping reply from stale fork', route.childRef);
|
|
59
|
+
console.warn('[parel-channel] dropping turn-end text from stale fork', route.childRef);
|
|
88
60
|
return [];
|
|
89
61
|
}
|
|
90
|
-
const sessionId = route.childRef
|
|
91
|
-
? await ensureChildSession(ctx, route.childRef, { timeoutMs: 3_000 })
|
|
92
|
-
: await ensureSession(ctx, { timeoutMs: 3_000 });
|
|
93
62
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}),
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
const res = await fetch(request.url, {
|
|
120
|
-
method: request.method,
|
|
121
|
-
headers: request.headers,
|
|
122
|
-
body: request.body,
|
|
123
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
124
|
-
});
|
|
125
|
-
if (!res.ok) {
|
|
126
|
-
// 4xx (bar 429) is permanent — retrying an unprocessable reply burns
|
|
127
|
-
// requests forever. Drop it (logged); turn_completed still closes the
|
|
128
|
-
// turn's indicator.
|
|
129
|
-
const permanent = res.status >= 400 && res.status < 500 && res.status !== 429;
|
|
130
|
-
if (!permanent) throw new Error(`reply post failed (${res.status})`);
|
|
131
|
-
console.error(`[parel-channel] reply rejected (${res.status}) — dropping`);
|
|
63
|
+
if (route.chatId) {
|
|
64
|
+
const payload = delivery.payload as { text?: string } | string | undefined;
|
|
65
|
+
const text = typeof payload === 'string' ? payload : (payload?.text ?? '');
|
|
66
|
+
if (text) {
|
|
67
|
+
// Audit-only: the session panel shows what the model produced, the
|
|
68
|
+
// chat never does. Short timeout — this hook sits at turn end, nothing
|
|
69
|
+
// user-visible waits on it. Fork turns land the step on the child ase_.
|
|
70
|
+
const sessionId = route.childRef
|
|
71
|
+
? await ensureChildSession(ctx, route.childRef, { timeoutMs: 3_000 })
|
|
72
|
+
: await ensureSession(ctx, { timeoutMs: 3_000 });
|
|
73
|
+
if (sessionId) {
|
|
74
|
+
const result = await createSuppressedTextStep(ctx, sessionId, {
|
|
75
|
+
subject: route.chatId,
|
|
76
|
+
deliveryId: delivery.id,
|
|
77
|
+
text,
|
|
78
|
+
});
|
|
79
|
+
if (result === 'stale') {
|
|
80
|
+
if (route.childRef) await invalidateChildSession(ctx, route.childRef);
|
|
81
|
+
else await invalidateSession(ctx);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
132
84
|
}
|
|
133
|
-
} catch (err) {
|
|
134
|
-
console.error('[parel-channel] direct reply failed; falling back to host retry', err);
|
|
135
|
-
return [{ type: 'fetch', request, idempotencyKey: `reply:${delivery.id}` }];
|
|
136
85
|
}
|
|
137
86
|
|
|
138
|
-
// Redundant
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// overlap
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
87
|
+
// Redundant turn-end ledger close, fence-checked — the deliver-side twin of
|
|
88
|
+
// onAgentEvent's complete. Exists for bindings WITHOUT `observe: [turn]`
|
|
89
|
+
// (the npm ^1 fallback path): there turn events never arrive and, with
|
|
90
|
+
// ack-on-emit gone, this hook is the only terminal ledger path — without it
|
|
91
|
+
// a delivered turn's row would stay live and re-emit on every TTL sweep.
|
|
92
|
+
// Under the normal artifact path both hooks run; the fence makes the
|
|
93
|
+
// overlap free (whichever completes first consumes it, the other skips).
|
|
94
|
+
// Fork-handled envelopes never wrote a fence, so this is a no-op for them
|
|
95
|
+
// (their ack machinery owns the ledger there).
|
|
96
|
+
// Known bound of this fallback: the replyRoute snapshot carries ONE
|
|
97
|
+
// envelope id, so a folded turn's OTHER sources heal only via the TTL
|
|
98
|
+
// re-emit here. Reaching that state needs a binding with injectInFlight
|
|
99
|
+
// ON and observe OFF — the platform template always sets both together,
|
|
100
|
+
// so it exists only on a hand-crafted binding.
|
|
101
|
+
if (route.envelopeId) {
|
|
102
|
+
await completeFencedEnvelopes(ctx, [route.envelopeId]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const subject = route.chatId ?? route.conversationId;
|
|
106
|
+
if (subject) {
|
|
107
|
+
// Redundant done signal, kept deliberately: turn_completed (onAgentEvent)
|
|
108
|
+
// is the primary close for every turn — including no-reply turns — but a
|
|
109
|
+
// binding deployed without `observe: [turn]` would otherwise never idle.
|
|
110
|
+
// Child turns skip the marker guard entirely — the marker is main's; a
|
|
111
|
+
// child's own turn ending always idles the child ase_.
|
|
112
|
+
const sessionId = route.childRef
|
|
113
|
+
? await ensureChildSession(ctx, route.childRef, { timeoutMs: 3_000 })
|
|
114
|
+
: await ensureSession(ctx, { timeoutMs: 3_000 });
|
|
115
|
+
if (sessionId) {
|
|
116
|
+
if (route.childRef) {
|
|
117
|
+
const result = await patchSession(ctx, sessionId, { status: 'idle' });
|
|
118
|
+
if (result === 'stale') await invalidateChildSession(ctx, route.childRef);
|
|
119
|
+
} else {
|
|
120
|
+
await idleUnlessSuperseded(ctx, route.envelopeId, sessionId);
|
|
121
|
+
}
|
|
152
122
|
}
|
|
153
123
|
}
|
|
154
124
|
return [];
|
package/src/fork.ts
CHANGED
|
@@ -411,11 +411,14 @@ export async function handleChildSpawnFailed(
|
|
|
411
411
|
* equivalent; the platform already excludes in-flight output from the seed,
|
|
412
412
|
* this line keeps the child from "finishing" main's visible work).
|
|
413
413
|
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
414
|
+
* Replies are CLI-only on EVERY fork turn (the platform reply contract —
|
|
415
|
+
* nothing auto-delivers, parel-reply-contract-alignment.md). The opening
|
|
416
|
+
* turn additionally has no envelope (it starts from the spawn input), so it
|
|
417
|
+
* gets no per-turn invocation context either — the prefix therefore spells
|
|
418
|
+
* out the full CLI reply command with the explicit chat target; follow-ups
|
|
419
|
+
* arrive as deliverTo envelopes whose invocation context presets
|
|
420
|
+
* PRLL_CHAT_ID, so the system prompt's bare `parall messages send` teaching
|
|
421
|
+
* covers them.
|
|
419
422
|
*/
|
|
420
423
|
export function buildForkScopePrefix(subject: string, threadRootId?: string): string {
|
|
421
424
|
// Quoted-heredoc input form (mirrors agent-core's send hint): plain
|
|
@@ -427,6 +430,6 @@ export function buildForkScopePrefix(subject: string, threadRootId?: string): st
|
|
|
427
430
|
return (
|
|
428
431
|
`[Fork scope: you are a parallel branch of your main session, handling ONE conversation (${subject}) only. ` +
|
|
429
432
|
`Act only on this conversation's request. Do not continue, complete, or produce output for other work visible in your inherited history — the main session owns it.]\n` +
|
|
430
|
-
`<system-reminder>Reply to THIS message with your sandbox CLI: \`${send}\` … \`EOF\` — the quoted heredoc keeps \`$\`, backticks and apostrophes literal.
|
|
433
|
+
`<system-reminder>Reply to THIS message with your sandbox CLI: \`${send}\` … \`EOF\` — the quoted heredoc keeps \`$\`, backticks and apostrophes literal. Your plain text output is NOT delivered anywhere — on this and every later message in this conversation, reply via \`parall messages send\`.</system-reminder>\n\n`
|
|
431
434
|
);
|
|
432
435
|
}
|
package/src/inbound-channel.ts
CHANGED
|
@@ -7,9 +7,12 @@ import {
|
|
|
7
7
|
ensureSession,
|
|
8
8
|
invalidateSession,
|
|
9
9
|
markDispatchReceived,
|
|
10
|
+
nextEnvelopeId,
|
|
10
11
|
patchSession,
|
|
12
|
+
readActiveEmit,
|
|
11
13
|
REQUEST_TIMEOUT_MS,
|
|
12
14
|
TURN_ENVELOPE_KEY,
|
|
15
|
+
writeActiveEmit,
|
|
13
16
|
} from './session.js';
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -42,7 +45,6 @@ export const PROVIDER_LOOKUP_TIMEOUT_MS = 3_000;
|
|
|
42
45
|
*/
|
|
43
46
|
export async function effectsForChannelDispatch(
|
|
44
47
|
sourceId: string,
|
|
45
|
-
dispatchId: string,
|
|
46
48
|
ctx: ConnectorContext,
|
|
47
49
|
): Promise<ConnectorEffect[]> {
|
|
48
50
|
const apiUrl = parallApiUrl(ctx);
|
|
@@ -109,10 +111,12 @@ export async function effectsForChannelDispatch(
|
|
|
109
111
|
const externalMessageId = msg?.external_message_id ? String(msg.external_message_id) : undefined;
|
|
110
112
|
|
|
111
113
|
// Progress reporting — identical ordering contract to the message path
|
|
112
|
-
// (see the comments there): received settles before
|
|
113
|
-
//
|
|
114
|
+
// (see the comments there): received settles before this function returns;
|
|
115
|
+
// the turnEnvelope marker goes down before any session write; the row
|
|
116
|
+
// stays 'received' until the turn event's complete-sources.
|
|
114
117
|
const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
|
|
115
|
-
const
|
|
118
|
+
const priorEmit = await readActiveEmit(ctx, 'channel_message', sourceId);
|
|
119
|
+
const envelopeId = nextEnvelopeId('channel_message', sourceId, priorEmit);
|
|
116
120
|
let sessionId = await ensureSession(ctx);
|
|
117
121
|
if (sessionId) {
|
|
118
122
|
await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => {});
|
|
@@ -148,6 +152,7 @@ export async function effectsForChannelDispatch(
|
|
|
148
152
|
void recordConversationSession(ctx, conversationId, sessionId).catch(() => {});
|
|
149
153
|
}
|
|
150
154
|
await receivedSettled;
|
|
155
|
+
await writeActiveEmit(ctx, 'channel_message', sourceId, envelopeId);
|
|
151
156
|
|
|
152
157
|
return [
|
|
153
158
|
{
|
|
@@ -190,10 +195,13 @@ export async function effectsForChannelDispatch(
|
|
|
190
195
|
},
|
|
191
196
|
// No invocation context: PRLL_CHAT_ID must not point CLI defaults at
|
|
192
197
|
// a chat that doesn't exist, and the reply targets (external ids)
|
|
193
|
-
// already ride the prompt verbatim.
|
|
198
|
+
// already ride the prompt verbatim. (So no PRLL_DISPATCH_SOURCE_*
|
|
199
|
+
// either — the external reply goes out through the provider clip,
|
|
200
|
+
// and this row's ledger close is the turn event's complete-sources.)
|
|
194
201
|
},
|
|
195
202
|
},
|
|
196
|
-
|
|
203
|
+
// No ack effect — see the message path (inbound.ts): the row stays
|
|
204
|
+
// received until complete-sources closes it.
|
|
197
205
|
];
|
|
198
206
|
}
|
|
199
207
|
|
package/src/inbound.ts
CHANGED
|
@@ -34,22 +34,27 @@ import {
|
|
|
34
34
|
import {
|
|
35
35
|
ackByIdEffect,
|
|
36
36
|
ackEffect,
|
|
37
|
+
completeFencedEnvelopes,
|
|
37
38
|
conversationKey,
|
|
38
39
|
createErrorStep,
|
|
39
40
|
fetchMainSessionFresh,
|
|
40
41
|
createInputStep,
|
|
41
42
|
createTraceStep,
|
|
42
43
|
createTypedInputStep,
|
|
44
|
+
EMIT_TTL_MS,
|
|
43
45
|
ensureChildSession,
|
|
44
46
|
ensureSession,
|
|
45
47
|
invalidateChildSession,
|
|
46
48
|
invalidateSession,
|
|
47
49
|
markDispatchReceived,
|
|
50
|
+
nextEnvelopeId,
|
|
48
51
|
patchSession,
|
|
52
|
+
readActiveEmit,
|
|
49
53
|
reportingEnabled,
|
|
50
54
|
REQUEST_TIMEOUT_MS,
|
|
51
55
|
SESSION_CACHE_KEY,
|
|
52
56
|
TURN_ENVELOPE_KEY,
|
|
57
|
+
writeActiveEmit,
|
|
53
58
|
} from './session.js';
|
|
54
59
|
import { isTypedEventType, planTypedDispatch } from './typed-dispatch.js';
|
|
55
60
|
|
|
@@ -58,10 +63,20 @@ import { isTypedEventType, planTypedDispatch } from './typed-dispatch.js';
|
|
|
58
63
|
*
|
|
59
64
|
* The Parall gateway pushes `dispatch.new` signals on the agent's WS; the
|
|
60
65
|
* dispatch is a pointer (source_type/source_id), not the payload. For each
|
|
61
|
-
* message dispatch we pull the source message text,
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
66
|
+
* message dispatch we pull the source message text, mark it received, and
|
|
67
|
+
* emit it to the agent as a ChannelEnvelope. MAIN-path rows then stay
|
|
68
|
+
* `received` for the whole turn — their terminal resolution is the CLI
|
|
69
|
+
* reply's dispatch_source (effect) or the turn event's complete-sources
|
|
70
|
+
* (broad-cover / no_action), the ledger lifecycle the chat activity
|
|
71
|
+
* indicator rides on (parel-reply-contract-alignment.md; the old ack-on-emit
|
|
72
|
+
* killed the indicator at turn start). Redelivery protection is the
|
|
73
|
+
* per-source attempt fence in the durable store: on (re)connect, onOpen
|
|
74
|
+
* sweeps `GET /dispatch` (the WS push has no replay), skips rows whose fence
|
|
75
|
+
* is younger than EMIT_TTL_MS, and re-emits stale ones under a fresh
|
|
76
|
+
* attempt-suffixed envelope id. FORK paths (deliverTo/spawn) keep their own
|
|
77
|
+
* ack machinery — the pending ack doubles as spawn-failure recovery there —
|
|
78
|
+
* so fork-handled rows still resolve as legacy_ack (indicator parity for
|
|
79
|
+
* forked turns is a design follow-up, parel-reply-contract-alignment.md §6).
|
|
65
80
|
*
|
|
66
81
|
* Contract (confirmed against parel #102–#107, routing switched with #127):
|
|
67
82
|
* raw WS frames are handed to onMessage verbatim; the binding created by the
|
|
@@ -317,6 +332,30 @@ async function catchUpParallInner(ctx: ConnectorContext): Promise<ConnectorEffec
|
|
|
317
332
|
return effects;
|
|
318
333
|
}
|
|
319
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Turn-in-flight guard for redelivery (catch-up sweep AND repeated
|
|
337
|
+
* dispatch.new pushes). Main-path rows stay live (not acked) for the whole
|
|
338
|
+
* turn now, and GET /dispatch lists pending AND received rows — so redelivery
|
|
339
|
+
* re-sees every in-flight source. Skip any row whose attempt fence is younger
|
|
340
|
+
* than EMIT_TTL_MS, REGARDLESS of row status: a transiently-failed
|
|
341
|
+
* /dispatch/received leaves the row `pending` while the emit succeeded (fence
|
|
342
|
+
* written), and re-emitting it inside the TTL would duplicate the in-flight
|
|
343
|
+
* turn — complete-sources resolves pending rows just as well, so the fence
|
|
344
|
+
* alone is the truth. Past the TTL (crashed emit, lost turn events) fall
|
|
345
|
+
* through and re-emit with a fresh attempt-suffixed envelope id. Brand-new
|
|
346
|
+
* sources have no fence and never skip; fork-handled rows never write a
|
|
347
|
+
* fence (their ack machinery owns redelivery there).
|
|
348
|
+
* Design: parel-reply-contract-alignment.md §5.3.
|
|
349
|
+
*/
|
|
350
|
+
async function shouldSkipLiveRow(
|
|
351
|
+
ctx: ConnectorContext,
|
|
352
|
+
sourceType: string,
|
|
353
|
+
sourceId: string,
|
|
354
|
+
): Promise<boolean> {
|
|
355
|
+
const active = await readActiveEmit(ctx, sourceType, sourceId);
|
|
356
|
+
return active !== null && ctx.now() - active.at < EMIT_TTL_MS;
|
|
357
|
+
}
|
|
358
|
+
|
|
320
359
|
async function effectsForDispatch(
|
|
321
360
|
data: DispatchData,
|
|
322
361
|
ctx: ConnectorContext,
|
|
@@ -328,7 +367,8 @@ async function effectsForDispatch(
|
|
|
328
367
|
if (sourceType === 'channel_message' && sourceId) {
|
|
329
368
|
// External IM message (chat_id is NULL by design — the conversation
|
|
330
369
|
// lives on the provider, not in a Parall chat).
|
|
331
|
-
|
|
370
|
+
if (await shouldSkipLiveRow(ctx, sourceType, sourceId)) return [];
|
|
371
|
+
return effectsForChannelDispatch(sourceId, ctx);
|
|
332
372
|
}
|
|
333
373
|
const eventType = String(data.event_type ?? '');
|
|
334
374
|
if (sourceId && isTypedEventType(eventType)) {
|
|
@@ -341,10 +381,13 @@ async function effectsForDispatch(
|
|
|
341
381
|
return [];
|
|
342
382
|
}
|
|
343
383
|
// Conversational-only deployments (no sandbox → no `parall` CLI) cannot
|
|
344
|
-
// act on
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
384
|
+
// act on typed work AT ALL under the CLI-only reply contract — nothing
|
|
385
|
+
// auto-delivers anymore, approval_decided included — so emitting AND
|
|
386
|
+
// acking would silently consume real work. Keep it pending (same
|
|
387
|
+
// posture as unknown types). Defensive-only: the server's mandatory
|
|
388
|
+
// sandbox gate (422 PAREL_SANDBOX_REQUIRED) means no such managed
|
|
389
|
+
// agent can be provisioned.
|
|
390
|
+
if (!sandboxEnabled(ctx)) {
|
|
348
391
|
return [];
|
|
349
392
|
}
|
|
350
393
|
return effectsForTypedDispatch(data, ctx);
|
|
@@ -359,6 +402,7 @@ async function effectsForDispatch(
|
|
|
359
402
|
// A message dispatch with no chat can never be routed or replied to.
|
|
360
403
|
return [ackEffect(ctx, sourceType, sourceId)];
|
|
361
404
|
}
|
|
405
|
+
if (await shouldSkipLiveRow(ctx, sourceType, sourceId)) return [];
|
|
362
406
|
|
|
363
407
|
const apiUrl = parallApiUrl(ctx);
|
|
364
408
|
const agk = requireAgk(ctx);
|
|
@@ -426,7 +470,11 @@ async function effectsForDispatch(
|
|
|
426
470
|
text,
|
|
427
471
|
attachments,
|
|
428
472
|
});
|
|
429
|
-
|
|
473
|
+
// Deterministic source-derived envelope id (+ attempt suffix on re-emits):
|
|
474
|
+
// complete-sources strips the suffix back to the source, and every re-emit
|
|
475
|
+
// is globally unique so parel's dedupe (either way) can't strand a retry.
|
|
476
|
+
const priorEmit = await readActiveEmit(ctx, sourceType, sourceId);
|
|
477
|
+
const envelopeId = nextEnvelopeId(sourceType, sourceId, priorEmit);
|
|
430
478
|
// Input-step summary must never be empty (attachment-only messages).
|
|
431
479
|
const stepText = text || `[${attachments.length} attachment(s)]`;
|
|
432
480
|
// Thread-aware conversation identity: F2 grouping / F3 injection / fork
|
|
@@ -443,9 +491,16 @@ async function effectsForDispatch(
|
|
|
443
491
|
// Per-turn invocation context: parel snapshots this at turn start and
|
|
444
492
|
// consume-gated plugins receive it — sandbox-e2b flattens it into per-exec
|
|
445
493
|
// env, so `parall` CLI calls inside the sandbox land on the right chat.
|
|
494
|
+
// The PRLL_DISPATCH_SOURCE_* pair is the CLI's by-source ledger binding: a
|
|
495
|
+
// send back into the exact trigger (chat, thread) carries dispatch_source
|
|
496
|
+
// so the server resolves this WorkItem in the reply's own transaction.
|
|
497
|
+
// (Fork turns get it too — their rows are legacy-acked so the resolve is a
|
|
498
|
+
// no-op today, and it wires up for free when fork ledger parity lands.)
|
|
446
499
|
const invocationContext: Record<string, string> = {
|
|
447
500
|
PRLL_CHAT_ID: chatId,
|
|
448
501
|
PRLL_TRIGGER_MESSAGE_ID: sourceId,
|
|
502
|
+
PRLL_DISPATCH_SOURCE_TYPE: sourceType,
|
|
503
|
+
PRLL_DISPATCH_SOURCE_ID: sourceId,
|
|
449
504
|
...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
|
|
450
505
|
};
|
|
451
506
|
|
|
@@ -617,15 +672,16 @@ async function effectsForDispatch(
|
|
|
617
672
|
// fall through: black-holed spawn rides main below.
|
|
618
673
|
}
|
|
619
674
|
|
|
620
|
-
// Main path. Progress reporting (best-effort
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
675
|
+
// Main path. Progress reporting (best-effort): received flips the row out
|
|
676
|
+
// of 'pending' and lights the indicator (dispatch.received → active); the
|
|
677
|
+
// row then STAYS 'received' for the whole turn — resolution comes from the
|
|
678
|
+
// CLI reply's dispatch_source or the turn event's complete-sources, never
|
|
679
|
+
// from an ack here (the old ack-on-emit is what killed the indicator
|
|
680
|
+
// early; parel-reply-contract-alignment.md §1.1). received still settles
|
|
681
|
+
// before this function returns so a same-batch reply can never race the
|
|
682
|
+
// pending→received transition. It is deliberately NOT gated on the
|
|
683
|
+
// session resolving: a transient session failure must not also cost the
|
|
684
|
+
// indicator.
|
|
629
685
|
const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
|
|
630
686
|
let sessionId = await ensureSession(ctx);
|
|
631
687
|
if (sessionId) {
|
|
@@ -664,8 +720,21 @@ async function effectsForDispatch(
|
|
|
664
720
|
}
|
|
665
721
|
}
|
|
666
722
|
await receivedSettled;
|
|
723
|
+
// Arm the attempt fence BEFORE the emit effect is returned: turn events
|
|
724
|
+
// complete a source only while `active:{source}` matches their envelope id
|
|
725
|
+
// exactly, and the redelivery-skip window starts at this timestamp. Only
|
|
726
|
+
// the main path writes it — fork paths keep their own ack machinery.
|
|
727
|
+
// Deliberate ordering tradeoff: a crash between this write and the host
|
|
728
|
+
// executing the emit leaves an armed fence for an un-emitted source, so
|
|
729
|
+
// the reconnect sweep hides it until the TTL re-emit (≤30min delay). The
|
|
730
|
+
// reverse order (fence after emit) would instead double-emit on a crash —
|
|
731
|
+
// a duplicate agent turn. Delay heals; duplicates don't.
|
|
732
|
+
await writeActiveEmit(ctx, sourceType, sourceId, envelopeId);
|
|
667
733
|
|
|
668
|
-
|
|
734
|
+
// No ack effect: the row stays 'received' until the reply (dispatch
|
|
735
|
+
// _source) or the turn event (complete-sources) resolves it — the
|
|
736
|
+
// indicator's whole lifecycle rides on that.
|
|
737
|
+
return [{ type: 'emitEvent', event: envelope }];
|
|
669
738
|
}
|
|
670
739
|
|
|
671
740
|
/**
|
|
@@ -744,9 +813,11 @@ async function effectsForTypedDispatch(
|
|
|
744
813
|
replyRoute: {
|
|
745
814
|
kind: 'provider_http',
|
|
746
815
|
connectionId: ctx.connectionId,
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
816
|
+
// Replies are CLI-only for every typed event (nothing
|
|
817
|
+
// auto-delivers). chatId — only approval_decided has one — merely
|
|
818
|
+
// routes deliver()'s suppressed turn-end audit step to the chat;
|
|
819
|
+
// chat-less routes carry just the envelopeId for the redundant
|
|
820
|
+
// ledger close + idle.
|
|
750
821
|
data: plan.chatId
|
|
751
822
|
? { chatId: plan.chatId, messageId: sourceId, envelopeId }
|
|
752
823
|
: { envelopeId },
|
|
@@ -911,6 +982,21 @@ async function handleParallAgentEventInner(
|
|
|
911
982
|
await noteMainTurnEvent(ctx, withChild).catch(() => {});
|
|
912
983
|
if (!chatId) return [];
|
|
913
984
|
if (event.type === 'turn_completed' || event.type === 'turn_failed') {
|
|
985
|
+
// Ledger close FIRST, fenced per source — deliberately NOT behind the
|
|
986
|
+
// agent-global marker guard below: that guard protects the shared
|
|
987
|
+
// session's idle (last-emit-wins), while the ledger rows are
|
|
988
|
+
// per-source facts that must close whenever THEIR envelope is the one
|
|
989
|
+
// this turn consumed (a concurrent conversation-B emit must not leave
|
|
990
|
+
// conversation-A's resolution hanging). The attempt fence inside
|
|
991
|
+
// completeFencedEnvelopes drops late events from a superseded attempt
|
|
992
|
+
// (the TTL re-emit took the source over) AND fork-handled envelopes
|
|
993
|
+
// (no fence written), so it can never close the in-flight retry's
|
|
994
|
+
// work nor double-close fork acks. F3-absorbed envelopes appear in
|
|
995
|
+
// the absorbing turn's envelopeIds and settle here naturally. The
|
|
996
|
+
// server decides reply broad-cover vs no_action per batch; turn_failed
|
|
997
|
+
// sweeps the same way with the error step below as the visible record.
|
|
998
|
+
await completeFencedEnvelopes(ctx, event.envelopeIds);
|
|
999
|
+
|
|
914
1000
|
// Staleness guard: only idle if this event's turn covers the AGENT'S
|
|
915
1001
|
// latest emitted envelope (the marker is agent-global — all chats share
|
|
916
1002
|
// the one main session, so a done signal for chat A's turn must not
|