@parall/agent-core 1.45.0 → 1.46.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,161 @@
1
+ import type { AgentStep, CreateAgentStepRequest, ParallClient } from '@parall/sdk';
2
+ import { ApiError } from '@parall/sdk';
3
+ import { type StepDrainOutcome, StepRetryQueue } from './step-retry-queue.js';
4
+
5
+ /**
6
+ * AgentStep write path: one place decides inline-write vs park-for-retry.
7
+ *
8
+ * Extracted from the gateway so step persistence is a nameable collaborator
9
+ * instead of more growth in gateway-base.ts. Behavior contract (see
10
+ * AGENTS.md § Step write resilience): every request carries an
11
+ * idempotency_key so retries are replay-safe; transient failures park in the
12
+ * per-session FIFO StepRetryQueue; while a session has parked writes, new
13
+ * writes queue behind them to preserve ledger order; session-stale errors
14
+ * propagate to the caller (the gateway's existing recovery contract).
15
+ */
16
+ export type StepPersisterOpts = {
17
+ client: ParallClient;
18
+ orgId: string;
19
+ agentUserId: string;
20
+ log?: { warn: (msg: string) => void };
21
+ /** Session is dead (SESSION_NOT_LIVE / INVALID_TRANSITION 409). */
22
+ isSessionStale: (err: unknown) => boolean;
23
+ /** Retry backoff override (tests). Default: StepRetryQueue's schedule. */
24
+ retryDelaysMs?: number[];
25
+ };
26
+
27
+ export function isRetryableStepError(err: unknown): boolean {
28
+ // ApiError status 0 = fetch-level failure (timeout / network); 5xx =
29
+ // server-side transient. 4xx are contract errors a retry cannot fix.
30
+ return err instanceof ApiError && (err.status === 0 || err.status >= 500);
31
+ }
32
+
33
+ export class StepPersister {
34
+ private readonly queue: StepRetryQueue;
35
+ // Sessions whose producer end is closed (finalization in progress) — new
36
+ // writes are refused instead of racing the drain/close sequence.
37
+ private readonly sealed = new Set<string>();
38
+ private disposed = false;
39
+
40
+ constructor(private readonly opts: StepPersisterOpts) {
41
+ this.queue = new StepRetryQueue({
42
+ log: opts.log,
43
+ isRetryable: isRetryableStepError,
44
+ isSessionStale: opts.isSessionStale,
45
+ retryDelaysMs: opts.retryDelaysMs,
46
+ // Inline step writes already drive full stale recovery; a background
47
+ // retry discovering it just drops the dead session's queue — the next
48
+ // dispatch hits the same 409 inline and heals.
49
+ onSessionStale: (sessionId, err) =>
50
+ opts.log?.warn(`queued step hit stale session ${sessionId}: ${String(err)}`),
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Write one AgentStep, riding the retry queue on transient failure.
56
+ * Returns the created step, or null when the write was queued or dropped.
57
+ */
58
+ async persist(
59
+ sessionId: string,
60
+ label: string,
61
+ req: CreateAgentStepRequest,
62
+ ): Promise<AgentStep | null> {
63
+ if (this.disposed) {
64
+ // Absorbing, like the queue and the lifecycle coordinator: a dispatch
65
+ // still draining past the shutdown deadline must not race the exiting
66
+ // process with fresh inline writes — its WorkItem is un-acked, so the
67
+ // re-drive on the replacement pod rewrites these steps idempotently.
68
+ this.opts.log?.warn(`refusing ${label} step after dispose (shutting down)`);
69
+ return null;
70
+ }
71
+ if (this.sealed.has(sessionId)) {
72
+ this.opts.log?.warn(
73
+ `refusing ${label} step for sealed session ${sessionId} (finalization in progress)`,
74
+ );
75
+ return null;
76
+ }
77
+ if (this.queue.hasPending(sessionId)) {
78
+ // Preserve per-session FIFO while degraded.
79
+ this.enqueue(sessionId, label, req);
80
+ return null;
81
+ }
82
+ try {
83
+ return await this.opts.client.createAgentStep(
84
+ this.opts.orgId,
85
+ this.opts.agentUserId,
86
+ sessionId,
87
+ req,
88
+ );
89
+ } catch (err) {
90
+ if (this.opts.isSessionStale(err)) throw err;
91
+ if (isRetryableStepError(err)) {
92
+ this.opts.log?.warn(`failed to create ${label} step (queued for retry): ${String(err)}`);
93
+ this.enqueue(sessionId, label, req);
94
+ } else {
95
+ this.opts.log?.warn(`failed to create ${label} step: ${String(err)}`);
96
+ }
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Close the producer end for a session (finalization began): subsequent
103
+ * persist() calls are refused with a warn. Parked/in-flight writes keep
104
+ * retrying — seal gates NEW writes only.
105
+ */
106
+ seal(sessionId: string): void {
107
+ this.sealed.add(sessionId);
108
+ }
109
+
110
+ /**
111
+ * Reclaim a seal once its finalization released the session (the seal set
112
+ * must not grow forever on a daemon churning fork sessions). After the
113
+ * session is closed server-side, any late write is rejected there (409).
114
+ */
115
+ unseal(sessionId: string): void {
116
+ this.sealed.delete(sessionId);
117
+ }
118
+
119
+ /**
120
+ * Resolves when the session's step queue reaches quiescence — see
121
+ * StepRetryQueue.awaitSessionDrained. Non-destructive: parked writes keep
122
+ * retrying on their normal schedule while awaited.
123
+ */
124
+ drainSession(sessionId: string): Promise<StepDrainOutcome> {
125
+ return this.queue.awaitSessionDrained(sessionId);
126
+ }
127
+
128
+ /** DESTRUCTIVE: discards the session's parked writes (server-terminal only). */
129
+ dropSession(sessionId: string): void {
130
+ this.sealed.delete(sessionId);
131
+ this.queue.dropSession(sessionId);
132
+ }
133
+
134
+ /** Every session is dead (server closed them all — new_session). */
135
+ dropAllSessions(): void {
136
+ this.sealed.clear();
137
+ this.queue.dropAllSessions();
138
+ }
139
+
140
+ /** Best-effort single pass over parked writes; returns items still parked. */
141
+ flush(deadlineMs: number): Promise<number> {
142
+ return this.queue.flush(deadlineMs);
143
+ }
144
+
145
+ pendingTotal(): number {
146
+ return this.queue.pendingTotal();
147
+ }
148
+
149
+ dispose(): void {
150
+ this.disposed = true;
151
+ this.queue.dispose();
152
+ }
153
+
154
+ private enqueue(sessionId: string, label: string, req: CreateAgentStepRequest): void {
155
+ this.queue.enqueue(sessionId, label, () =>
156
+ this.opts.client
157
+ .createAgentStep(this.opts.orgId, this.opts.agentUserId, sessionId, req)
158
+ .then(() => undefined),
159
+ );
160
+ }
161
+ }
@@ -0,0 +1,296 @@
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
+
19
+ export type StepRetryQueueOpts = {
20
+ log?: { warn: (msg: string) => void };
21
+ /** Transient failures worth retrying (timeouts, network, 5xx). */
22
+ isRetryable: (err: unknown) => boolean;
23
+ /** Session is dead (SESSION_NOT_LIVE / INVALID_TRANSITION) — drop its queue. */
24
+ isSessionStale: (err: unknown) => boolean;
25
+ /** Invoked once when a retry discovers a stale session. */
26
+ onSessionStale?: (sessionId: string, err: unknown) => void;
27
+ /** Drop an item once it has been parked this long. Default 10 minutes. */
28
+ maxAgeMs?: number;
29
+ /** Per-session cap; enqueue beyond it drops the NEW item. Default 200. */
30
+ maxQueueLength?: number;
31
+ /** Backoff schedule override (tests). Default 5s→10s→20s→40s→60s. */
32
+ retryDelaysMs?: number[];
33
+ };
34
+
35
+ type QueuedItem = {
36
+ label: string;
37
+ exec: () => Promise<void>;
38
+ enqueuedAt: number;
39
+ attempts: number;
40
+ };
41
+
42
+ /**
43
+ * How a session's queue reached quiescence: `drained` — every parked item
44
+ * settled (written, or dropped loudly by age/cap/non-retryable policy);
45
+ * `dropped` — the queue was discarded because the server considers the
46
+ * session terminal (stale 409, `agent.new_session`); `disposed` — the queue
47
+ * was shut down mid-wait.
48
+ */
49
+ export type StepDrainOutcome = 'drained' | 'dropped' | 'disposed';
50
+
51
+ const DEFAULT_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 40_000, 60_000];
52
+
53
+ async function raceWithDeadline<T>(work: Promise<T>, ms: number): Promise<T | 'timeout'> {
54
+ let timer: NodeJS.Timeout | undefined;
55
+ const timeout = new Promise<'timeout'>((resolve) => {
56
+ timer = setTimeout(() => resolve('timeout'), Math.max(0, ms));
57
+ });
58
+ try {
59
+ return await Promise.race([work, timeout]);
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+
65
+ export class StepRetryQueue {
66
+ private readonly queues = new Map<string, QueuedItem[]>();
67
+ private readonly timers = new Map<string, NodeJS.Timeout>();
68
+ // Per-session in-flight drain promise. At most ONE drain may be active per
69
+ // session: a second one would re-run the same head and the two shift()
70
+ // calls would silently discard the next queued item. schedule() is a no-op
71
+ // while a drain is in flight; a concurrent drain() call JOINS the in-flight
72
+ // promise (flush must be able to await a background drain instead of
73
+ // misreading it as "no progress"); the drain's finally re-arms from live
74
+ // map state.
75
+ private readonly drains = new Map<string, Promise<void>>();
76
+ // Callers awaiting a session's quiescence (awaitSessionDrained).
77
+ private readonly drainWaiters = new Map<string, Array<(outcome: StepDrainOutcome) => void>>();
78
+ private readonly retryDelays: number[];
79
+ private disposed = false;
80
+
81
+ constructor(private readonly opts: StepRetryQueueOpts) {
82
+ this.retryDelays = opts.retryDelaysMs?.length ? opts.retryDelaysMs : DEFAULT_RETRY_DELAYS_MS;
83
+ }
84
+
85
+ hasPending(sessionId: string): boolean {
86
+ return (this.queues.get(sessionId)?.length ?? 0) > 0;
87
+ }
88
+
89
+ pendingCount(sessionId: string): number {
90
+ return this.queues.get(sessionId)?.length ?? 0;
91
+ }
92
+
93
+ enqueue(sessionId: string, label: string, exec: () => Promise<void>): void {
94
+ if (this.disposed) return;
95
+ const queue = this.queues.get(sessionId) ?? [];
96
+ const cap = this.opts.maxQueueLength ?? 200;
97
+ if (queue.length >= cap) {
98
+ this.opts.log?.warn(
99
+ `step retry queue full for session ${sessionId} (${cap}); dropping ${label} step permanently`,
100
+ );
101
+ return;
102
+ }
103
+ queue.push({ label, exec, enqueuedAt: Date.now(), attempts: 0 });
104
+ this.queues.set(sessionId, queue);
105
+ this.schedule(sessionId, this.retryDelays[0] ?? 5_000);
106
+ }
107
+
108
+ dropSession(sessionId: string): void {
109
+ const queue = this.queues.get(sessionId);
110
+ if (queue?.length) {
111
+ this.opts.log?.warn(`dropping ${queue.length} queued step(s) for session ${sessionId}`);
112
+ }
113
+ this.queues.delete(sessionId);
114
+ const timer = this.timers.get(sessionId);
115
+ if (timer) clearTimeout(timer);
116
+ this.timers.delete(sessionId);
117
+ this.resolveDrainWaiters(sessionId, 'dropped');
118
+ }
119
+
120
+ /**
121
+ * Resolves when this session's queue reaches quiescence: every parked item
122
+ * settled (`drained` — including items dropped loudly by the age/cap/
123
+ * non-retryable policy), the queue was discarded because the server
124
+ * considers the session terminal (`dropped`), or the queue was disposed
125
+ * (`disposed`). Retries keep riding the normal backoff schedule while a
126
+ * caller waits — this only OBSERVES the queue, it never accelerates or
127
+ * abandons it.
128
+ */
129
+ awaitSessionDrained(sessionId: string): Promise<StepDrainOutcome> {
130
+ if (this.disposed) return Promise.resolve('disposed');
131
+ if (this.pendingCount(sessionId) === 0 && !this.drains.has(sessionId)) {
132
+ return Promise.resolve('drained');
133
+ }
134
+ return new Promise((resolve) => {
135
+ const waiters = this.drainWaiters.get(sessionId) ?? [];
136
+ waiters.push(resolve);
137
+ this.drainWaiters.set(sessionId, waiters);
138
+ });
139
+ }
140
+
141
+ pendingTotal(): number {
142
+ let total = 0;
143
+ for (const queue of this.queues.values()) total += queue.length;
144
+ return total;
145
+ }
146
+
147
+ /**
148
+ * One best-effort pass over every parked item — no backoff waits. Used at
149
+ * graceful shutdown: parked items are process-local (their WorkItems
150
+ * already resolved, so restart catch-up will NOT re-drive them), and the
151
+ * common shutdown (idle-stop, deploy) happens on a healthy network where
152
+ * the writes just succeed. A session whose head still fails retryably is
153
+ * skipped (its remaining items would fail the same way). A drain already in
154
+ * flight when flush arrives is JOINED (drain() returns the shared promise),
155
+ * so flush waits for its settle within the deadline instead of misreading
156
+ * "no progress" and giving up early. `deadlineMs` is a HARD cap: a write
157
+ * still in flight at the deadline is abandoned to the background (it is
158
+ * idempotent; dispose() right after clears any re-arm) and flush returns.
159
+ * Returns the number of items still parked.
160
+ */
161
+ async flush(deadlineMs: number): Promise<number> {
162
+ const deadline = Date.now() + deadlineMs;
163
+ const timeLeft = () => deadline - Date.now();
164
+ let timedOut = false;
165
+ for (const sessionId of [...this.queues.keys()]) {
166
+ while (!this.disposed && !timedOut && timeLeft() > 0) {
167
+ const before = this.pendingCount(sessionId);
168
+ if (before === 0) break;
169
+ const outcome = await raceWithDeadline(
170
+ this.drain(sessionId).then(() => 'drained' as const),
171
+ timeLeft(),
172
+ );
173
+ if (outcome === 'timeout') {
174
+ timedOut = true;
175
+ break;
176
+ }
177
+ if (this.pendingCount(sessionId) >= before) break;
178
+ }
179
+ if (this.disposed || timedOut || timeLeft() <= 0) break;
180
+ }
181
+ return this.pendingTotal();
182
+ }
183
+
184
+ /** Every tracked session is dead (server closed them all — new_session). */
185
+ dropAllSessions(): void {
186
+ for (const sessionId of [...this.queues.keys()]) this.dropSession(sessionId);
187
+ }
188
+
189
+ dispose(): void {
190
+ this.disposed = true;
191
+ for (const timer of this.timers.values()) clearTimeout(timer);
192
+ this.timers.clear();
193
+ this.queues.clear();
194
+ for (const sessionId of [...this.drainWaiters.keys()]) {
195
+ this.resolveDrainWaiters(sessionId, 'disposed');
196
+ }
197
+ }
198
+
199
+ private schedule(sessionId: string, delayMs: number): void {
200
+ // No timer while a drain is in flight — its finally re-arms from live
201
+ // state, so arming here would start a second concurrent drain.
202
+ if (this.disposed || this.timers.has(sessionId) || this.drains.has(sessionId)) return;
203
+ const timer = setTimeout(() => {
204
+ this.timers.delete(sessionId);
205
+ void this.drain(sessionId);
206
+ }, delayMs);
207
+ timer.unref?.();
208
+ this.timers.set(sessionId, timer);
209
+ }
210
+
211
+ // At most one drain per session; a concurrent call joins the in-flight
212
+ // promise so callers (flush) await the real settle instead of a no-op.
213
+ private drain(sessionId: string): Promise<void> {
214
+ const inFlight = this.drains.get(sessionId);
215
+ if (inFlight) return inFlight;
216
+ const run = (async () => {
217
+ try {
218
+ await this.drainOnce(sessionId);
219
+ } finally {
220
+ this.drains.delete(sessionId);
221
+ this.rearm(sessionId);
222
+ }
223
+ })();
224
+ this.drains.set(sessionId, run);
225
+ return run;
226
+ }
227
+
228
+ private async drainOnce(sessionId: string): Promise<void> {
229
+ const queue = this.queues.get(sessionId);
230
+ const head = queue?.[0];
231
+ if (!queue || !head) {
232
+ if (queue) this.queues.delete(sessionId);
233
+ return;
234
+ }
235
+ const maxAgeMs = this.opts.maxAgeMs ?? 10 * 60_000;
236
+ if (Date.now() - head.enqueuedAt > maxAgeMs) {
237
+ this.opts.log?.warn(
238
+ `giving up on ${head.label} step for session ${sessionId} after ${head.attempts} retries (parked > ${Math.round(maxAgeMs / 1000)}s); step permanently lost`,
239
+ );
240
+ queue.shift();
241
+ return;
242
+ }
243
+ head.attempts += 1;
244
+ try {
245
+ await head.exec();
246
+ // The session may have been dropped (or the queue replaced) while
247
+ // exec was in flight — only mutate the queue we started with if it
248
+ // is still the live one.
249
+ if (this.queues.get(sessionId) === queue) queue.shift();
250
+ } catch (err) {
251
+ if (this.opts.isSessionStale(err)) {
252
+ this.dropSession(sessionId);
253
+ this.opts.onSessionStale?.(sessionId, err);
254
+ return;
255
+ }
256
+ if (this.queues.get(sessionId) !== queue) return;
257
+ if (!this.opts.isRetryable(err)) {
258
+ this.opts.log?.warn(
259
+ `dropping ${head.label} step for session ${sessionId} (non-retryable): ${String(err)}`,
260
+ );
261
+ queue.shift();
262
+ }
263
+ // Retryable: keep the head in place for the next pass.
264
+ }
265
+ }
266
+
267
+ // Single re-arm point after a drain settles, reading LIVE map state (the
268
+ // queue may have been dropped, or grown, while exec was awaited). Also the
269
+ // single point that observes quiescence for awaitSessionDrained: it runs
270
+ // after EVERY drain settle, so an emptied (or already-dropped) queue always
271
+ // resolves its waiters here.
272
+ private rearm(sessionId: string): void {
273
+ const queue = this.queues.get(sessionId);
274
+ if (!queue || queue.length === 0) {
275
+ if (queue) this.queues.delete(sessionId);
276
+ this.resolveDrainWaiters(sessionId, 'drained');
277
+ return;
278
+ }
279
+ const head = queue[0];
280
+ if (!head) return;
281
+ // A fresh head (previous item succeeded or was dropped) drains quickly;
282
+ // a head that just failed a retryable attempt backs off by its attempts.
283
+ const delay =
284
+ head.attempts === 0
285
+ ? Math.min(250, this.retryDelays[0] ?? 250)
286
+ : (this.retryDelays[Math.min(head.attempts, this.retryDelays.length - 1)] ?? 60_000);
287
+ this.schedule(sessionId, delay);
288
+ }
289
+
290
+ private resolveDrainWaiters(sessionId: string, outcome: StepDrainOutcome): void {
291
+ const waiters = this.drainWaiters.get(sessionId);
292
+ if (!waiters?.length) return;
293
+ this.drainWaiters.delete(sessionId);
294
+ for (const resolve of waiters) resolve(outcome);
295
+ }
296
+ }