@parall/agent-core 1.45.0 → 1.47.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.
Files changed (46) hide show
  1. package/dist/channel-capability.d.ts +2 -0
  2. package/dist/channel-capability.d.ts.map +1 -1
  3. package/dist/channel-capability.js +15 -0
  4. package/dist/dispatch-adapter.d.ts +9 -0
  5. package/dist/dispatch-adapter.d.ts.map +1 -1
  6. package/dist/event-format.d.ts.map +1 -1
  7. package/dist/event-format.js +27 -7
  8. package/dist/fork-session-finalizer.d.ts +65 -0
  9. package/dist/fork-session-finalizer.d.ts.map +1 -0
  10. package/dist/fork-session-finalizer.js +70 -0
  11. package/dist/gateway-base.d.ts +47 -0
  12. package/dist/gateway-base.d.ts.map +1 -1
  13. package/dist/gateway-base.js +457 -200
  14. package/dist/gateway-lane-flow.d.ts.map +1 -1
  15. package/dist/gateway-lane-flow.js +13 -7
  16. package/dist/http-keepalive.d.ts +4 -0
  17. package/dist/http-keepalive.d.ts.map +1 -0
  18. package/dist/http-keepalive.js +33 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -0
  22. package/dist/session-lifecycle.d.ts +198 -0
  23. package/dist/session-lifecycle.d.ts.map +1 -0
  24. package/dist/session-lifecycle.js +446 -0
  25. package/dist/skills/parall-clips.d.ts +1 -1
  26. package/dist/skills/parall-clips.d.ts.map +1 -1
  27. package/dist/skills/parall-clips.js +3 -0
  28. package/dist/step-persister.d.ts +66 -0
  29. package/dist/step-persister.d.ts.map +1 -0
  30. package/dist/step-persister.js +116 -0
  31. package/dist/step-retry-queue.d.ts +91 -0
  32. package/dist/step-retry-queue.d.ts.map +1 -0
  33. package/dist/step-retry-queue.js +259 -0
  34. package/package.json +3 -2
  35. package/src/channel-capability.ts +16 -0
  36. package/src/dispatch-adapter.ts +10 -0
  37. package/src/event-format.ts +27 -7
  38. package/src/fork-session-finalizer.ts +122 -0
  39. package/src/gateway-base.ts +487 -255
  40. package/src/gateway-lane-flow.ts +12 -7
  41. package/src/http-keepalive.ts +36 -0
  42. package/src/index.ts +6 -0
  43. package/src/session-lifecycle.ts +552 -0
  44. package/src/skills/parall-clips.ts +3 -0
  45. package/src/step-persister.ts +161 -0
  46. package/src/step-retry-queue.ts +296 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Per-session FIFO retry queue for AgentStep writes.
