@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.
- 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 +47 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +457 -200
- package/dist/gateway-lane-flow.d.ts.map +1 -1
- package/dist/gateway-lane-flow.js +13 -7
- 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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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/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/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 +487 -255
- package/src/gateway-lane-flow.ts +12 -7
- package/src/http-keepalive.ts +36 -0
- package/src/index.ts +6 -0
- package/src/session-lifecycle.ts +552 -0
- package/src/skills/parall-clips.ts +3 -0
- package/src/step-persister.ts +161 -0
- package/src/step-retry-queue.ts +296 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionLifecycleCoordinator — the single entry point for session status
|
|
3
|
+
* writes (active / idle / closed).
|
|
4
|
+
*
|
|
5
|
+
* SCOPE — this is a **process-local mitigation**, not a distributed fence.
|
|
6
|
+
* It serializes and orders the lifecycle writes THIS process issues. It does
|
|
7
|
+
* NOT order across processes, and it cannot fix a write that times out
|
|
8
|
+
* client-side but lands server-side later, nor an old turn's step retry
|
|
9
|
+
* landing during a new turn. The structural fix is a server-side turn fence
|
|
10
|
+
* (design tracked in #1879 — durable step outbox + turn fence); until that
|
|
11
|
+
* ships, this closes the in-process interleavings that produced the
|
|
12
|
+
* 2026-07-13 repro.
|
|
13
|
+
*
|
|
14
|
+
* The gateway used to fire active and idle PATCHes independently
|
|
15
|
+
* (fire-and-forget + detached retries). Two independent HTTP requests have
|
|
16
|
+
* no ordering: turn 1's in-flight idle could land AFTER turn 2's active and
|
|
17
|
+
* demote a running session — an epoch check on retry TIMERS cannot cancel a
|
|
18
|
+
* request already on the wire. The coordinator closes that class by being a
|
|
19
|
+
* serialized desired-state reconciler:
|
|
20
|
+
*
|
|
21
|
+
* - Per session it stores only the LATEST desired state plus a monotonic
|
|
22
|
+
* generation (bumped by beginTurn and closeSession).
|
|
23
|
+
* - At most ONE lifecycle write is in flight per session; when it settles
|
|
24
|
+
* the loop re-reads the latest desired state and reconciles — so a
|
|
25
|
+
* begin(turn N+1), or a closeSession, that arrives while an earlier write
|
|
26
|
+
* is in flight simply waits for it to settle and is written AFTER it.
|
|
27
|
+
* - finishTurn carries the TurnHandle from its beginTurn; a stale handle
|
|
28
|
+
* (generation superseded) is ignored outright.
|
|
29
|
+
* - Retries re-run the reconcile loop against the LATEST desired state —
|
|
30
|
+
* they never replay a captured command, so a pending idle retry is
|
|
31
|
+
* naturally superseded by a new turn.
|
|
32
|
+
* - Errors are CLASSIFIED (ports, defaults are conservative): transient →
|
|
33
|
+
* bounded backoff; permanent (4xx) → warn + abandon that desired state (a
|
|
34
|
+
* later turn retries with a fresh generation); session-stale (the server
|
|
35
|
+
* considers the session terminal) → drop the session, never write again.
|
|
36
|
+
* - beginTurn / closeSession resolve once one write attempt covering their
|
|
37
|
+
* generation has SETTLED (success or failure — bounded by the write's own
|
|
38
|
+
* timeout, plus at most one in-flight predecessor). The gateway awaits
|
|
39
|
+
* beginTurn before persisting the turn's first AgentStep, so a reused idle
|
|
40
|
+
* session is reconciled to active before steps arrive and the server's
|
|
41
|
+
* presence guard cannot swallow the turn's activity. A failed active write
|
|
42
|
+
* warns and lets the step flow continue.
|
|
43
|
+
* - closeSession's first settle carries a STRUCTURED outcome (`closed` /
|
|
44
|
+
* `stale` / `retrying` / `permanent_failure` / `disposed`) plus a
|
|
45
|
+
* `terminal` promise that resolves when the close reaches a terminal fate
|
|
46
|
+
* — a caller holding cleanup behind the close (ForkSessionFinalizer) can
|
|
47
|
+
* keep ownership through `retrying` instead of mistaking "one attempt
|
|
48
|
+
* settled" for "close completed".
|
|
49
|
+
* - Entries are RECLAIMED (no unbounded Map growth on long-lived daemons):
|
|
50
|
+
* dropSession removes the entry immediately when idle, or at the end of an
|
|
51
|
+
* in-flight write; a `closed` that landed OR failed permanently reclaims
|
|
52
|
+
* too — the permanent failure is not hidden by the reclaim, it is reported
|
|
53
|
+
* through the close's outcome/terminal before the entry goes away.
|
|
54
|
+
* - dispose() is absorbing: every later public command is a no-op that never
|
|
55
|
+
* re-creates an entry, issues a write, or arms a timer.
|
|
56
|
+
*
|
|
57
|
+
* The write port is infrastructure (ParallClient.updateAgentSession);
|
|
58
|
+
* retries, generations, error classification and the state machine live here.
|
|
59
|
+
*/
|
|
60
|
+
const DEFAULT_RETRY_DELAYS_MS = [5_000, 15_000, 45_000, 135_000, 405_000];
|
|
61
|
+
const defaultScheduler = {
|
|
62
|
+
setTimeout(fn, ms) {
|
|
63
|
+
const timer = setTimeout(fn, ms);
|
|
64
|
+
timer.unref?.();
|
|
65
|
+
return timer;
|
|
66
|
+
},
|
|
67
|
+
clearTimeout(timer) {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
export class SessionLifecycleCoordinator {
|
|
72
|
+
opts;
|
|
73
|
+
sessions = new Map();
|
|
74
|
+
scheduler;
|
|
75
|
+
retryDelays;
|
|
76
|
+
// Generations are coordinator-global and strictly increasing, so a handle
|
|
77
|
+
// from before an entry was reclaimed can never match a REBUILT entry for
|
|
78
|
+
// the same session id (per-entry counters restart at 1 — an ABA hazard
|
|
79
|
+
// where a stale finish would demote a fresh turn).
|
|
80
|
+
nextGeneration = 0;
|
|
81
|
+
disposed = false;
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
this.opts = opts;
|
|
84
|
+
this.scheduler = opts.scheduler ?? defaultScheduler;
|
|
85
|
+
this.retryDelays = opts.retryDelaysMs?.length ? opts.retryDelaysMs : DEFAULT_RETRY_DELAYS_MS;
|
|
86
|
+
}
|
|
87
|
+
/** Sessions currently tracked — reclaimed entries are gone (leak probe). */
|
|
88
|
+
trackedSessions() {
|
|
89
|
+
return this.sessions.size;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Declare a new turn. Resolves once one write attempt covering this turn's
|
|
93
|
+
* generation has settled (bounded: at most one in-flight predecessor plus
|
|
94
|
+
* one active write, each bounded by the write port's own timeout). Always
|
|
95
|
+
* resolves — a failed active write is warned and reconciled in the
|
|
96
|
+
* background, never blocks the caller.
|
|
97
|
+
*/
|
|
98
|
+
async beginTurn(sessionId, triggerMessageId) {
|
|
99
|
+
// Dispose is absorbing: never touch the map (an upsert here would
|
|
100
|
+
// re-create an entry nothing will ever reclaim), never write.
|
|
101
|
+
// Generation 0 can't match any live entry, so the handle is inert.
|
|
102
|
+
if (this.disposed)
|
|
103
|
+
return { sessionId, generation: 0 };
|
|
104
|
+
const entry = this.upsert(sessionId);
|
|
105
|
+
entry.desired = 'active';
|
|
106
|
+
entry.triggerMessageId = triggerMessageId;
|
|
107
|
+
const generation = entry.generation;
|
|
108
|
+
const settled = this.waitFor(entry, generation);
|
|
109
|
+
this.pump(sessionId);
|
|
110
|
+
await settled;
|
|
111
|
+
return { sessionId, generation };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Declare the turn finished. A stale handle (a newer turn — or a close —
|
|
115
|
+
* has bumped this session's generation) is ignored: finish(turn N) can
|
|
116
|
+
* never override begin(turn N+1). Reconciliation runs detached.
|
|
117
|
+
*/
|
|
118
|
+
finishTurn(handle) {
|
|
119
|
+
if (this.disposed)
|
|
120
|
+
return;
|
|
121
|
+
const entry = this.sessions.get(handle.sessionId);
|
|
122
|
+
if (!entry || entry.dropped || entry.generation !== handle.generation)
|
|
123
|
+
return;
|
|
124
|
+
if (entry.desired === 'closed')
|
|
125
|
+
return;
|
|
126
|
+
entry.desired = 'idle';
|
|
127
|
+
entry.retryAttempt = 0;
|
|
128
|
+
this.cancelRetry(entry);
|
|
129
|
+
this.pump(handle.sessionId);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Terminal transition (fork teardown). Serialized behind any in-flight
|
|
133
|
+
* write — a `closed` can never race the turn's idle — and reclaimed once
|
|
134
|
+
* settled. Resolves after one bounded settle with a STRUCTURED outcome:
|
|
135
|
+
* `closed` / `stale` / `permanent_failure` are terminal; `retrying` means
|
|
136
|
+
* background reconciliation continues — the caller keeps ownership of the
|
|
137
|
+
* session until `terminal` resolves. A close is never silently "done":
|
|
138
|
+
* every fate is observable.
|
|
139
|
+
*/
|
|
140
|
+
async closeSession(sessionId) {
|
|
141
|
+
if (this.disposed) {
|
|
142
|
+
return { outcome: 'disposed', terminal: Promise.resolve('disposed') };
|
|
143
|
+
}
|
|
144
|
+
const entry = this.upsert(sessionId);
|
|
145
|
+
entry.desired = 'closed';
|
|
146
|
+
entry.triggerMessageId = undefined;
|
|
147
|
+
const generation = entry.generation;
|
|
148
|
+
const terminal = new Promise((resolve) => {
|
|
149
|
+
entry.closeWaiters.push({ generation, resolve });
|
|
150
|
+
});
|
|
151
|
+
const settled = this.waitFor(entry, generation);
|
|
152
|
+
this.pump(sessionId);
|
|
153
|
+
const settle = await settled;
|
|
154
|
+
return { outcome: closeSettleFor(settle), terminal };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Session is dead (stale / superseded) — no further lifecycle writes, and
|
|
158
|
+
* the entry is reclaimed (immediately when idle; at the end of an in-flight
|
|
159
|
+
* write otherwise, unless a new turn revives it in the meantime).
|
|
160
|
+
*/
|
|
161
|
+
dropSession(sessionId) {
|
|
162
|
+
const entry = this.sessions.get(sessionId);
|
|
163
|
+
if (!entry)
|
|
164
|
+
return;
|
|
165
|
+
entry.dropped = true;
|
|
166
|
+
this.cancelRetry(entry);
|
|
167
|
+
this.resolveWaiters(entry, Number.POSITIVE_INFINITY, 'dropped');
|
|
168
|
+
this.reclaim(sessionId, entry);
|
|
169
|
+
}
|
|
170
|
+
/** Every tracked session is dead (server closed them all — new_session). */
|
|
171
|
+
dropAllSessions() {
|
|
172
|
+
for (const sessionId of [...this.sessions.keys()])
|
|
173
|
+
this.dropSession(sessionId);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* One best-effort reconcile pass over unsettled sessions within a hard
|
|
177
|
+
* deadline (shutdown). Returns the number still unsettled.
|
|
178
|
+
*/
|
|
179
|
+
async flush(deadlineMs) {
|
|
180
|
+
const deadline = Date.now() + deadlineMs;
|
|
181
|
+
for (const [sessionId, entry] of [...this.sessions.entries()]) {
|
|
182
|
+
if (this.disposed)
|
|
183
|
+
break;
|
|
184
|
+
if (entry.dropped || this.isSettled(entry))
|
|
185
|
+
continue;
|
|
186
|
+
this.cancelRetry(entry);
|
|
187
|
+
entry.retryAttempt = 0;
|
|
188
|
+
const timeLeft = deadline - Date.now();
|
|
189
|
+
if (timeLeft <= 0)
|
|
190
|
+
break;
|
|
191
|
+
const settled = this.waitFor(entry, entry.generation);
|
|
192
|
+
this.pump(sessionId);
|
|
193
|
+
await raceWithDeadline(settled, timeLeft);
|
|
194
|
+
}
|
|
195
|
+
let remaining = 0;
|
|
196
|
+
for (const entry of this.sessions.values()) {
|
|
197
|
+
if (!entry.dropped && !this.isSettled(entry))
|
|
198
|
+
remaining += 1;
|
|
199
|
+
}
|
|
200
|
+
return remaining;
|
|
201
|
+
}
|
|
202
|
+
dispose() {
|
|
203
|
+
this.disposed = true;
|
|
204
|
+
for (const entry of this.sessions.values()) {
|
|
205
|
+
this.cancelRetry(entry);
|
|
206
|
+
this.resolveWaiters(entry, Number.POSITIVE_INFINITY, 'disposed');
|
|
207
|
+
this.settleCloseTerminal(entry, 'disposed');
|
|
208
|
+
}
|
|
209
|
+
this.sessions.clear();
|
|
210
|
+
}
|
|
211
|
+
// --- internals ----------------------------------------------------------
|
|
212
|
+
/**
|
|
213
|
+
* Get-or-create the entry and take the next generation. A dropped entry is
|
|
214
|
+
* REUSED (not replaced): its `writing` flag keeps the single-writer chain
|
|
215
|
+
* intact if a pre-drop write is still on the wire, and the generation stays
|
|
216
|
+
* monotonic across the drop boundary.
|
|
217
|
+
*/
|
|
218
|
+
upsert(sessionId) {
|
|
219
|
+
let entry = this.sessions.get(sessionId);
|
|
220
|
+
if (!entry) {
|
|
221
|
+
entry = {
|
|
222
|
+
desired: 'active',
|
|
223
|
+
generation: 0,
|
|
224
|
+
writing: false,
|
|
225
|
+
retryAttempt: 0,
|
|
226
|
+
waiters: [],
|
|
227
|
+
closeWaiters: [],
|
|
228
|
+
dropped: false,
|
|
229
|
+
};
|
|
230
|
+
this.sessions.set(sessionId, entry);
|
|
231
|
+
}
|
|
232
|
+
entry.dropped = false;
|
|
233
|
+
this.nextGeneration += 1;
|
|
234
|
+
entry.generation = this.nextGeneration;
|
|
235
|
+
entry.retryAttempt = 0;
|
|
236
|
+
this.cancelRetry(entry);
|
|
237
|
+
return entry;
|
|
238
|
+
}
|
|
239
|
+
waitFor(entry, generation) {
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
entry.waiters.push({ generation, resolve });
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
isReconciled(entry) {
|
|
245
|
+
return (entry.written !== undefined &&
|
|
246
|
+
entry.written.state === entry.desired &&
|
|
247
|
+
entry.written.generation === entry.generation);
|
|
248
|
+
}
|
|
249
|
+
isAbandoned(entry) {
|
|
250
|
+
return (entry.abandoned !== undefined &&
|
|
251
|
+
entry.abandoned.state === entry.desired &&
|
|
252
|
+
entry.abandoned.generation === entry.generation);
|
|
253
|
+
}
|
|
254
|
+
/** Nothing more to do for the current desired state (written or given up). */
|
|
255
|
+
isSettled(entry) {
|
|
256
|
+
return this.isReconciled(entry) || this.isAbandoned(entry);
|
|
257
|
+
}
|
|
258
|
+
pump(sessionId) {
|
|
259
|
+
const entry = this.sessions.get(sessionId);
|
|
260
|
+
if (!entry || entry.writing || entry.dropped || this.disposed)
|
|
261
|
+
return;
|
|
262
|
+
if (this.isSettled(entry)) {
|
|
263
|
+
this.resolveWaiters(entry, entry.generation, this.isReconciled(entry) ? 'ok' : 'permanent');
|
|
264
|
+
this.reclaim(sessionId, entry);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
entry.writing = true;
|
|
268
|
+
void this.writeLoop(sessionId, entry);
|
|
269
|
+
}
|
|
270
|
+
async writeLoop(sessionId, entry) {
|
|
271
|
+
try {
|
|
272
|
+
while (!entry.dropped && !this.disposed && !this.isSettled(entry)) {
|
|
273
|
+
const snapshot = {
|
|
274
|
+
state: entry.desired,
|
|
275
|
+
generation: entry.generation,
|
|
276
|
+
trigger: entry.triggerMessageId,
|
|
277
|
+
};
|
|
278
|
+
let outcome = 'ok';
|
|
279
|
+
try {
|
|
280
|
+
await this.opts.write(sessionId, payloadFor(snapshot));
|
|
281
|
+
entry.written = { state: snapshot.state, generation: snapshot.generation };
|
|
282
|
+
entry.retryAttempt = 0;
|
|
283
|
+
if (snapshot.state === 'closed') {
|
|
284
|
+
this.settleCloseTerminal(entry, 'closed', snapshot.generation);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
outcome = this.classify(err);
|
|
289
|
+
this.opts.log?.warn(`session ${sessionId} ${snapshot.state} write failed (${outcome}, gen ${snapshot.generation}): ${String(err)}`);
|
|
290
|
+
}
|
|
291
|
+
// A settle — success or failure — resolves every waiter this
|
|
292
|
+
// snapshot covers (the bounded beginTurn / closeSession contract).
|
|
293
|
+
this.resolveWaiters(entry, snapshot.generation, outcome);
|
|
294
|
+
if (outcome === 'stale') {
|
|
295
|
+
// Terminal server-side: never write to this session again.
|
|
296
|
+
entry.dropped = true;
|
|
297
|
+
this.cancelRetry(entry);
|
|
298
|
+
this.settleCloseTerminal(entry, 'stale');
|
|
299
|
+
this.resolveWaiters(entry, Number.POSITIVE_INFINITY, 'stale');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const desiredUnchanged = snapshot.generation === entry.generation && snapshot.state === entry.desired;
|
|
303
|
+
if (outcome === 'permanent' && desiredUnchanged) {
|
|
304
|
+
// No retry loop against a 4xx — a later turn (fresh generation)
|
|
305
|
+
// un-abandons this session automatically.
|
|
306
|
+
entry.abandoned = { state: snapshot.state, generation: snapshot.generation };
|
|
307
|
+
if (snapshot.state === 'closed') {
|
|
308
|
+
this.settleCloseTerminal(entry, 'permanent_failure', snapshot.generation);
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (outcome === 'retry' && desiredUnchanged && !entry.dropped && !this.disposed) {
|
|
313
|
+
// The retry re-enters pump and replays the LATEST desired state,
|
|
314
|
+
// never this snapshot.
|
|
315
|
+
this.scheduleRetry(sessionId, entry);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Success, or the desired state advanced while writing: loop and
|
|
319
|
+
// reconcile the fresh state immediately.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
entry.writing = false;
|
|
324
|
+
this.reclaim(sessionId, entry);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
classify(err) {
|
|
328
|
+
if (this.opts.isSessionStale?.(err))
|
|
329
|
+
return 'stale';
|
|
330
|
+
if (this.opts.isRetryable?.(err))
|
|
331
|
+
return 'retry';
|
|
332
|
+
return 'permanent';
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Drop the entry when it can never write again: dropped (and no write on
|
|
336
|
+
* the wire), a terminal `closed` that landed, or a `closed` that failed
|
|
337
|
+
* permanently (its fate has been reported via closeSession's outcome — an
|
|
338
|
+
* entry kept around would be an unowned leak, not a recovery path). Called
|
|
339
|
+
* after every write settles and from dropSession — this is what keeps the
|
|
340
|
+
* Map bounded on a long-lived daemon churning fork sessions.
|
|
341
|
+
*/
|
|
342
|
+
reclaim(sessionId, entry) {
|
|
343
|
+
if (this.sessions.get(sessionId) !== entry)
|
|
344
|
+
return;
|
|
345
|
+
if (entry.writing)
|
|
346
|
+
return; // the write's finally will re-run this
|
|
347
|
+
const closedLanded = entry.desired === 'closed' &&
|
|
348
|
+
entry.written?.state === 'closed' &&
|
|
349
|
+
entry.written.generation === entry.generation;
|
|
350
|
+
const closedAbandoned = entry.desired === 'closed' &&
|
|
351
|
+
entry.abandoned?.state === 'closed' &&
|
|
352
|
+
entry.abandoned.generation === entry.generation;
|
|
353
|
+
if (!entry.dropped && !closedLanded && !closedAbandoned)
|
|
354
|
+
return;
|
|
355
|
+
this.cancelRetry(entry);
|
|
356
|
+
this.settleCloseTerminal(entry, closedLanded ? 'closed' : entry.dropped ? 'stale' : 'permanent_failure');
|
|
357
|
+
this.resolveWaiters(entry, Number.POSITIVE_INFINITY, closedLanded ? 'ok' : entry.dropped ? 'dropped' : 'permanent');
|
|
358
|
+
this.sessions.delete(sessionId);
|
|
359
|
+
}
|
|
360
|
+
scheduleRetry(sessionId, entry) {
|
|
361
|
+
if (entry.retryTimer !== undefined)
|
|
362
|
+
return;
|
|
363
|
+
if (entry.retryAttempt >= this.retryDelays.length) {
|
|
364
|
+
this.opts.log?.warn(`session ${sessionId} lifecycle reconcile giving up after ${entry.retryAttempt} retries (desired=${entry.desired})`);
|
|
365
|
+
entry.abandoned = { state: entry.desired, generation: entry.generation };
|
|
366
|
+
if (entry.desired === 'closed') {
|
|
367
|
+
this.settleCloseTerminal(entry, 'permanent_failure');
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const delay = this.retryDelays[entry.retryAttempt] ?? 5_000;
|
|
372
|
+
entry.retryAttempt += 1;
|
|
373
|
+
entry.retryTimer = this.scheduler.setTimeout(() => {
|
|
374
|
+
entry.retryTimer = undefined;
|
|
375
|
+
this.pump(sessionId);
|
|
376
|
+
}, delay);
|
|
377
|
+
}
|
|
378
|
+
cancelRetry(entry) {
|
|
379
|
+
if (entry.retryTimer !== undefined) {
|
|
380
|
+
this.scheduler.clearTimeout(entry.retryTimer);
|
|
381
|
+
entry.retryTimer = undefined;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
resolveWaiters(entry, upToGeneration, settle) {
|
|
385
|
+
if (entry.waiters.length === 0)
|
|
386
|
+
return;
|
|
387
|
+
const remaining = [];
|
|
388
|
+
for (const waiter of entry.waiters) {
|
|
389
|
+
if (waiter.generation <= upToGeneration)
|
|
390
|
+
waiter.resolve(settle);
|
|
391
|
+
else
|
|
392
|
+
remaining.push(waiter);
|
|
393
|
+
}
|
|
394
|
+
entry.waiters = remaining;
|
|
395
|
+
}
|
|
396
|
+
/** Resolve closeSession terminal waiters covered by this settle, once. */
|
|
397
|
+
settleCloseTerminal(entry, outcome, upToGeneration = Number.POSITIVE_INFINITY) {
|
|
398
|
+
if (entry.closeWaiters.length === 0)
|
|
399
|
+
return;
|
|
400
|
+
const remaining = [];
|
|
401
|
+
for (const waiter of entry.closeWaiters) {
|
|
402
|
+
if (waiter.generation <= upToGeneration)
|
|
403
|
+
waiter.resolve(outcome);
|
|
404
|
+
else
|
|
405
|
+
remaining.push(waiter);
|
|
406
|
+
}
|
|
407
|
+
entry.closeWaiters = remaining;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Map a first-settle write outcome onto the closeSession vocabulary. */
|
|
411
|
+
function closeSettleFor(settle) {
|
|
412
|
+
switch (settle) {
|
|
413
|
+
case 'ok':
|
|
414
|
+
return 'closed';
|
|
415
|
+
case 'retry':
|
|
416
|
+
return 'retrying';
|
|
417
|
+
case 'stale':
|
|
418
|
+
case 'dropped':
|
|
419
|
+
return 'stale';
|
|
420
|
+
case 'disposed':
|
|
421
|
+
return 'disposed';
|
|
422
|
+
default:
|
|
423
|
+
return 'permanent_failure';
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function payloadFor(snapshot) {
|
|
427
|
+
if (snapshot.state === 'active') {
|
|
428
|
+
return {
|
|
429
|
+
status: 'active',
|
|
430
|
+
...(snapshot.trigger ? { trigger_message_id: snapshot.trigger } : {}),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return snapshot.state === 'idle' ? { status: 'idle' } : { status: 'closed' };
|
|
434
|
+
}
|
|
435
|
+
async function raceWithDeadline(work, ms) {
|
|
436
|
+
let timer;
|
|
437
|
+
const timeout = new Promise((resolve) => {
|
|
438
|
+
timer = setTimeout(() => resolve('timeout'), Math.max(0, ms));
|
|
439
|
+
});
|
|
440
|
+
try {
|
|
441
|
+
return await Promise.race([work, timeout]);
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const PARALL_CLIPS_SKILL = "# Parall Clips\n\nClips are packaged capabilities that let agents operate external systems \u2014\nAPIs and websites \u2014 through named commands installed in the org.\n\n## Discover\n\n```bash\nparall clip list # clips installed in this org\nparall clip info <alias> # commands, params, version\n```\n\n## Invoke\n\n```bash\nparall clip invoke <alias> <command> [input] [--timeout <ms>] # timeout default 30s\n# input: JSON string or plain text, per the command's params in `info`\nparall clip invoke github-tools list-repos '{\"org\":\"acme\"}'\n```\n\nResults are JSON on stdout; failures print an error.\n\n## Behavior rules\n\n- An authorization error (clip not bound to you) is a fail-fast: ask the\n clip's owner or an admin to bind it \u2014 do not retry or work around it.\n- If the executing runtime is offline or the call times out, report that\n plainly; do not queue, and never fabricate a result for a run that errored.\n- A clip may act through a person's real logged-in account \u2014 outward,\n irreversible, or spending actions (post, order, delete, pay) get the same\n caution as any shared-state change: confirm when intent isn't explicit.\n- Reach for `parall clip list` whenever a task needs capabilities beyond\n built-in tools.\n";
|
|
1
|
+
export declare const PARALL_CLIPS_SKILL = "# Parall Clips\n\nClips are packaged capabilities that let agents operate external systems \u2014\nAPIs and websites \u2014 through named commands installed in the org.\n\n## Discover\n\n```bash\nparall clip list # clips installed in this org\nparall clip info <alias> # commands, params, version\n```\n\n## Invoke\n\n```bash\nparall clip invoke <alias> <command> [input] [--timeout <ms>] # timeout default 30s\n# input: JSON string or plain text, per the command's params in `info`\nparall clip invoke github-tools list-repos '{\"org\":\"acme\"}'\n```\n\nResults are JSON on stdout; failures print an error.\n\n## Behavior rules\n\n- An authorization error (clip not bound to you) is a fail-fast: ask the\n clip's owner or an admin to bind it \u2014 do not retry or work around it.\n- If the executing runtime is offline or the call times out, report that\n plainly; do not queue, and never fabricate a result for a run that errored.\n- Hosted browser activation is handled by the CLI: it waits (bounded) while a\n cold hosted browser starts, so if the invoke still fails, report the error \u2014\n do not blind-retry in a loop.\n- A clip may act through a person's real logged-in account \u2014 outward,\n irreversible, or spending actions (post, order, delete, pay) get the same\n caution as any shared-state change: confirm when intent isn't explicit.\n- Reach for `parall clip list` whenever a task needs capabilities beyond\n built-in tools.\n";
|
|
2
2
|
//# sourceMappingURL=parall-clips.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parall-clips.d.ts","sourceRoot":"","sources":["../../src/skills/parall-clips.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,
|
|
1
|
+
{"version":3,"file":"parall-clips.d.ts","sourceRoot":"","sources":["../../src/skills/parall-clips.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,q8CAoC9B,CAAC"}
|
|
@@ -26,6 +26,9 @@ Results are JSON on stdout; failures print an error.
|
|
|
26
26
|
clip's owner or an admin to bind it — do not retry or work around it.
|
|
27
27
|
- If the executing runtime is offline or the call times out, report that
|
|
28
28
|
plainly; do not queue, and never fabricate a result for a run that errored.
|
|
29
|
+
- Hosted browser activation is handled by the CLI: it waits (bounded) while a
|
|
30
|
+
cold hosted browser starts, so if the invoke still fails, report the error —
|
|
31
|
+
do not blind-retry in a loop.
|
|
29
32
|
- A clip may act through a person's real logged-in account — outward,
|
|
30
33
|
irreversible, or spending actions (post, order, delete, pay) get the same
|
|
31
34
|
caution as any shared-state change: confirm when intent isn't explicit.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AgentStep, CreateAgentStepRequest, ParallClient } from '@parall/sdk';
|
|
2
|
+
import { type StepDrainOutcome } from './step-retry-queue.js';
|
|
3
|
+
/**
|
|
4
|
+
* AgentStep write path: one place decides inline-write vs park-for-retry.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from the gateway so step persistence is a nameable collaborator
|
|
7
|
+
* instead of more growth in gateway-base.ts. Behavior contract (see
|
|
8
|
+
* AGENTS.md § Step write resilience): every request carries an
|
|
9
|
+
* idempotency_key so retries are replay-safe; transient failures park in the
|
|
10
|
+
* per-session FIFO StepRetryQueue; while a session has parked writes, new
|
|
11
|
+
* writes queue behind them to preserve ledger order; session-stale errors
|
|
12
|
+
* propagate to the caller (the gateway's existing recovery contract).
|
|
13
|
+
*/
|
|
14
|
+
export type StepPersisterOpts = {
|
|
15
|
+
client: ParallClient;
|
|
16
|
+
orgId: string;
|
|
17
|
+
agentUserId: string;
|
|
18
|
+
log?: {
|
|
19
|
+
warn: (msg: string) => void;
|
|
20
|
+
};
|
|
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
|
+
export declare function isRetryableStepError(err: unknown): boolean;
|
|
27
|
+
export declare class StepPersister {
|
|
28
|
+
private readonly opts;
|
|
29
|
+
private readonly queue;
|
|
30
|
+
private readonly sealed;
|
|
31
|
+
private disposed;
|
|
32
|
+
constructor(opts: StepPersisterOpts);
|
|
33
|
+
/**
|
|
34
|
+
* Write one AgentStep, riding the retry queue on transient failure.
|
|
35
|
+
* Returns the created step, or null when the write was queued or dropped.
|
|
36
|
+
*/
|
|
37
|
+
persist(sessionId: string, label: string, req: CreateAgentStepRequest): Promise<AgentStep | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Close the producer end for a session (finalization began): subsequent
|
|
40
|
+
* persist() calls are refused with a warn. Parked/in-flight writes keep
|
|
41
|
+
* retrying — seal gates NEW writes only.
|
|
42
|
+
*/
|
|
43
|
+
seal(sessionId: string): void;
|
|
44
|
+
/**
|
|
45
|
+
* Reclaim a seal once its finalization released the session (the seal set
|
|
46
|
+
* must not grow forever on a daemon churning fork sessions). After the
|
|
47
|
+
* session is closed server-side, any late write is rejected there (409).
|
|
48
|
+
*/
|
|
49
|
+
unseal(sessionId: string): void;
|
|
50
|
+
/**
|
|
51
|
+
* Resolves when the session's step queue reaches quiescence — see
|
|
52
|
+
* StepRetryQueue.awaitSessionDrained. Non-destructive: parked writes keep
|
|
53
|
+
* retrying on their normal schedule while awaited.
|
|
54
|
+
*/
|
|
55
|
+
drainSession(sessionId: string): Promise<StepDrainOutcome>;
|
|
56
|
+
/** DESTRUCTIVE: discards the session's parked writes (server-terminal only). */
|
|
57
|
+
dropSession(sessionId: string): void;
|
|
58
|
+
/** Every session is dead (server closed them all — new_session). */
|
|
59
|
+
dropAllSessions(): void;
|
|
60
|
+
/** Best-effort single pass over parked writes; returns items still parked. */
|
|
61
|
+
flush(deadlineMs: number): Promise<number>;
|
|
62
|
+
pendingTotal(): number;
|
|
63
|
+
dispose(): void;
|
|
64
|
+
private enqueue;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=step-persister.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"step-persister.d.ts","sourceRoot":"","sources":["../src/step-persister.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,sBAAsB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEnF,OAAO,EAAE,KAAK,gBAAgB,EAAkB,MAAM,uBAAuB,CAAC;AAE9E;;;;;;;;;;GAUG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAC;IACtC,mEAAmE;IACnE,cAAc,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC;IAC1C,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAI1D;AAED,qBAAa,aAAa;IAOZ,OAAO,CAAC,QAAQ,CAAC,IAAI;IANjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IAGvC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqB;IAC5C,OAAO,CAAC,QAAQ,CAAS;gBAEI,IAAI,EAAE,iBAAiB;IAcpD;;;OAGG;IACG,OAAO,CACX,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,sBAAsB,GAC1B,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAuC5B;;;;OAIG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI7B;;;;OAIG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B;;;;OAIG;IACH,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAI1D,gFAAgF;IAChF,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAKpC,oEAAoE;IACpE,eAAe,IAAI,IAAI;IAKvB,8EAA8E;IAC9E,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI1C,YAAY,IAAI,MAAM;IAItB,OAAO,IAAI,IAAI;IAKf,OAAO,CAAC,OAAO;CAOhB"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ApiError } from '@parall/sdk';
|
|
2
|
+
import { StepRetryQueue } from './step-retry-queue.js';
|
|
3
|
+
export function isRetryableStepError(err) {
|
|
4
|
+
// ApiError status 0 = fetch-level failure (timeout / network); 5xx =
|
|
5
|
+
// server-side transient. 4xx are contract errors a retry cannot fix.
|
|
6
|
+
return err instanceof ApiError && (err.status === 0 || err.status >= 500);
|
|
7
|
+
}
|
|
8
|
+
export class StepPersister {
|
|
9
|
+
opts;
|
|
10
|
+
queue;
|
|
11
|
+
// Sessions whose producer end is closed (finalization in progress) — new
|
|
12
|
+
// writes are refused instead of racing the drain/close sequence.
|
|
13
|
+
sealed = new Set();
|
|
14
|
+
disposed = false;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.opts = opts;
|
|
17
|
+
this.queue = new StepRetryQueue({
|
|
18
|
+
log: opts.log,
|
|
19
|
+
isRetryable: isRetryableStepError,
|
|
20
|
+
isSessionStale: opts.isSessionStale,
|
|
21
|
+
retryDelaysMs: opts.retryDelaysMs,
|
|
22
|
+
// Inline step writes already drive full stale recovery; a background
|
|
23
|
+
// retry discovering it just drops the dead session's queue — the next
|
|
24
|
+
// dispatch hits the same 409 inline and heals.
|
|
25
|
+
onSessionStale: (sessionId, err) => opts.log?.warn(`queued step hit stale session ${sessionId}: ${String(err)}`),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Write one AgentStep, riding the retry queue on transient failure.
|
|
30
|
+
* Returns the created step, or null when the write was queued or dropped.
|
|
31
|
+
*/
|
|
32
|
+
async persist(sessionId, label, req) {
|
|
33
|
+
if (this.disposed) {
|
|
34
|
+
// Absorbing, like the queue and the lifecycle coordinator: a dispatch
|
|
35
|
+
// still draining past the shutdown deadline must not race the exiting
|
|
36
|
+
// process with fresh inline writes — its WorkItem is un-acked, so the
|
|
37
|
+
// re-drive on the replacement pod rewrites these steps idempotently.
|
|
38
|
+
this.opts.log?.warn(`refusing ${label} step after dispose (shutting down)`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (this.sealed.has(sessionId)) {
|
|
42
|
+
this.opts.log?.warn(`refusing ${label} step for sealed session ${sessionId} (finalization in progress)`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (this.queue.hasPending(sessionId)) {
|
|
46
|
+
// Preserve per-session FIFO while degraded.
|
|
47
|
+
this.enqueue(sessionId, label, req);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return await this.opts.client.createAgentStep(this.opts.orgId, this.opts.agentUserId, sessionId, req);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (this.opts.isSessionStale(err))
|
|
55
|
+
throw err;
|
|
56
|
+
if (isRetryableStepError(err)) {
|
|
57
|
+
this.opts.log?.warn(`failed to create ${label} step (queued for retry): ${String(err)}`);
|
|
58
|
+
this.enqueue(sessionId, label, req);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.opts.log?.warn(`failed to create ${label} step: ${String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Close the producer end for a session (finalization began): subsequent
|
|
68
|
+
* persist() calls are refused with a warn. Parked/in-flight writes keep
|
|
69
|
+
* retrying — seal gates NEW writes only.
|
|
70
|
+
*/
|
|
71
|
+
seal(sessionId) {
|
|
72
|
+
this.sealed.add(sessionId);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reclaim a seal once its finalization released the session (the seal set
|
|
76
|
+
* must not grow forever on a daemon churning fork sessions). After the
|
|
77
|
+
* session is closed server-side, any late write is rejected there (409).
|
|
78
|
+
*/
|
|
79
|
+
unseal(sessionId) {
|
|
80
|
+
this.sealed.delete(sessionId);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolves when the session's step queue reaches quiescence — see
|
|
84
|
+
* StepRetryQueue.awaitSessionDrained. Non-destructive: parked writes keep
|
|
85
|
+
* retrying on their normal schedule while awaited.
|
|
86
|
+
*/
|
|
87
|
+
drainSession(sessionId) {
|
|
88
|
+
return this.queue.awaitSessionDrained(sessionId);
|
|
89
|
+
}
|
|
90
|
+
/** DESTRUCTIVE: discards the session's parked writes (server-terminal only). */
|
|
91
|
+
dropSession(sessionId) {
|
|
92
|
+
this.sealed.delete(sessionId);
|
|
93
|
+
this.queue.dropSession(sessionId);
|
|
94
|
+
}
|
|
95
|
+
/** Every session is dead (server closed them all — new_session). */
|
|
96
|
+
dropAllSessions() {
|
|
97
|
+
this.sealed.clear();
|
|
98
|
+
this.queue.dropAllSessions();
|
|
99
|
+
}
|
|
100
|
+
/** Best-effort single pass over parked writes; returns items still parked. */
|
|
101
|
+
flush(deadlineMs) {
|
|
102
|
+
return this.queue.flush(deadlineMs);
|
|
103
|
+
}
|
|
104
|
+
pendingTotal() {
|
|
105
|
+
return this.queue.pendingTotal();
|
|
106
|
+
}
|
|
107
|
+
dispose() {
|
|
108
|
+
this.disposed = true;
|
|
109
|
+
this.queue.dispose();
|
|
110
|
+
}
|
|
111
|
+
enqueue(sessionId, label, req) {
|
|
112
|
+
this.queue.enqueue(sessionId, label, () => this.opts.client
|
|
113
|
+
.createAgentStep(this.opts.orgId, this.opts.agentUserId, sessionId, req)
|
|
114
|
+
.then(() => undefined));
|
|
115
|
+
}
|
|
116
|
+
}
|