@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,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.
|
|
3
|
+
"version": "1.46.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
|
-
"
|
|
38
|
+
"undici": "^7.24.8",
|
|
39
|
+
"@parall/sdk": "1.46.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
|
package/src/dispatch-adapter.ts
CHANGED
|
@@ -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.
|
package/src/event-format.ts
CHANGED
|
@@ -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
|
|
179
|
-
//
|
|
180
|
-
// reply path
|
|
181
|
-
//
|
|
182
|
-
//
|
|
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
|
+
}
|