3
+ *
4
+ * Step creation runs serially inside the dispatch drain loop, so a blocking
5
+ * retry there would stall healthy runtime work behind a degraded api link
6
+ * (the 2026-07-10 window: model calls fine, every fresh parall HTTPS
7
+ * handshake timing out). Instead, a failed write is parked here and retried
8
+ * in the background with backoff; while a session has parked items, new
9
+ * writes for that session are appended behind them so ledger order is
10
+ * preserved. All step requests carry an `idempotency_key`, which makes
11
+ * retries safe even when the original attempt landed server-side but its
12
+ * response was lost.
13
+ *
14
+ * In-memory only — a process restart drops parked items (the ledger hole is
15
+ * then no worse than the pre-queue behavior). Items are dropped, loudly, when
16
+ * they exceed `maxAgeMs` or the per-session cap.
17
+ */
18
+ export type StepRetryQueueOpts = {
19
+ log?: {
20
+ warn: (msg: string) => void;
21
+ };
22
+ /** Transient failures worth retrying (timeouts, network, 5xx). */
23
+ isRetryable: (err: unknown) => boolean;
24
+ /** Session is dead (SESSION_NOT_LIVE / INVALID_TRANSITION) — drop its queue. */
25
+ isSessionStale: (err: unknown) => boolean;
26
+ /** Invoked once when a retry discovers a stale session. */
27
+ onSessionStale?: (sessionId: string, err: unknown) => void;
28
+ /** Drop an item once it has been parked this long. Default 10 minutes. */
29
+ maxAgeMs?: number;
30
+ /** Per-session cap; enqueue beyond it drops the NEW item. Default 200. */
31
+ maxQueueLength?: number;
32
+ /** Backoff schedule override (tests). Default 5s→10s→20s→40s→60s. */
33
+ retryDelaysMs?: number[];
34
+ };
35
+ /**
36
+ * How a session's queue reached quiescence: `drained` — every parked item
37
+ * settled (written, or dropped loudly by age/cap/non-retryable policy);
38
+ * `dropped` — the queue was discarded because the server considers the
39
+ * session terminal (stale 409, `agent.new_session`); `disposed` — the queue
40
+ * was shut down mid-wait.
41
+ */
42
+ export type StepDrainOutcome = 'drained' | 'dropped' | 'disposed';
43
+ export declare class StepRetryQueue {
44
+ private readonly opts;
45
+ private readonly queues;
46
+ private readonly timers;
47
+ private readonly drains;
48
+ private readonly drainWaiters;
49
+ private readonly retryDelays;
50
+ private disposed;
51
+ constructor(opts: StepRetryQueueOpts);
52
+ hasPending(sessionId: string): boolean;
53
+ pendingCount(sessionId: string): number;
54
+ enqueue(sessionId: string, label: string, exec: () => Promise<void>): void;
55
+ dropSession(sessionId: string): void;
56
+ /**
57
+ * Resolves when this session's queue reaches quiescence: every parked item
58
+ * settled (`drained` — including items dropped loudly by the age/cap/
59
+ * non-retryable policy), the queue was discarded because the server
60
+ * considers the session terminal (`dropped`), or the queue was disposed
61
+ * (`disposed`). Retries keep riding the normal backoff schedule while a
62
+ * caller waits — this only OBSERVES the queue, it never accelerates or
63
+ * abandons it.
64
+ */
65
+ awaitSessionDrained(sessionId: string): Promise<StepDrainOutcome>;
66
+ pendingTotal(): number;
67
+ /**
68
+ * One best-effort pass over every parked item — no backoff waits. Used at
69
+ * graceful shutdown: parked items are process-local (their WorkItems
70
+ * already resolved, so restart catch-up will NOT re-drive them), and the
71
+ * common shutdown (idle-stop, deploy) happens on a healthy network where
72
+ * the writes just succeed. A session whose head still fails retryably is
73
+ * skipped (its remaining items would fail the same way). A drain already in
74
+ * flight when flush arrives is JOINED (drain() returns the shared promise),
75
+ * so flush waits for its settle within the deadline instead of misreading
76
+ * "no progress" and giving up early. `deadlineMs` is a HARD cap: a write
77
+ * still in flight at the deadline is abandoned to the background (it is
78
+ * idempotent; dispose() right after clears any re-arm) and flush returns.
79
+ * Returns the number of items still parked.
80
+ */
81
+ flush(deadlineMs: number): Promise<number>;
82
+ /** Every tracked session is dead (server closed them all — new_session). */
83
+ dropAllSessions(): void;
84
+ dispose(): void;
85
+ private schedule;
86
+ private drain;
87
+ private drainOnce;
88
+ private rearm;
89
+ private resolveDrainWaiters;
90
+ }
91
+ //# sourceMappingURL=step-retry-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"step-retry-queue.d.ts","sourceRoot":"","sources":["../src/step-retry-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,GAAG,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAC;IACtC,kEAAkE;IAClE,WAAW,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC;IACvC,gFAAgF;IAChF,cAAc,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC;IAC1C,2DAA2D;IAC3D,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;IAC3D,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B,CAAC;AASF;;;;;;GAMG;AACH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;AAgBlE,qBAAa,cAAc;IAgBb,OAAO,CAAC,QAAQ,CAAC,IAAI;IAfjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmC;IAC1D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqC;IAQ5D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;IAE3D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAiE;IAC9F,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAW;IACvC,OAAO,CAAC,QAAQ,CAAS;gBAEI,IAAI,EAAE,kBAAkB;IAIrD,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAItC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;IAIvC,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IAe1E,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAYpC;;;;;;;;OAQG;IACH,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAYjE,YAAY,IAAI,MAAM;IAMtB;;;;;;;;;;;;;OAaG;IACG,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAuBhD,4EAA4E;IAC5E,eAAe,IAAI,IAAI;IAIvB,OAAO,IAAI,IAAI;IAUf,OAAO,CAAC,QAAQ;IAchB,OAAO,CAAC,KAAK;YAeC,SAAS;IA4CvB,OAAO,CAAC,KAAK;IAkBb,OAAO,CAAC,mBAAmB;CAM5B"}
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Per-session FIFO retry queue for AgentStep writes.
3
+ *
4
+ * Step creation runs serially inside the dispatch drain loop, so a blocking
5
+ * retry there would stall healthy runtime work behind a degraded api link
6
+ * (the 2026-07-10 window: model calls fine, every fresh parall HTTPS
7
+ * handshake timing out). Instead, a failed write is parked here and retried
8
+ * in the background with backoff; while a session has parked items, new
9
+ * writes for that session are appended behind them so ledger order is
10
+ * preserved. All step requests carry an `idempotency_key`, which makes
11
+ * retries safe even when the original attempt landed server-side but its
12
+ * response was lost.
13
+ *
14
+ * In-memory only — a process restart drops parked items (the ledger hole is
15
+ * then no worse than the pre-queue behavior). Items are dropped, loudly, when
16
+ * they exceed `maxAgeMs` or the per-session cap.
17
+ */
18
+ const DEFAULT_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 40_000, 60_000];
19
+ async function raceWithDeadline(work, ms) {
20
+ let timer;
21
+ const timeout = new Promise((resolve) => {
22
+ timer = setTimeout(() => resolve('timeout'), Math.max(0, ms));
23
+ });
24
+ try {
25
+ return await Promise.race([work, timeout]);
26
+ }
27
+ finally {
28
+ clearTimeout(timer);
29
+ }
30
+ }
31
+ export class StepRetryQueue {
32
+ opts;
33
+ queues = new Map();
34
+ timers = new Map();
35
+ // Per-session in-flight drain promise. At most ONE drain may be active per
36
+ // session: a second one would re-run the same head and the two shift()
37
+ // calls would silently discard the next queued item. schedule() is a no-op
38
+ // while a drain is in flight; a concurrent drain() call JOINS the in-flight
39
+ // promise (flush must be able to await a background drain instead of
40
+ // misreading it as "no progress"); the drain's finally re-arms from live
41
+ // map state.
42
+ drains = new Map();
43
+ // Callers awaiting a session's quiescence (awaitSessionDrained).
44
+ drainWaiters = new Map();
45
+ retryDelays;
46
+ disposed = false;
47
+ constructor(opts) {
48
+ this.opts = opts;
49
+ this.retryDelays = opts.retryDelaysMs?.length ? opts.retryDelaysMs : DEFAULT_RETRY_DELAYS_MS;
50
+ }
51
+ hasPending(sessionId) {
52
+ return (this.queues.get(sessionId)?.length ?? 0) > 0;
53
+ }
54
+ pendingCount(sessionId) {
55
+ return this.queues.get(sessionId)?.length ?? 0;
56
+ }
57
+ enqueue(sessionId, label, exec) {
58
+ if (this.disposed)
59
+ return;
60
+ const queue = this.queues.get(sessionId) ?? [];
61
+ const cap = this.opts.maxQueueLength ?? 200;
62
+ if (queue.length >= cap) {
63
+ this.opts.log?.warn(`step retry queue full for session ${sessionId} (${cap}); dropping ${label} step permanently`);
64
+ return;
65
+ }
66
+ queue.push({ label, exec, enqueuedAt: Date.now(), attempts: 0 });
67
+ this.queues.set(sessionId, queue);
68
+ this.schedule(sessionId, this.retryDelays[0] ?? 5_000);
69
+ }
70
+ dropSession(sessionId) {
71
+ const queue = this.queues.get(sessionId);
72
+ if (queue?.length) {
73
+ this.opts.log?.warn(`dropping ${queue.length} queued step(s) for session ${sessionId}`);
74
+ }
75
+ this.queues.delete(sessionId);
76
+ const timer = this.timers.get(sessionId);
77
+ if (timer)
78
+ clearTimeout(timer);
79
+ this.timers.delete(sessionId);
80
+ this.resolveDrainWaiters(sessionId, 'dropped');
81
+ }
82
+ /**
83
+ * Resolves when this session's queue reaches quiescence: every parked item
84
+ * settled (`drained` — including items dropped loudly by the age/cap/
85
+ * non-retryable policy), the queue was discarded because the server
86
+ * considers the session terminal (`dropped`), or the queue was disposed
87
+ * (`disposed`). Retries keep riding the normal backoff schedule while a
88
+ * caller waits — this only OBSERVES the queue, it never accelerates or
89
+ * abandons it.
90
+ */
91
+ awaitSessionDrained(sessionId) {
92
+ if (this.disposed)
93
+ return Promise.resolve('disposed');
94
+ if (this.pendingCount(sessionId) === 0 && !this.drains.has(sessionId)) {
95
+ return Promise.resolve('drained');
96
+ }
97
+ return new Promise((resolve) => {
98
+ const waiters = this.drainWaiters.get(sessionId) ?? [];
99
+ waiters.push(resolve);
100
+ this.drainWaiters.set(sessionId, waiters);
101
+ });
102
+ }
103
+ pendingTotal() {
104
+ let total = 0;
105
+ for (const queue of this.queues.values())
106
+ total += queue.length;
107
+ return total;
108
+ }
109
+ /**
110
+ * One best-effort pass over every parked item — no backoff waits. Used at
111
+ * graceful shutdown: parked items are process-local (their WorkItems
112
+ * already resolved, so restart catch-up will NOT re-drive them), and the
113
+ * common shutdown (idle-stop, deploy) happens on a healthy network where
114
+ * the writes just succeed. A session whose head still fails retryably is
115
+ * skipped (its remaining items would fail the same way). A drain already in
116
+ * flight when flush arrives is JOINED (drain() returns the shared promise),
117
+ * so flush waits for its settle within the deadline instead of misreading
118
+ * "no progress" and giving up early. `deadlineMs` is a HARD cap: a write
119
+ * still in flight at the deadline is abandoned to the background (it is
120
+ * idempotent; dispose() right after clears any re-arm) and flush returns.
121
+ * Returns the number of items still parked.
122
+ */
123
+ async flush(deadlineMs) {
124
+ const deadline = Date.now() + deadlineMs;
125
+ const timeLeft = () => deadline - Date.now();
126
+ let timedOut = false;
127
+ for (const sessionId of [...this.queues.keys()]) {
128
+ while (!this.disposed && !timedOut && timeLeft() > 0) {
129
+ const before = this.pendingCount(sessionId);
130
+ if (before === 0)
131
+ break;
132
+ const outcome = await raceWithDeadline(this.drain(sessionId).then(() => 'drained'), timeLeft());
133
+ if (outcome === 'timeout') {
134
+ timedOut = true;
135
+ break;
136
+ }
137
+ if (this.pendingCount(sessionId) >= before)
138
+ break;
139
+ }
140
+ if (this.disposed || timedOut || timeLeft() <= 0)
141
+ break;
142
+ }
143
+ return this.pendingTotal();
144
+ }
145
+ /** Every tracked session is dead (server closed them all — new_session). */
146
+ dropAllSessions() {
147
+ for (const sessionId of [...this.queues.keys()])
148
+ this.dropSession(sessionId);
149
+ }
150
+ dispose() {
151
+ this.disposed = true;
152
+ for (const timer of this.timers.values())
153
+ clearTimeout(timer);
154
+ this.timers.clear();
155
+ this.queues.clear();
156
+ for (const sessionId of [...this.drainWaiters.keys()]) {
157
+ this.resolveDrainWaiters(sessionId, 'disposed');
158
+ }
159
+ }
160
+ schedule(sessionId, delayMs) {
161
+ // No timer while a drain is in flight — its finally re-arms from live
162
+ // state, so arming here would start a second concurrent drain.
163
+ if (this.disposed || this.timers.has(sessionId) || this.drains.has(sessionId))
164
+ return;
165
+ const timer = setTimeout(() => {
166
+ this.timers.delete(sessionId);
167
+ void this.drain(sessionId);
168
+ }, delayMs);
169
+ timer.unref?.();
170
+ this.timers.set(sessionId, timer);
171
+ }
172
+ // At most one drain per session; a concurrent call joins the in-flight
173
+ // promise so callers (flush) await the real settle instead of a no-op.
174
+ drain(sessionId) {
175
+ const inFlight = this.drains.get(sessionId);
176
+ if (inFlight)
177
+ return inFlight;
178
+ const run = (async () => {
179
+ try {
180
+ await this.drainOnce(sessionId);
181
+ }
182
+ finally {
183
+ this.drains.delete(sessionId);
184
+ this.rearm(sessionId);
185
+ }
186
+ })();
187
+ this.drains.set(sessionId, run);
188
+ return run;
189
+ }
190
+ async drainOnce(sessionId) {
191
+ const queue = this.queues.get(sessionId);
192
+ const head = queue?.[0];
193
+ if (!queue || !head) {
194
+ if (queue)
195
+ this.queues.delete(sessionId);
196
+ return;
197
+ }
198
+ const maxAgeMs = this.opts.maxAgeMs ?? 10 * 60_000;
199
+ if (Date.now() - head.enqueuedAt > maxAgeMs) {
200
+ this.opts.log?.warn(`giving up on ${head.label} step for session ${sessionId} after ${head.attempts} retries (parked > ${Math.round(maxAgeMs / 1000)}s); step permanently lost`);
201
+ queue.shift();
202
+ return;
203
+ }
204
+ head.attempts += 1;
205
+ try {
206
+ await head.exec();
207
+ // The session may have been dropped (or the queue replaced) while
208
+ // exec was in flight — only mutate the queue we started with if it
209
+ // is still the live one.
210
+ if (this.queues.get(sessionId) === queue)
211
+ queue.shift();
212
+ }
213
+ catch (err) {
214
+ if (this.opts.isSessionStale(err)) {
215
+ this.dropSession(sessionId);
216
+ this.opts.onSessionStale?.(sessionId, err);
217
+ return;
218
+ }
219
+ if (this.queues.get(sessionId) !== queue)
220
+ return;
221
+ if (!this.opts.isRetryable(err)) {
222
+ this.opts.log?.warn(`dropping ${head.label} step for session ${sessionId} (non-retryable): ${String(err)}`);
223
+ queue.shift();
224
+ }
225
+ // Retryable: keep the head in place for the next pass.
226
+ }
227
+ }
228
+ // Single re-arm point after a drain settles, reading LIVE map state (the
229
+ // queue may have been dropped, or grown, while exec was awaited). Also the
230
+ // single point that observes quiescence for awaitSessionDrained: it runs
231
+ // after EVERY drain settle, so an emptied (or already-dropped) queue always
232
+ // resolves its waiters here.
233
+ rearm(sessionId) {
234
+ const queue = this.queues.get(sessionId);
235
+ if (!queue || queue.length === 0) {
236
+ if (queue)
237
+ this.queues.delete(sessionId);
238
+ this.resolveDrainWaiters(sessionId, 'drained');
239
+ return;
240
+ }
241
+ const head = queue[0];
242
+ if (!head)
243
+ return;
244
+ // A fresh head (previous item succeeded or was dropped) drains quickly;
245
+ // a head that just failed a retryable attempt backs off by its attempts.
246
+ const delay = head.attempts === 0
247
+ ? Math.min(250, this.retryDelays[0] ?? 250)
248
+ : (this.retryDelays[Math.min(head.attempts, this.retryDelays.length - 1)] ?? 60_000);
249
+ this.schedule(sessionId, delay);
250
+ }
251
+ resolveDrainWaiters(sessionId, outcome) {
252
+ const waiters = this.drainWaiters.get(sessionId);
253
+ if (!waiters?.length)
254
+ return;
255
+ this.drainWaiters.delete(sessionId);
256
+ for (const resolve of waiters)
257
+ resolve(outcome);
258
+ }
259
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/agent-core",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "description": "Shared agent runtime orchestration helpers for Parall",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,8 @@
35
35
  "@opentelemetry/sdk-logs": "^0.57.0",
36
36
  "@opentelemetry/sdk-metrics": "^1.30.0",
37
37
  "@opentelemetry/sdk-trace-node": "^1.30.0",
38
- "@parall/sdk": "1.45.0"
38
+ "undici": "^7.24.8",
39
+ "@parall/sdk": "1.47.0"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@types/node": "^22.0.0",
@@ -24,6 +24,22 @@ import type { AgentCapability } from './platform-config.js';
24
24
 
25
25
  export const CAPABILITY_FEISHU_CLI = 'feishu-cli';
26
26
 
27
+ // Slack's channel capability key (tier B): the affordance is the platform
28
+ // verb `parall slack send`, not a vendor CLI — there is no `slack-cli` and
29
+ // no PATH shim to reconcile. The key exists here so event-hint routing
30
+ // (gateway-base) can map provider → capability without pattern-matching on
31
+ // a `-cli` suffix slack will never have.
32
+ export const CAPABILITY_SLACK_SEND = 'slack-send';
33
+
34
+ // channelCapabilityKeyFor maps a channel provider to the capability key the
35
+ // server declares for it in agents.capabilities[] — the SSOT for the
36
+ // per-provider reply-affordance lookup. Unknown providers fall back to the
37
+ // `<provider>-cli` convention (the tier-A shape).
38
+ export function channelCapabilityKeyFor(provider: string): string {
39
+ if (provider === 'slack') return CAPABILITY_SLACK_SEND;
40
+ return `${provider}-cli`;
41
+ }
42
+
27
43
  // Marker embedded in every generated pointer. channel-exec skips any PATH
28
44
  // candidate whose head carries it, so a pointer (or a stray copy of one) can
29
45
  // never be mistaken for the real vendor binary — self-recursion is
@@ -105,6 +105,16 @@ export interface DispatchAdapter {
105
105
  /** Dispatch a Parall event to the runtime and emit normalized runtime events. */
106
106
  dispatch(opts: DispatchOpts): AsyncIterable<RuntimeEvent>;
107
107
 
108
+ /**
109
+ * True when dispatch() presents `earlierEvents` to the model itself (e.g.
110
+ * OpenClaw maps them into native InboundHistory). The gateway then must
111
+ * NOT concatenate a buffered typed group's earlier bodies into
112
+ * bodyForAgent — the model would see every earlier member twice. Adapters
113
+ * without this flag (claude/codex) only persist earlier events as input
114
+ * steps, so the concatenated body is the model's only route to them.
115
+ */
116
+ earlierEventsInPrompt?: boolean;
117
+
108
118
  /**
109
119
  * Inject a user message into the running runtime mid-turn so it steers
110
120
  * immediately, rather than waiting for the current turn to complete.
@@ -175,14 +175,34 @@ function buildSendMessageHint(event: ParallEvent): string {
175
175
  }
176
176
 
177
177
  if (event.type === 'channel_message') {
178
- // Single-path routing (multi-channel-architecture-design §6): with the
179
- // `<provider>-cli` capability granted, the vendor CLI on PATH is THE
180
- // reply path; without it there is no outbound path at all say so
181
- // instead of pointing at the retired provider clip.
182
- // channelCliCapable alone decides: only feishu mints exist today, so a
183
- // live grant implies Feishu even when the cosmetic provider-label lookup
184
- // failed (event.channelProvider undefined).
178
+ // Single-path routing (multi-channel-architecture-design §6/§8.2): with
179
+ // the channel capability granted, that provider's affordance is THE
180
+ // reply path feishu (tier A) = the vendor CLI on PATH, slack (tier B)
181
+ // = the `parall slack send` platform verb; without a grant there is no
182
+ // outbound path at all say so instead of pointing at a retired clip.
185
183
  if (event.channelCliCapable) {
184
+ if (event.channelProvider === 'slack') {
185
+ const channelArg = event.channelExternalConversationId
186
+ ? ` --channel "${event.channelExternalConversationId}"`
187
+ : ' --channel <conversation id from this event>';
188
+ // Anchor the reply onto THIS inbound message whenever its id is
189
+ // known: in channels --reply-to is REQUIRED (the send lands in that
190
+ // message's thread), in DMs it is accepted and harmless (linear).
191
+ const replyTo = event.channelExternalMessageId
192
+ ? ` --reply-to "${event.channelExternalMessageId}"`
193
+ : '';
194
+ return `\n<system-reminder>To reply, use the platform verb: \`parall slack send${channelArg}${replyTo} --text <your reply>\`. In channels --reply-to is REQUIRED (the reply lands in that message's thread); in DMs it is optional (DMs are linear). \`parall slack send\` is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
195
+ }
196
+ if (!event.channelProvider) {
197
+ // Cosmetic provider-label miss (the connection lookup transiently
198
+ // failed): a live grant means outbound IS available, but naming one
199
+ // vendor's command here could point a Slack conversation at
200
+ // lark-cli (or vice versa). Stay vendor-neutral and defer to the
201
+ // capability declaration already in the system prompt.
202
+ return `\n<system-reminder>To reply, use the channel capability granted in your system prompt — for Feishu conversations that is \`lark-cli im\`, for Slack it is \`parall slack send\` (pass the message id from this event as --reply-to). That capability is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
203
+ }
204
+ // Known non-slack provider → the tier-A vendor-CLI shape (feishu is
205
+ // the only tier-A channel today).
186
206
  const convRef = event.channelExternalConversationId
187
207
  ? `chat_id "${event.channelExternalConversationId}"`
188
208
  : 'the conversation id named in this event';
@@ -0,0 +1,122 @@
1
+ import type { SessionCloseOutcome, SessionCloseResult } from './session-lifecycle.js';
2
+ import type { StepDrainOutcome } from './step-retry-queue.js';
3
+
4
+ /**
5
+ * ForkSessionFinalizer — the application use case for NORMAL fork teardown.
6
+ *
7
+ * The gateway used to inline this as `dropSession() + closeSession() +
8
+ * delete binding`, which destroyed steps parked for retry (dropSession) and
9
+ * released ownership after one close attempt regardless of whether the close
10
+ * actually landed. The finalizer owns the ordered sequence instead:
11
+ *
12
+ * seal step writes (no new steps once finalization begins)
13
+ * → drain pending/in-flight step writes (every parked step ACKed —
14
+ * or settled by the queue's loud age/cap policy — before close)
15
+ * → close the server session (structured outcome; ownership held
16
+ * through `retrying` until the close reaches a terminal fate)
17
+ * → release the session binding (the caller-supplied `release`)
18
+ *
19
+ * Invariants: normal finalization NEVER calls the destructive dropSession on
20
+ * the step queue; `closed` is never sent while step writes are pending; the
21
+ * binding is released only after the close reached a terminal fate. A
22
+ * server-terminal signal during drain or close (`stale`) means the session
23
+ * is already dead server-side: local state is cleaned and released without a
24
+ * close write. A permanent close failure is released too — but LOUDLY: the
25
+ * server-side session may remain non-closed until superseded; a durable fix
26
+ * is the #1879 outbox/turn-fence work, not silent retries here.
27
+ *
28
+ * `agent.new_session` is NOT this use case — the server has already closed
29
+ * every session, so the gateway's dropAllSessions() forced-reset semantics
30
+ * are correct there.
31
+ *
32
+ * Process-local, best-effort scope (same as the retry queue): a process exit
33
+ * mid-finalization loses whatever is still parked.
34
+ */
35
+
36
+ export type ForkFinalizeOutcome = SessionCloseOutcome;
37
+
38
+ export type ForkSessionFinalizerOpts = {
39
+ steps: {
40
+ seal(sessionId: string): void;
41
+ unseal(sessionId: string): void;
42
+ drainSession(sessionId: string): Promise<StepDrainOutcome>;
43
+ };
44
+ lifecycle: {
45
+ closeSession(sessionId: string): Promise<SessionCloseResult>;
46
+ dropSession(sessionId: string): void;
47
+ };
48
+ log?: { warn: (msg: string) => void; error?: (msg: string) => void };
49
+ };
50
+
51
+ export class ForkSessionFinalizer {
52
+ // Ownership ledger: a session being finalized has exactly one finalization
53
+ // in flight; a duplicate finalize() joins it (cleanupFork-style idempotence).
54
+ private readonly active = new Map<string, Promise<ForkFinalizeOutcome>>();
55
+
56
+ constructor(private readonly opts: ForkSessionFinalizerOpts) {}
57
+
58
+ /** True while this finalizer still owns the session's teardown. */
59
+ isFinalizing(sessionId: string): boolean {
60
+ return this.active.has(sessionId);
61
+ }
62
+
63
+ activeCount(): number {
64
+ return this.active.size;
65
+ }
66
+
67
+ /**
68
+ * Run the ordered teardown for one fork session. `release` is invoked
69
+ * exactly once, only when finalization reaches a terminal state — never
70
+ * while step writes are pending or a close retry is still owned here.
71
+ */
72
+ finalize(sessionId: string, release: () => void): Promise<ForkFinalizeOutcome> {
73
+ const existing = this.active.get(sessionId);
74
+ if (existing) return existing;
75
+ const run = this.run(sessionId, release).finally(() => {
76
+ this.active.delete(sessionId);
77
+ });
78
+ this.active.set(sessionId, run);
79
+ return run;
80
+ }
81
+
82
+ private async run(sessionId: string, release: () => void): Promise<ForkFinalizeOutcome> {
83
+ this.opts.steps.seal(sessionId);
84
+ try {
85
+ const drained = await this.opts.steps.drainSession(sessionId);
86
+ if (drained === 'dropped') {
87
+ // Server-terminal (stale 409 / new_session) — the session is already
88
+ // dead server-side; a close write would only 409. Clean up locally.
89
+ this.opts.lifecycle.dropSession(sessionId);
90
+ release();
91
+ return 'stale';
92
+ }
93
+ if (drained === 'disposed') {
94
+ // Shutdown raced the finalization; the gateway's flush/dispose path
95
+ // owns whatever remains.
96
+ release();
97
+ return 'disposed';
98
+ }
99
+
100
+ const close = await this.opts.lifecycle.closeSession(sessionId);
101
+ const terminal = close.outcome === 'retrying' ? await close.terminal : close.outcome;
102
+ if (terminal === 'permanent_failure') {
103
+ // Explicit failure policy: release local ownership, but never
104
+ // silently — the server-side session may sit non-closed until a new
105
+ // session supersedes it. Process-local retries are exhausted; the
106
+ // durable answer is the #1879 outbox/turn-fence work.
107
+ const msg =
108
+ `fork session ${sessionId} close failed permanently after retries; ` +
109
+ 'releasing local ownership — the server-side session may remain ' +
110
+ 'non-closed until superseded (tracked structurally in #1879)';
111
+ (this.opts.log?.error ?? this.opts.log?.warn)?.(msg);
112
+ }
113
+ release();
114
+ return terminal;
115
+ } finally {
116
+ // Reclaim the seal in every exit path — the sealed set must not grow
117
+ // on a daemon churning fork sessions. Late writes against the (now
118
+ // closed/stale) session are rejected server-side with a 409.
119
+ this.opts.steps.unseal(sessionId);
120
+ }
121
+ }
122
+ }