@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/dist/channel-prompt.d.ts
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
* form ({"message_id": …}) so threaded conversations aren't nudged toward
|
|
26
26
|
* new top-level messages.
|
|
27
27
|
*/
|
|
28
|
+
export interface PromptAttachment {
|
|
29
|
+
id: string;
|
|
30
|
+
fileName: string;
|
|
31
|
+
fileSize: number;
|
|
32
|
+
mimeType: string;
|
|
33
|
+
}
|
|
28
34
|
export interface ChatPromptArgs {
|
|
29
35
|
chatId: string;
|
|
30
36
|
/** Display name of the chat; undefined when unresolved (best-effort). */
|
|
@@ -32,8 +38,10 @@ export interface ChatPromptArgs {
|
|
|
32
38
|
/** Sender user id (usr_); display-name enrichment is a follow-up. */
|
|
33
39
|
senderId?: string;
|
|
34
40
|
threadRootId?: string;
|
|
35
|
-
/** The inbound message text (raw, un-framed). */
|
|
41
|
+
/** The inbound message text (raw, un-framed); may be empty (attachment-only). */
|
|
36
42
|
text: string;
|
|
43
|
+
/** Message attachments — rendered as reference lines (agent fetches via CLI). */
|
|
44
|
+
attachments?: PromptAttachment[];
|
|
37
45
|
}
|
|
38
46
|
/**
|
|
39
47
|
* Frame a Parall chat message with its conversation of origin. Kept to a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AASH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,
|
|
1
|
+
{"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AASH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,WAAW,CAAC,EAAE,gBAAgB,EAAE,CAAC;CAClC;AAmBD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAU5D;AAED,MAAM,WAAW,iBAAiB;IAChC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAkBlE"}
|
package/dist/channel-prompt.js
CHANGED
|
@@ -31,6 +31,22 @@ function sanitizeMeta(value) {
|
|
|
31
31
|
.replace(/[[\]|]/g, ' ')
|
|
32
32
|
.trim();
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* One reference line per attachment, byte-identical to agent-core's
|
|
36
|
+
* event-format rendering: the agent learns the attachment EXISTS and pulls
|
|
37
|
+
* the bytes itself through its sandbox CLI (`parall files download prll://…`)
|
|
38
|
+
* — same discover-then-fetch contract the standard runtimes use.
|
|
39
|
+
*/
|
|
40
|
+
function attachmentLines(attachments) {
|
|
41
|
+
if (!attachments?.length)
|
|
42
|
+
return [];
|
|
43
|
+
return attachments.map((att) => {
|
|
44
|
+
const sizeStr = att.fileSize >= 1048576
|
|
45
|
+
? `${(att.fileSize / 1048576).toFixed(1)}MB`
|
|
46
|
+
: `${Math.round(att.fileSize / 1024)}KB`;
|
|
47
|
+
return `[Attachment: prll://${att.id} | ${sanitizeMeta(att.mimeType)} | ${sizeStr} | ${sanitizeMeta(att.fileName)}]`;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
34
50
|
/**
|
|
35
51
|
* Frame a Parall chat message with its conversation of origin. Kept to a
|
|
36
52
|
* minimal metadata header: the text itself is the user message, and heavier
|
|
@@ -46,6 +62,7 @@ export function buildChatPrompt(args) {
|
|
|
46
62
|
lines.push(`[From: ${sanitizeMeta(args.senderId)}]`);
|
|
47
63
|
if (args.threadRootId)
|
|
48
64
|
lines.push(`[Thread: ${sanitizeMeta(args.threadRootId)}]`);
|
|
65
|
+
lines.push(...attachmentLines(args.attachments));
|
|
49
66
|
lines.push('', args.text);
|
|
50
67
|
return lines.join('\n');
|
|
51
68
|
}
|
package/dist/connect.d.ts
CHANGED
|
@@ -18,5 +18,12 @@ export declare function parallOrgId(ctx: ConnectorContext): string;
|
|
|
18
18
|
* shipped — reporting degrades to off until the agent is redeployed.
|
|
19
19
|
*/
|
|
20
20
|
export declare function parallAgentId(ctx: ConnectorContext): string;
|
|
21
|
+
/**
|
|
22
|
+
* Whether this deployment provisions a sandbox (shell + `parall` CLI) for the
|
|
23
|
+
* agent. Server writes `parallSandbox: "off"` into the connection config only
|
|
24
|
+
* for conversational-only deployments (no platform E2B key) — an absent field
|
|
25
|
+
* means sandbox-enabled (every config generated before the field existed was).
|
|
26
|
+
*/
|
|
27
|
+
export declare function sandboxEnabled(ctx: ConnectorContext): boolean;
|
|
21
28
|
export declare function requireAgk(ctx: ConnectorContext): string;
|
|
22
29
|
//# sourceMappingURL=connect.d.ts.map
|
package/dist/connect.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAE1E;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC,CAyBlF;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE3D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAMxD"}
|
|
1
|
+
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAE1E;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC,CAyBlF;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAE3D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAE7D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAMxD"}
|
package/dist/connect.js
CHANGED
|
@@ -48,6 +48,15 @@ export function parallOrgId(ctx) {
|
|
|
48
48
|
export function parallAgentId(ctx) {
|
|
49
49
|
return String(ctx.config.parallAgentId ?? '');
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this deployment provisions a sandbox (shell + `parall` CLI) for the
|
|
53
|
+
* agent. Server writes `parallSandbox: "off"` into the connection config only
|
|
54
|
+
* for conversational-only deployments (no platform E2B key) — an absent field
|
|
55
|
+
* means sandbox-enabled (every config generated before the field existed was).
|
|
56
|
+
*/
|
|
57
|
+
export function sandboxEnabled(ctx) {
|
|
58
|
+
return String(ctx.config.parallSandbox ?? '') !== 'off';
|
|
59
|
+
}
|
|
51
60
|
export function requireAgk(ctx) {
|
|
52
61
|
const agk = ctx.secrets.parallApiKey;
|
|
53
62
|
if (!agk) {
|
package/dist/delivery.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"delivery.d.ts","sourceRoot":"","sources":["../src/delivery.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"delivery.d.ts","sourceRoot":"","sources":["../src/delivery.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAa5F;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAqH5B"}
|
package/dist/delivery.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
2
|
-
import {
|
|
2
|
+
import { childRefKnown } from './fork.js';
|
|
3
|
+
import { ensureChildSession, ensureSession, invalidateChildSession, invalidateSession, patchSession, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, } from './session.js';
|
|
3
4
|
/**
|
|
4
5
|
* Post the agent's reply back to the Parall chat. The chat id rides the
|
|
5
6
|
* replyRoute.data set in inbound.ts.
|
|
@@ -28,12 +29,14 @@ export async function buildParallDelivery(delivery, ctx) {
|
|
|
28
29
|
const route = (delivery.replyRoute?.data ?? {});
|
|
29
30
|
const chatId = route.chatId;
|
|
30
31
|
if (!chatId) {
|
|
31
|
-
//
|
|
32
|
-
// the provider clip, agent-invoked
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
32
|
+
// No delivery target: external IM turns (channel_message — the reply
|
|
33
|
+
// already went out through the provider clip, agent-invoked) and typed
|
|
34
|
+
// dispatch turns (task/schedule/trigger/wiki — the agent acts via its
|
|
35
|
+
// sandbox CLI). The turn-end text is deliberately NOT delivered anywhere
|
|
36
|
+
// (same contract as agent-core: plain output is not the reply). Only run
|
|
37
|
+
// the redundant idle so a binding without `observe: [turn]` still closes
|
|
38
|
+
// the session.
|
|
39
|
+
if (route.envelopeId) {
|
|
37
40
|
const sessionId = await ensureSession(ctx, { timeoutMs: 3_000 });
|
|
38
41
|
if (sessionId) {
|
|
39
42
|
await idleUnlessSuperseded(ctx, route.envelopeId, sessionId);
|
|
@@ -48,7 +51,24 @@ export async function buildParallDelivery(delivery, ctx) {
|
|
|
48
51
|
// Short timeout: this sits in front of the user-visible reply POST. Cache
|
|
49
52
|
// hit (every dispatch after the agent's first) is a store read; on a slow
|
|
50
53
|
// miss we degrade to a reply without the session link rather than delay it.
|
|
51
|
-
|
|
54
|
+
// A fork-owned envelope (replyRoute carries childRef) links to the CHILD's
|
|
55
|
+
// ase_ — its session panel owns this turn, not main's. Same short timeout
|
|
56
|
+
// as the main path: this sits in front of the user-visible reply POST, so
|
|
57
|
+
// a slow resolve degrades to a reply without the session link, never a
|
|
58
|
+
// 10s-delayed message.
|
|
59
|
+
const childLive = route.childRef ? await childRefKnown(ctx, route.childRef) : false;
|
|
60
|
+
if (route.childRef && !childLive) {
|
|
61
|
+
// Terminal stale-child delivery: the fork was purged (New Session) or
|
|
62
|
+
// given up on (black hole → the opening already re-planned onto main).
|
|
63
|
+
// Posting would land pre-reset output in the fresh conversation line, or
|
|
64
|
+
// duplicate a reply main already produced — drop it entirely (no POST,
|
|
65
|
+
// no retry effect).
|
|
66
|
+
console.warn('[parel-channel] dropping reply from stale fork', route.childRef);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const sessionId = route.childRef
|
|
70
|
+
? await ensureChildSession(ctx, route.childRef, { timeoutMs: 3_000 })
|
|
71
|
+
: await ensureSession(ctx, { timeoutMs: 3_000 });
|
|
52
72
|
const request = {
|
|
53
73
|
url: `${apiUrl}/api/v1/orgs/${orgId}/chats/${chatId}/messages`,
|
|
54
74
|
method: 'POST',
|
|
@@ -99,9 +119,17 @@ export async function buildParallDelivery(delivery, ctx) {
|
|
|
99
119
|
// resolves this connector (npm ^1 fallback path; the artifact path freezes
|
|
100
120
|
// connector+config atomically) would otherwise never idle. Both signals
|
|
101
121
|
// run the same turnEnvelope staleness guard and idle is idempotent, so the
|
|
102
|
-
// overlap is harmless.
|
|
122
|
+
// overlap is harmless. Child turns skip the guard entirely — the marker is
|
|
123
|
+
// main's; a child's own turn ending always idles the child ase_.
|
|
103
124
|
if (sessionId) {
|
|
104
|
-
|
|
125
|
+
if (route.childRef) {
|
|
126
|
+
const result = await patchSession(ctx, sessionId, { status: 'idle' });
|
|
127
|
+
if (result === 'stale')
|
|
128
|
+
await invalidateChildSession(ctx, route.childRef);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
await idleUnlessSuperseded(ctx, route.envelopeId, sessionId);
|
|
132
|
+
}
|
|
105
133
|
}
|
|
106
134
|
return [];
|
|
107
135
|
}
|
package/dist/fork.d.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ConnectorContext } from '@parel/plugin-sdk';
|
|
2
|
+
import type { AgentEventWithChild, ChildSpawnFailedEvent } from './parel-sdk-compat.js';
|
|
3
|
+
/** Delay before a failed spawn's messages are re-driven through onTimer. */
|
|
4
|
+
export declare const FORK_RETRY_DELAY_MS = 15000;
|
|
5
|
+
export declare const FORK_RETRY_PREFIX = "forkRetry:";
|
|
6
|
+
export interface PendingAck {
|
|
7
|
+
dispatchId: string;
|
|
8
|
+
sourceId: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ChildRow {
|
|
11
|
+
childRef: string;
|
|
12
|
+
at: number;
|
|
13
|
+
/**
|
|
14
|
+
* The opening message's replay identity — set at spawn and NEVER cleared
|
|
15
|
+
* while the row lives. pendingAcks can be dropped after MAX_ACK_ATTEMPTS,
|
|
16
|
+
* but a replay of the opening source must still match branch 1a (its input
|
|
17
|
+
* was a spawn payload, not an envelope — a deliverTo replay would bypass
|
|
18
|
+
* ingress dedupe and inject the user's first message twice).
|
|
19
|
+
*/
|
|
20
|
+
openingSourceId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Un-acked WorkItems this child holds: the spawn opening dispatch plus any
|
|
23
|
+
* follow-ups routed to the child BEFORE its provisioning was confirmed (a
|
|
24
|
+
* deliverTo into a spawn that then fails terminally is ignored by the host
|
|
25
|
+
* — acking those up front would lose them). Acked (by WorkItem id) on the
|
|
26
|
+
* child's observed events; sourceIds double as replay identities (a swept
|
|
27
|
+
* dispatch whose sourceId appears here is one of these very messages).
|
|
28
|
+
* pendingAcks[0] is always the opening message.
|
|
29
|
+
*/
|
|
30
|
+
pendingAcks?: PendingAck[];
|
|
31
|
+
/** Set on the child's first observed event — provisioning demonstrably done. */
|
|
32
|
+
confirmed?: boolean;
|
|
33
|
+
/** Ack re-send counter: acks are re-issued on later events until this hits
|
|
34
|
+
* MAX_ACK_ATTEMPTS (fetch effects are fire-and-forget; one lost ack must
|
|
35
|
+
* not orphan the WorkItem forever). */
|
|
36
|
+
ackAttempts?: number;
|
|
37
|
+
}
|
|
38
|
+
export type ForkDecision = {
|
|
39
|
+
mode: 'main';
|
|
40
|
+
}
|
|
41
|
+
/** trackAck: the child is not yet confirmed — the caller must park the
|
|
42
|
+
* WorkItem ack on the bookkeeping row instead of acking on emit. */
|
|
43
|
+
| {
|
|
44
|
+
mode: 'deliverTo';
|
|
45
|
+
childRef: string;
|
|
46
|
+
trackAck: boolean;
|
|
47
|
+
} | {
|
|
48
|
+
mode: 'spawn';
|
|
49
|
+
childRef: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Decide how to route one inbound chat message. Called on the message hot
|
|
53
|
+
* path — store reads only, never a fetch.
|
|
54
|
+
*
|
|
55
|
+
* Concurrency: the decision-then-record sequence is NOT internally locked —
|
|
56
|
+
* it relies on the parel host executing one connection's hooks serially (the
|
|
57
|
+
* C3 contract; the same guarantee the turnEnvelope marker's read-then-patch
|
|
58
|
+
* ordering already leans on). Two same-chat dispatches therefore cannot both
|
|
59
|
+
* observe "no child" — the second runs after the first's recordSpawn.
|
|
60
|
+
*/
|
|
61
|
+
export declare function planForkDecision(ctx: ConnectorContext, subject: string, sourceId: string): Promise<ForkDecision>;
|
|
62
|
+
/** Record a just-issued spawn so follow-ups route to the child. Idempotent
|
|
63
|
+
* for the 1a replay path: an existing row for the same ref keeps its parked
|
|
64
|
+
* follow-up acks and confirmation state, only refreshing liveness. */
|
|
65
|
+
export declare function recordSpawn(ctx: ConnectorContext, subject: string, childRef: string, opening: PendingAck): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Park a follow-up's WorkItem ack on an unconfirmed child. Returns false when
|
|
68
|
+
* the row is gone or the cap is hit — the caller then acks on emit (the cap
|
|
69
|
+
* bounds the loss window of a pathological never-confirming child; beyond it
|
|
70
|
+
* we prefer at-most-once for the tail over unbounded growth).
|
|
71
|
+
*/
|
|
72
|
+
export declare function trackFollowUpAck(ctx: ConnectorContext, subject: string, childRef: string, ack: PendingAck): Promise<boolean>;
|
|
73
|
+
/** Whether a childRef is still part of live fork bookkeeping. Late child
|
|
74
|
+
* callbacks after a New Session purge (or after give-up) must NOT recreate
|
|
75
|
+
* fork ase_ rows via the blind get-or-create — a pre-reset child's delayed
|
|
76
|
+
* trace/reply would attach a fresh session under the NEW conversation line. */
|
|
77
|
+
export declare function childRefKnown(ctx: ConnectorContext, childRef: string): Promise<boolean>;
|
|
78
|
+
/** Whether a subject's live child has ever produced an observed event. */
|
|
79
|
+
export declare function childConfirmed(ctx: ConnectorContext, subject: string, childRef: string): Promise<boolean>;
|
|
80
|
+
/** Drop one subject's fork routing (give-up path: message re-plans fresh). */
|
|
81
|
+
export declare function clearForkSubject(ctx: ConnectorContext, subject: string, childRef: string): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Bump-and-park a fork retry ledger row (also the spawn-verification
|
|
84
|
+
* coordinate onTimer re-plans). Returns the attempt count INCLUDING this one.
|
|
85
|
+
*/
|
|
86
|
+
export declare function bumpForkRetryAttempts(ctx: ConnectorContext, retryKey: string, data: Record<string, unknown>): Promise<number>;
|
|
87
|
+
/** Give up re-verifying a spawn after this many timer rounds. */
|
|
88
|
+
export declare const MAX_SPAWN_VERIFY_ATTEMPTS = 3;
|
|
89
|
+
/**
|
|
90
|
+
* Purge ALL fork routing state — called on `agent.new_session`. The binding
|
|
91
|
+
* reset rotates main's conversation line; stale child routing surviving it
|
|
92
|
+
* would deliver post-reset messages into pre-reset forks (old context), and
|
|
93
|
+
* a stale busy marker would fork off a session that no longer exists.
|
|
94
|
+
* Returns the childRefs that were live so the caller can also invalidate
|
|
95
|
+
* their cached ase_ rows.
|
|
96
|
+
*/
|
|
97
|
+
export declare function clearForkState(ctx: ConnectorContext): Promise<string[]>;
|
|
98
|
+
/**
|
|
99
|
+
* Track main-session busyness from the E1 turn stream. Child events (they
|
|
100
|
+
* carry childRef) must NOT feed this — only the main session's turns do.
|
|
101
|
+
*/
|
|
102
|
+
export declare function noteMainTurnEvent(ctx: ConnectorContext, event: AgentEventWithChild): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a child event's bookkeeping row (by its subject mirror) and fire
|
|
105
|
+
* the pending spawn ack exactly once. Returns the ack request when due.
|
|
106
|
+
*/
|
|
107
|
+
export declare function settleChildEvent(ctx: ConnectorContext, event: AgentEventWithChild): Promise<{
|
|
108
|
+
subject: string;
|
|
109
|
+
pendingAcks: PendingAck[];
|
|
110
|
+
} | null>;
|
|
111
|
+
/**
|
|
112
|
+
* A spawn failed before the child ran. NEVER acks the opening dispatch — it
|
|
113
|
+
* stays received and the catch-up sweep re-plans it. Failure codes split
|
|
114
|
+
* three ways (parel §18.2 three-state mapping semantics):
|
|
115
|
+
* - `concurrency_limit` is TRANSIENT: the platform keeps the mapping in
|
|
116
|
+
* `provisioning` and the same childRef can retry — keep the bookkeeping so
|
|
117
|
+
* the sweep's replay re-issues the same spawn key (branch 1a) instead of
|
|
118
|
+
* abandoning one provisioning row per attempt.
|
|
119
|
+
* - `disabled` / `unsupported_routing` are configuration-shaped: clear the
|
|
120
|
+
* bookkeeping AND park a no-fork flag so the connector stops re-trying a
|
|
121
|
+
* fork the binding will never allow.
|
|
122
|
+
* - Everything else is terminal for this childRef (the ref is burned): clear
|
|
123
|
+
* the bookkeeping so the sweep's re-plan mints a fresh ref.
|
|
124
|
+
* Returns true when the bookkeeping was cleared (the caller then settles the
|
|
125
|
+
* orphaned child ase_).
|
|
126
|
+
*/
|
|
127
|
+
export declare function handleChildSpawnFailed(ctx: ConnectorContext, event: ChildSpawnFailedEvent): Promise<{
|
|
128
|
+
cleared: boolean;
|
|
129
|
+
subject: string;
|
|
130
|
+
pendingAcks: PendingAck[];
|
|
131
|
+
}>;
|
|
132
|
+
/**
|
|
133
|
+
* Scope guard prepended to a fork child's opening input — the behavioral
|
|
134
|
+
* fallback agent-core applies to every forked dispatch (buildForkScopePrefix
|
|
135
|
+
* equivalent; the platform already excludes in-flight output from the seed,
|
|
136
|
+
* this line keeps the child from "finishing" main's visible work).
|
|
137
|
+
*
|
|
138
|
+
* The opening turn has no envelope (it starts from the spawn input), so its
|
|
139
|
+
* turn-end text is NOT auto-delivered anywhere (parel §18.3: outward replies
|
|
140
|
+
* from a child are the agent's own job) — the prefix therefore spells out
|
|
141
|
+
* the CLI reply command for THIS message; follow-ups arrive as deliverTo
|
|
142
|
+
* envelopes whose replies auto-deliver like any chat message.
|
|
143
|
+
*/
|
|
144
|
+
export declare function buildForkScopePrefix(subject: string, threadRootId?: string): string;
|
|
145
|
+
//# sourceMappingURL=fork.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fork.d.ts","sourceRoot":"","sources":["../src/fork.ts"],"names":[],"mappings":"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"}
|
package/dist/fork.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fork-on-busy scheduling (the parall-side half of parel F4, parel-mono #140;
|
|
3
|
+
* strategy ownership per parel-session-alignment-plan.md §6 — the platform
|
|
4
|
+
* provides the spawnChildSession primitive, WHEN to fork is connector code).
|
|
5
|
+
*
|
|
6
|
+
* Model, mirroring the standard runtimes' fork-on-busy:
|
|
7
|
+
* - The main session is the single orchestrator. While it is mid-turn, a
|
|
8
|
+
* message for a DIFFERENT conversation would head-of-line block behind it.
|
|
9
|
+
* - Instead, the connector spawns a fork child session (inherits main's
|
|
10
|
+
* transcript at the last turn boundary — platform-guaranteed) keyed by an
|
|
11
|
+
* opaque childRef, and routes that conversation's follow-ups to the child
|
|
12
|
+
* via emitEvent deliverTo.
|
|
13
|
+
* - The child replies through the normal turn-end deliver (its envelopes
|
|
14
|
+
* carry the same replyRoute shape), and its summary flows back to main via
|
|
15
|
+
* the platform's async child notification. Same-conversation messages keep
|
|
16
|
+
* riding main (steer/inject handles mid-turn arrival there).
|
|
17
|
+
*
|
|
18
|
+
* Busy tracking is BEST-EFFORT on the E1 turn stream (turn_started /
|
|
19
|
+
* turn_completed / turn_failed): a missed event mis-classifies busy vs idle,
|
|
20
|
+
* which costs a fork too many or a queued message too long — never
|
|
21
|
+
* correctness (F2 subject grouping guarantees turns don't mix conversations
|
|
22
|
+
* either way). Stale entries expire by TTL so a lost done event cannot wedge
|
|
23
|
+
* the classifier.
|
|
24
|
+
*
|
|
25
|
+
* Ack discipline: the spawn opening message — and any follow-up routed to
|
|
26
|
+
* the child before provisioning is confirmed — is NOT acked on emit; spawn
|
|
27
|
+
* failures come back asynchronously (child_spawn_failed) and an
|
|
28
|
+
* acked-but-never-processed message would be silently lost. Parked acks ride
|
|
29
|
+
* the child bookkeeping row and fire (by WorkItem id, re-sent a bounded
|
|
30
|
+
* number of times) on the child's observed events. On spawn failure the
|
|
31
|
+
* inbound layer arms an onTimer re-drive (FORK_RETRY_DELAY_MS) so the
|
|
32
|
+
* messages are re-planned promptly on a healthy long-lived connection —
|
|
33
|
+
* transient failures re-issue the SAME spawn ref (branch 1a), terminal ones
|
|
34
|
+
* re-plan from scratch (fresh ref or main).
|
|
35
|
+
*/
|
|
36
|
+
/** Main-session turn considered over after this long without a done event. */
|
|
37
|
+
const MAIN_TURN_TTL_MS = 15 * 60 * 1000;
|
|
38
|
+
/** A child with no observed activity for this long is considered gone. */
|
|
39
|
+
const CHILD_TTL_MS = 10 * 60 * 1000;
|
|
40
|
+
/** After a disabled/unsupported spawn failure, stop trying to fork for a while. */
|
|
41
|
+
const FORK_DISABLED_TTL_MS = 60 * 60 * 1000;
|
|
42
|
+
/** Delay before a failed spawn's messages are re-driven through onTimer. */
|
|
43
|
+
export const FORK_RETRY_DELAY_MS = 15_000;
|
|
44
|
+
/** Cap on un-acked follow-ups parked on an unconfirmed child. */
|
|
45
|
+
const MAX_PENDING_FOLLOWUPS = 20;
|
|
46
|
+
/** Stop re-sending acks after this many settle rounds. */
|
|
47
|
+
const MAX_ACK_ATTEMPTS = 3;
|
|
48
|
+
const MAIN_TURN_KEY = 'forkMainTurn';
|
|
49
|
+
export const FORK_RETRY_PREFIX = 'forkRetry:';
|
|
50
|
+
const FORK_DISABLED_KEY = 'forkDisabled';
|
|
51
|
+
const childKey = (subject) => `forkChild:${subject}`;
|
|
52
|
+
const childSubjectKey = (childRef) => `forkChildSubject:${childRef}`;
|
|
53
|
+
function isLive(at, ttl, now) {
|
|
54
|
+
return typeof at === 'number' && now - at < ttl;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Decide how to route one inbound chat message. Called on the message hot
|
|
58
|
+
* path — store reads only, never a fetch.
|
|
59
|
+
*
|
|
60
|
+
* Concurrency: the decision-then-record sequence is NOT internally locked —
|
|
61
|
+
* it relies on the parel host executing one connection's hooks serially (the
|
|
62
|
+
* C3 contract; the same guarantee the turnEnvelope marker's read-then-patch
|
|
63
|
+
* ordering already leans on). Two same-chat dispatches therefore cannot both
|
|
64
|
+
* observe "no child" — the second runs after the first's recordSpawn.
|
|
65
|
+
*/
|
|
66
|
+
export async function planForkDecision(ctx, subject, sourceId) {
|
|
67
|
+
const now = ctx.now();
|
|
68
|
+
const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
|
|
69
|
+
if (child?.childRef && isLive(child.at, CHILD_TTL_MS, now)) {
|
|
70
|
+
// 1a. A replay of the fork's own opening message: re-issue the spawn
|
|
71
|
+
// with the SAME childRef. The platform spawn is idempotent (same ref
|
|
72
|
+
// → same child, duplicate input suppressed, transient
|
|
73
|
+
// concurrency_limit failures retry through) — this is both the retry
|
|
74
|
+
// path for transient spawn failures and the duplicate-processing
|
|
75
|
+
// guard (a deliverTo here would bypass ingress dedupe, the opening
|
|
76
|
+
// input never was an envelope). Matched on the row-lifetime
|
|
77
|
+
// openingSourceId, not pendingAcks — the acks list can be dropped
|
|
78
|
+
// after bounded re-sends while replays must keep matching.
|
|
79
|
+
if (child.openingSourceId === sourceId) {
|
|
80
|
+
return { mode: 'spawn', childRef: child.childRef };
|
|
81
|
+
}
|
|
82
|
+
// 1b. An existing live child owns its conversation's follow-ups. Until
|
|
83
|
+
// the child's first observed event confirms provisioning, follow-up
|
|
84
|
+
// acks are parked on the row (a deliverTo into a terminally-failed
|
|
85
|
+
// spawn is host-ignored — acking up front would lose the message).
|
|
86
|
+
return { mode: 'deliverTo', childRef: child.childRef, trackAck: child.confirmed !== true };
|
|
87
|
+
}
|
|
88
|
+
// 2. Forking disabled (binding opt-out observed) — everything rides main.
|
|
89
|
+
const disabled = (await ctx.store?.get(FORK_DISABLED_KEY).catch(() => null));
|
|
90
|
+
if (disabled && isLive(disabled.at, FORK_DISABLED_TTL_MS, now)) {
|
|
91
|
+
return { mode: 'main' };
|
|
92
|
+
}
|
|
93
|
+
// 3. Main mid-turn on a DIFFERENT conversation → fork. Same conversation
|
|
94
|
+
// stays on main (inject-in-flight absorbs it there).
|
|
95
|
+
const turn = (await ctx.store?.get(MAIN_TURN_KEY).catch(() => null));
|
|
96
|
+
if (turn?.turnId && isLive(turn.at, MAIN_TURN_TTL_MS, now) && turn.subject !== subject) {
|
|
97
|
+
// now() suffix: a childRef is burned once its spawn fails terminally
|
|
98
|
+
// (platform mapping row is three-state), so a re-plan after a terminal
|
|
99
|
+
// failure must mint a fresh ref; retries of a still-pending spawn go
|
|
100
|
+
// through branch 1a with the recorded ref instead.
|
|
101
|
+
return { mode: 'spawn', childRef: `fk-${now.toString(36)}-${subject}` };
|
|
102
|
+
}
|
|
103
|
+
return { mode: 'main' };
|
|
104
|
+
}
|
|
105
|
+
/** Record a just-issued spawn so follow-ups route to the child. Idempotent
|
|
106
|
+
* for the 1a replay path: an existing row for the same ref keeps its parked
|
|
107
|
+
* follow-up acks and confirmation state, only refreshing liveness. */
|
|
108
|
+
export async function recordSpawn(ctx, subject, childRef, opening) {
|
|
109
|
+
const existing = (await ctx.store?.get(childKey(subject)).catch(() => null));
|
|
110
|
+
const row = existing?.childRef === childRef
|
|
111
|
+
? { ...existing, at: ctx.now() }
|
|
112
|
+
: { childRef, at: ctx.now(), pendingAcks: [opening], openingSourceId: opening.sourceId };
|
|
113
|
+
await ctx.store?.set(childKey(subject), row).catch(() => { });
|
|
114
|
+
await ctx.store?.set(childSubjectKey(childRef), subject).catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Park a follow-up's WorkItem ack on an unconfirmed child. Returns false when
|
|
118
|
+
* the row is gone or the cap is hit — the caller then acks on emit (the cap
|
|
119
|
+
* bounds the loss window of a pathological never-confirming child; beyond it
|
|
120
|
+
* we prefer at-most-once for the tail over unbounded growth).
|
|
121
|
+
*/
|
|
122
|
+
export async function trackFollowUpAck(ctx, subject, childRef, ack) {
|
|
123
|
+
const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
|
|
124
|
+
if (!child || child.childRef !== childRef)
|
|
125
|
+
return false;
|
|
126
|
+
const acks = child.pendingAcks ?? [];
|
|
127
|
+
if (acks.some((a) => a.dispatchId === ack.dispatchId))
|
|
128
|
+
return true; // replay — already parked
|
|
129
|
+
if (acks.length >= MAX_PENDING_FOLLOWUPS)
|
|
130
|
+
return false;
|
|
131
|
+
const row = { ...child, at: ctx.now(), pendingAcks: [...acks, ack] };
|
|
132
|
+
await ctx.store?.set(childKey(subject), row).catch(() => { });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
/** Whether a childRef is still part of live fork bookkeeping. Late child
|
|
136
|
+
* callbacks after a New Session purge (or after give-up) must NOT recreate
|
|
137
|
+
* fork ase_ rows via the blind get-or-create — a pre-reset child's delayed
|
|
138
|
+
* trace/reply would attach a fresh session under the NEW conversation line. */
|
|
139
|
+
export async function childRefKnown(ctx, childRef) {
|
|
140
|
+
const subject = (await ctx.store?.get(childSubjectKey(childRef)).catch(() => null));
|
|
141
|
+
if (!subject)
|
|
142
|
+
return false;
|
|
143
|
+
const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
|
|
144
|
+
return child?.childRef === childRef;
|
|
145
|
+
}
|
|
146
|
+
/** Whether a subject's live child has ever produced an observed event. */
|
|
147
|
+
export async function childConfirmed(ctx, subject, childRef) {
|
|
148
|
+
const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
|
|
149
|
+
return child?.childRef === childRef && child.confirmed === true;
|
|
150
|
+
}
|
|
151
|
+
/** Drop one subject's fork routing (give-up path: message re-plans fresh). */
|
|
152
|
+
export async function clearForkSubject(ctx, subject, childRef) {
|
|
153
|
+
const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
|
|
154
|
+
if (child?.childRef === childRef) {
|
|
155
|
+
await ctx.store?.delete(childKey(subject)).catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
await ctx.store?.delete(childSubjectKey(childRef)).catch(() => { });
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Bump-and-park a fork retry ledger row (also the spawn-verification
|
|
161
|
+
* coordinate onTimer re-plans). Returns the attempt count INCLUDING this one.
|
|
162
|
+
*/
|
|
163
|
+
export async function bumpForkRetryAttempts(ctx, retryKey, data) {
|
|
164
|
+
const prior = (await ctx.store?.get(retryKey).catch(() => null));
|
|
165
|
+
const attempts = (prior?.attempts ?? 0) + 1;
|
|
166
|
+
await ctx.store?.set(retryKey, { ...data, attempts }).catch(() => { });
|
|
167
|
+
return attempts;
|
|
168
|
+
}
|
|
169
|
+
/** Give up re-verifying a spawn after this many timer rounds. */
|
|
170
|
+
export const MAX_SPAWN_VERIFY_ATTEMPTS = 3;
|
|
171
|
+
/**
|
|
172
|
+
* Purge ALL fork routing state — called on `agent.new_session`. The binding
|
|
173
|
+
* reset rotates main's conversation line; stale child routing surviving it
|
|
174
|
+
* would deliver post-reset messages into pre-reset forks (old context), and
|
|
175
|
+
* a stale busy marker would fork off a session that no longer exists.
|
|
176
|
+
* Returns the childRefs that were live so the caller can also invalidate
|
|
177
|
+
* their cached ase_ rows.
|
|
178
|
+
*/
|
|
179
|
+
export async function clearForkState(ctx) {
|
|
180
|
+
const refs = [];
|
|
181
|
+
try {
|
|
182
|
+
// EVERY fork-scoped key deliberately shares the `fork` prefix
|
|
183
|
+
// (forkChild / forkChildSubject / forkMainTurn / forkDisabled /
|
|
184
|
+
// forkRetry) so this one list-and-delete purges the whole family — a
|
|
185
|
+
// surviving disabled latch would suppress F4 for an hour after a reset,
|
|
186
|
+
// and a surviving retry row would re-drive a pre-reset message into the
|
|
187
|
+
// fresh session. Keep new fork keys on the prefix.
|
|
188
|
+
const keys = (await ctx.store?.list('fork').catch(() => [])) ?? [];
|
|
189
|
+
for (const key of keys) {
|
|
190
|
+
if (key.startsWith('forkChildSubject:')) {
|
|
191
|
+
refs.push(key.slice('forkChildSubject:'.length));
|
|
192
|
+
}
|
|
193
|
+
await ctx.store?.delete(key).catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Best-effort: leftover rows expire by TTL anyway.
|
|
198
|
+
}
|
|
199
|
+
return refs;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Track main-session busyness from the E1 turn stream. Child events (they
|
|
203
|
+
* carry childRef) must NOT feed this — only the main session's turns do.
|
|
204
|
+
*/
|
|
205
|
+
export async function noteMainTurnEvent(ctx, event) {
|
|
206
|
+
if (event.type === 'turn_started') {
|
|
207
|
+
const row = { turnId: event.turnId, subject: event.subject, at: ctx.now() };
|
|
208
|
+
await ctx.store?.set(MAIN_TURN_KEY, row).catch(() => { });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (event.type === 'turn_completed' || event.type === 'turn_failed') {
|
|
212
|
+
const turn = (await ctx.store?.get(MAIN_TURN_KEY).catch(() => null));
|
|
213
|
+
// Clear only the turn this done event belongs to — a stale done for a
|
|
214
|
+
// superseded turn must not mark a newer running turn idle.
|
|
215
|
+
if (!turn || turn.turnId === event.turnId) {
|
|
216
|
+
await ctx.store?.delete(MAIN_TURN_KEY).catch(() => { });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a child event's bookkeeping row (by its subject mirror) and fire
|
|
222
|
+
* the pending spawn ack exactly once. Returns the ack request when due.
|
|
223
|
+
*/
|
|
224
|
+
export async function settleChildEvent(ctx, event) {
|
|
225
|
+
const childRef = event.childRef;
|
|
226
|
+
if (!childRef)
|
|
227
|
+
return null;
|
|
228
|
+
const subject = event.subject ||
|
|
229
|
+
(await ctx.store?.get(childSubjectKey(childRef)).catch(() => null)) ||
|
|
230
|
+
'';
|
|
231
|
+
if (!subject)
|
|
232
|
+
return null;
|
|
233
|
+
const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
|
|
234
|
+
if (!child || child.childRef !== childRef) {
|
|
235
|
+
// Unknown/superseded child — refresh nothing, ack nothing.
|
|
236
|
+
return { subject, pendingAcks: [] };
|
|
237
|
+
}
|
|
238
|
+
// Acks are re-issued on subsequent events (bounded) rather than cleared on
|
|
239
|
+
// first send: the ack rides a fire-and-forget fetch effect, and clearing
|
|
240
|
+
// eagerly would let one lost request orphan the WorkItem in `received`
|
|
241
|
+
// forever (its replay would then re-enter the child as a duplicate).
|
|
242
|
+
const acks = child.pendingAcks ?? [];
|
|
243
|
+
const attempts = (child.ackAttempts ?? 0) + 1;
|
|
244
|
+
const done = attempts >= MAX_ACK_ATTEMPTS;
|
|
245
|
+
const refreshed = {
|
|
246
|
+
childRef,
|
|
247
|
+
at: ctx.now(),
|
|
248
|
+
confirmed: true,
|
|
249
|
+
// openingSourceId survives the ack-list drop — replay identity is
|
|
250
|
+
// row-lifetime (see the field doc).
|
|
251
|
+
...(child.openingSourceId ? { openingSourceId: child.openingSourceId } : {}),
|
|
252
|
+
...(done ? {} : { pendingAcks: acks, ackAttempts: attempts }),
|
|
253
|
+
};
|
|
254
|
+
await ctx.store?.set(childKey(subject), refreshed).catch(() => { });
|
|
255
|
+
return { subject, pendingAcks: acks };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A spawn failed before the child ran. NEVER acks the opening dispatch — it
|
|
259
|
+
* stays received and the catch-up sweep re-plans it. Failure codes split
|
|
260
|
+
* three ways (parel §18.2 three-state mapping semantics):
|
|
261
|
+
* - `concurrency_limit` is TRANSIENT: the platform keeps the mapping in
|
|
262
|
+
* `provisioning` and the same childRef can retry — keep the bookkeeping so
|
|
263
|
+
* the sweep's replay re-issues the same spawn key (branch 1a) instead of
|
|
264
|
+
* abandoning one provisioning row per attempt.
|
|
265
|
+
* - `disabled` / `unsupported_routing` are configuration-shaped: clear the
|
|
266
|
+
* bookkeeping AND park a no-fork flag so the connector stops re-trying a
|
|
267
|
+
* fork the binding will never allow.
|
|
268
|
+
* - Everything else is terminal for this childRef (the ref is burned): clear
|
|
269
|
+
* the bookkeeping so the sweep's re-plan mints a fresh ref.
|
|
270
|
+
* Returns true when the bookkeeping was cleared (the caller then settles the
|
|
271
|
+
* orphaned child ase_).
|
|
272
|
+
*/
|
|
273
|
+
export async function handleChildSpawnFailed(ctx, event) {
|
|
274
|
+
console.error(`[parel-channel] child spawn failed (${event.code}): ${event.error} (ref ${event.childRef})`);
|
|
275
|
+
const subject = (await ctx.store?.get(childSubjectKey(event.childRef)).catch(() => null)) ??
|
|
276
|
+
'';
|
|
277
|
+
const child = subject
|
|
278
|
+
? (await ctx.store?.get(childKey(subject)).catch(() => null))
|
|
279
|
+
: null;
|
|
280
|
+
const pendingAcks = child?.childRef === event.childRef ? (child.pendingAcks ?? []) : [];
|
|
281
|
+
if (event.code === 'concurrency_limit') {
|
|
282
|
+
// Transient: the platform keeps the mapping in `provisioning` and the
|
|
283
|
+
// SAME childRef can retry — keep the bookkeeping; the caller's retry
|
|
284
|
+
// timer re-drives the opening message through branch 1a.
|
|
285
|
+
return { cleared: false, subject, pendingAcks };
|
|
286
|
+
}
|
|
287
|
+
if (subject) {
|
|
288
|
+
if (child?.childRef === event.childRef) {
|
|
289
|
+
await ctx.store?.delete(childKey(subject)).catch(() => { });
|
|
290
|
+
}
|
|
291
|
+
await ctx.store?.delete(childSubjectKey(event.childRef)).catch(() => { });
|
|
292
|
+
}
|
|
293
|
+
if (event.code === 'disabled' || event.code === 'unsupported_routing') {
|
|
294
|
+
await ctx.store?.set(FORK_DISABLED_KEY, { at: ctx.now() }).catch(() => { });
|
|
295
|
+
}
|
|
296
|
+
return { cleared: true, subject, pendingAcks };
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Scope guard prepended to a fork child's opening input — the behavioral
|
|
300
|
+
* fallback agent-core applies to every forked dispatch (buildForkScopePrefix
|
|
301
|
+
* equivalent; the platform already excludes in-flight output from the seed,
|
|
302
|
+
* this line keeps the child from "finishing" main's visible work).
|
|
303
|
+
*
|
|
304
|
+
* The opening turn has no envelope (it starts from the spawn input), so its
|
|
305
|
+
* turn-end text is NOT auto-delivered anywhere (parel §18.3: outward replies
|
|
306
|
+
* from a child are the agent's own job) — the prefix therefore spells out
|
|
307
|
+
* the CLI reply command for THIS message; follow-ups arrive as deliverTo
|
|
308
|
+
* envelopes whose replies auto-deliver like any chat message.
|
|
309
|
+
*/
|
|
310
|
+
export function buildForkScopePrefix(subject, threadRootId) {
|
|
311
|
+
// Quoted-heredoc input form (mirrors agent-core's send hint): plain
|
|
312
|
+
// `--text "…"` lets the sandbox shell expand $, backticks and eat quotes —
|
|
313
|
+
// and this is the opening turn's ONLY delivery path.
|
|
314
|
+
const send = threadRootId
|
|
315
|
+
? `parall messages send ${subject} --thread-root-id ${threadRootId} --text-file - <<'EOF'`
|
|
316
|
+
: `parall messages send ${subject} --text-file - <<'EOF'`;
|
|
317
|
+
return (`[Fork scope: you are a parallel branch of your main session, handling ONE conversation (${subject}) only. ` +
|
|
318
|
+
`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` +
|
|
319
|
+
`<system-reminder>Reply to THIS message with your sandbox CLI: \`${send}\` … \`EOF\` — the quoted heredoc keeps \`$\`, backticks and apostrophes literal. For this opening message your plain text output is NOT auto-delivered; later messages in this conversation deliver replies automatically.</system-reminder>\n\n`);
|
|
320
|
+
}
|