@parall/agent-core 1.44.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.
- package/dist/channel-capability.d.ts +2 -0
- package/dist/channel-capability.d.ts.map +1 -1
- package/dist/channel-capability.js +15 -0
- package/dist/dispatch-adapter.d.ts +9 -0
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +27 -7
- package/dist/fork-session-finalizer.d.ts +65 -0
- package/dist/fork-session-finalizer.d.ts.map +1 -0
- package/dist/fork-session-finalizer.js +70 -0
- package/dist/gateway-base.d.ts +55 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +640 -263
- package/dist/gateway-lane-flow.d.ts +75 -5
- package/dist/gateway-lane-flow.d.ts.map +1 -1
- package/dist/gateway-lane-flow.js +240 -18
- package/dist/http-keepalive.d.ts +4 -0
- package/dist/http-keepalive.d.ts.map +1 -0
- package/dist/http-keepalive.js +33 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/lane-ledger.d.ts +8 -0
- package/dist/lane-ledger.d.ts.map +1 -1
- package/dist/lane-ledger.js +14 -0
- package/dist/session-lifecycle.d.ts +198 -0
- package/dist/session-lifecycle.d.ts.map +1 -0
- package/dist/session-lifecycle.js +446 -0
- package/dist/skills/parall-clips.d.ts +1 -1
- package/dist/skills/parall-clips.d.ts.map +1 -1
- package/dist/skills/parall-clips.js +3 -0
- package/dist/skills/parall-schedules.d.ts +1 -1
- package/dist/skills/parall-schedules.d.ts.map +1 -1
- package/dist/skills/parall-schedules.js +1 -1
- package/dist/skills/parall-tasks.d.ts +1 -1
- package/dist/skills/parall-tasks.d.ts.map +1 -1
- package/dist/skills/parall-tasks.js +21 -5
- package/dist/step-persister.d.ts +66 -0
- package/dist/step-persister.d.ts.map +1 -0
- package/dist/step-persister.js +116 -0
- package/dist/step-retry-queue.d.ts +91 -0
- package/dist/step-retry-queue.d.ts.map +1 -0
- package/dist/step-retry-queue.js +259 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/channel-capability.ts +16 -0
- package/src/dispatch-adapter.ts +10 -0
- package/src/event-format.ts +27 -7
- package/src/fork-session-finalizer.ts +122 -0
- package/src/gateway-base.ts +747 -331
- package/src/gateway-lane-flow.ts +275 -18
- package/src/http-keepalive.ts +36 -0
- package/src/index.ts +7 -1
- package/src/lane-ledger.ts +14 -0
- package/src/session-lifecycle.ts +552 -0
- package/src/skills/parall-clips.ts +3 -0
- package/src/skills/parall-schedules.ts +1 -1
- package/src/skills/parall-tasks.ts +21 -5
- package/src/step-persister.ts +161 -0
- package/src/step-retry-queue.ts +296 -0
- package/src/types.ts +2 -1
|
@@ -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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -86,7 +86,8 @@ export type ParallEvent = {
|
|
|
86
86
|
| 'comment'
|
|
87
87
|
| 'schedule_run'
|
|
88
88
|
| 'external_trigger_run'
|
|
89
|
-
| 'channel_message'
|
|
89
|
+
| 'channel_message'
|
|
90
|
+
| 'approval';
|
|
90
91
|
ackSourceId?: string;
|
|
91
92
|
/** WorkItem id, when known (dispatch catch-up / re-drive hints carry it; live message.new does not). */
|
|
92
93
|
dispatchEventId?: string;
|