@indigoai-us/hq-cli 5.108.15 → 5.108.16
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/CHANGELOG.md +13 -0
- package/dist/lib/mesh/api.js +6 -1
- package/dist/lib/mesh/client.js +26 -6
- package/dist/lib/mesh/live/daemon/run.d.ts +1 -0
- package/dist/lib/mesh/live/daemon/run.js +8 -1
- package/dist/lib/work-context/outbox.d.ts +23 -3
- package/dist/lib/work-context/outbox.js +143 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.16] — 2026-09-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Work Mesh Live daemon no longer retries every queued work-context outbox
|
|
10
|
+
operation on every flush cycle. Outbox replay now applies per-operation
|
|
11
|
+
exponential backoff (`nextAttemptAt`, 30s base → 6h cap with jitter), a
|
|
12
|
+
per-cycle cap (200 due ops), an attempt ceiling (50 → quarantine as
|
|
13
|
+
`<code>_MAX_ATTEMPTS`), cheaper listing that skips re-reading unchanged
|
|
14
|
+
outbox files, and classifies HTTP 404/409/410/412 (and other 4xx) from
|
|
15
|
+
transport errors as non-retryable instead of retrying them forever as
|
|
16
|
+
`TRANSPORT_ERROR`.
|
|
17
|
+
|
|
5
18
|
## [5.108.15] — 2026-09-07
|
|
6
19
|
|
|
7
20
|
### Fixed
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -43,7 +43,12 @@ export async function meshJson(token, path, init = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
if (!res.ok) {
|
|
45
45
|
const err = data;
|
|
46
|
-
|
|
46
|
+
// Always lead with the HTTP status: callers classify retryable vs permanent
|
|
47
|
+
// failures from the message (see createWorkSessionDeliverer). Without it a
|
|
48
|
+
// 400 whose body text matched no pattern was retried forever.
|
|
49
|
+
const detail = err.error || err.message || res.statusText || "request failed";
|
|
50
|
+
const code = typeof err.code === "string" && err.code ? `${err.code}: ` : "";
|
|
51
|
+
throw new Error(`${res.status} ${code}${detail}`);
|
|
47
52
|
}
|
|
48
53
|
return data;
|
|
49
54
|
}
|
package/dist/lib/mesh/client.js
CHANGED
|
@@ -46,17 +46,37 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
46
46
|
}
|
|
47
47
|
catch (err) {
|
|
48
48
|
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
-
// meshJson throws on !ok with error text; classify
|
|
50
|
-
|
|
49
|
+
// meshJson throws on !ok with error text; classify by leading status when present.
|
|
50
|
+
const statusMatch = /^(\d{3})\b/.exec(message);
|
|
51
|
+
if (statusMatch) {
|
|
52
|
+
const status = Number(statusMatch[1]);
|
|
53
|
+
if (status === 401 || status === 403) {
|
|
54
|
+
return { ok: false, retryable: false, code: "AUTH_DENIED" };
|
|
55
|
+
}
|
|
56
|
+
if (status === 400 || status === 422) {
|
|
57
|
+
return { ok: false, retryable: false, code: "VALIDATION_FAILED" };
|
|
58
|
+
}
|
|
59
|
+
if (status === 429 || (status >= 500 && status <= 599)) {
|
|
60
|
+
return { ok: false, retryable: true, code: `HTTP_${status}` };
|
|
61
|
+
}
|
|
62
|
+
if (status === 404 ||
|
|
63
|
+
status === 409 ||
|
|
64
|
+
status === 410 ||
|
|
65
|
+
status === 412 ||
|
|
66
|
+
(status >= 400 && status < 500)) {
|
|
67
|
+
return { ok: false, retryable: false, code: `HTTP_${status}` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (/unauthorized|forbidden|auth/i.test(message)) {
|
|
51
71
|
return { ok: false, retryable: false, code: "AUTH_DENIED" };
|
|
52
72
|
}
|
|
53
|
-
if (
|
|
73
|
+
if (/invalid|validation/i.test(message)) {
|
|
54
74
|
return { ok: false, retryable: false, code: "VALIDATION_FAILED" };
|
|
55
75
|
}
|
|
56
|
-
if (
|
|
76
|
+
if (/ECONN|ENOTFOUND|ETIMEDOUT|network|fetch failed/i.test(message)) {
|
|
57
77
|
return { ok: false, retryable: true, code: "NETWORK_OR_5XX" };
|
|
58
78
|
}
|
|
59
|
-
//
|
|
79
|
+
// No status in the message: keep queued (fail open to retry, not quarantine).
|
|
60
80
|
return { ok: false, retryable: true, code: "TRANSPORT_ERROR" };
|
|
61
81
|
}
|
|
62
82
|
};
|
|
@@ -73,7 +93,7 @@ function mapRegisterResponse(status, body, operationId) {
|
|
|
73
93
|
if (status >= 500 || status === 429) {
|
|
74
94
|
return { ok: false, retryable: true, code: `HTTP_${status}` };
|
|
75
95
|
}
|
|
76
|
-
if (status
|
|
96
|
+
if (status >= 400 && status < 500) {
|
|
77
97
|
return { ok: false, retryable: false, code: `HTTP_${status}` };
|
|
78
98
|
}
|
|
79
99
|
if (status === 0) {
|
|
@@ -255,7 +255,14 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
255
255
|
noteHookSessionsFromSpoolFile(workMeshHeldPath(meshRoot), lastHookEventAt, at);
|
|
256
256
|
const summary = await flushFn();
|
|
257
257
|
try {
|
|
258
|
-
await replayFn();
|
|
258
|
+
const replay = await replayFn();
|
|
259
|
+
const delivered = replay.delivered ?? 0;
|
|
260
|
+
const queued = replay.queued ?? 0;
|
|
261
|
+
const quarantined = replay.quarantined ?? 0;
|
|
262
|
+
const skipped = replay.skipped ?? 0;
|
|
263
|
+
if (delivered > 0 || queued > 0 || quarantined > 0 || skipped > 0) {
|
|
264
|
+
log(dir, `outbox replay: delivered=${delivered} queued=${queued} quarantined=${quarantined} skipped=${skipped}`);
|
|
265
|
+
}
|
|
259
266
|
}
|
|
260
267
|
catch (err) {
|
|
261
268
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
import type { DeliveryState } from "./contract.js";
|
|
6
6
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
7
7
|
/** Fields allowed on a durable outbox operation (privacy allowlist). */
|
|
8
|
-
export declare const OUTBOX_ALLOWLIST: readonly ["contractVersion", "operationId", "clientOperationId", "sessionId", "companyUid", "companySlug", "projectId", "taskId", "kind", "digest", "delivery", "createdAt", "updatedAt", "attemptCount", "lastErrorCode", "receiptId", "destinationCompanyUid", "destinationCompanySlug"];
|
|
8
|
+
export declare const OUTBOX_ALLOWLIST: readonly ["contractVersion", "operationId", "clientOperationId", "sessionId", "companyUid", "companySlug", "projectId", "taskId", "kind", "digest", "delivery", "createdAt", "updatedAt", "attemptCount", "lastErrorCode", "receiptId", "destinationCompanyUid", "destinationCompanySlug", "nextAttemptAt"];
|
|
9
|
+
/** Base delay for outbox retry backoff (attempt 1 → 30s). */
|
|
10
|
+
export declare const OUTBOX_RETRY_BASE_MS = 30000;
|
|
11
|
+
/** Cap for outbox retry backoff (6 hours). */
|
|
12
|
+
export declare const OUTBOX_RETRY_MAX_MS: number;
|
|
13
|
+
/** Quarantine after this many delivery attempts on retryable failures. */
|
|
14
|
+
export declare const OUTBOX_MAX_ATTEMPTS = 50;
|
|
15
|
+
/** Max due operations processed per replayOutbox call. */
|
|
16
|
+
export declare const OUTBOX_REPLAY_MAX_OPS = 200;
|
|
9
17
|
export type OutboxKind = "register" | "reconcile" | "migrate";
|
|
10
18
|
export interface OutboxOperation {
|
|
11
19
|
contractVersion: typeof WORK_CONTEXT_CONTRACT_VERSION;
|
|
@@ -27,6 +35,8 @@ export interface OutboxOperation {
|
|
|
27
35
|
/** Destination company for kind=migrate (source is companyUid). */
|
|
28
36
|
destinationCompanyUid?: string;
|
|
29
37
|
destinationCompanySlug?: string;
|
|
38
|
+
/** ISO time when the next delivery attempt is due (absent → due immediately). */
|
|
39
|
+
nextAttemptAt?: string;
|
|
30
40
|
}
|
|
31
41
|
export interface OutboxEnqueueInput {
|
|
32
42
|
clientOperationId: string;
|
|
@@ -40,6 +50,7 @@ export interface OutboxEnqueueInput {
|
|
|
40
50
|
destinationCompanySlug?: string;
|
|
41
51
|
now?: () => Date;
|
|
42
52
|
}
|
|
53
|
+
export declare function clearOutboxListCache(): void;
|
|
43
54
|
export declare function stableOperationId(clientOperationId: string, sessionId: string): string;
|
|
44
55
|
export declare function digestOperation(parts: {
|
|
45
56
|
sessionId: string;
|
|
@@ -52,6 +63,8 @@ export declare function digestOperation(parts: {
|
|
|
52
63
|
destinationCompanyUid?: string;
|
|
53
64
|
destinationCompanySlug?: string;
|
|
54
65
|
}): string;
|
|
66
|
+
/** Delay before next attempt: min(BASE * 2^(attemptCount-1), MAX), then [0.5, 1.0] jitter. */
|
|
67
|
+
export declare function outboxRetryDelayMs(attemptCount: number, random?: () => number): number;
|
|
55
68
|
/**
|
|
56
69
|
* Atomically enqueue (or idempotently return) an outbox operation.
|
|
57
70
|
* Disk/permission/lock failure → NotTrackingError (no network-only send).
|
|
@@ -60,9 +73,12 @@ export declare function enqueueOutbox(input: OutboxEnqueueInput, root: string):
|
|
|
60
73
|
export declare function readOutboxOperation(operationId: string, root: string): OutboxOperation | null;
|
|
61
74
|
export declare function updateOutboxOperation(op: OutboxOperation, root: string): void;
|
|
62
75
|
export declare function markOutboxAcked(operationId: string, root: string, receiptId: string, now?: () => Date): OutboxOperation | null;
|
|
63
|
-
export declare function markOutboxQueued(operationId: string, root: string, errorCode: string, now?: () => Date): OutboxOperation | null;
|
|
76
|
+
export declare function markOutboxQueued(operationId: string, root: string, errorCode: string, now?: () => Date, random?: () => number): OutboxOperation | null;
|
|
64
77
|
export declare function markOutboxQuarantined(operationId: string, root: string, errorCode: string, now?: () => Date): OutboxOperation | null;
|
|
65
|
-
export
|
|
78
|
+
export type OutboxListReadFile = (filePath: string) => string;
|
|
79
|
+
export declare function listOutboxOperations(root: string, deps?: {
|
|
80
|
+
readFile?: OutboxListReadFile;
|
|
81
|
+
}): OutboxOperation[];
|
|
66
82
|
export interface OutboxStats {
|
|
67
83
|
depth: number;
|
|
68
84
|
quarantined: number;
|
|
@@ -81,14 +97,18 @@ export type DeliverFn = (op: OutboxOperation) => Promise<{
|
|
|
81
97
|
/**
|
|
82
98
|
* Replay queued (and recover lost-receipt) operations.
|
|
83
99
|
* FIFO per sessionId; bounded parallelism across sessions.
|
|
100
|
+
* Skips ops whose nextAttemptAt is in the future; caps work per call.
|
|
84
101
|
*/
|
|
85
102
|
export declare function replayOutbox(root: string, deliver: DeliverFn, opts?: {
|
|
86
103
|
parallel?: number;
|
|
87
104
|
now?: () => Date;
|
|
105
|
+
maxOps?: number;
|
|
106
|
+
random?: () => number;
|
|
88
107
|
}): Promise<{
|
|
89
108
|
delivered: number;
|
|
90
109
|
queued: number;
|
|
91
110
|
quarantined: number;
|
|
111
|
+
skipped: number;
|
|
92
112
|
}>;
|
|
93
113
|
/** Remove acked ops older than retention (optional GC; not required by AC). */
|
|
94
114
|
export declare function removeAckedOutbox(root: string, olderThanMs: number, now?: number): number;
|
|
@@ -29,8 +29,24 @@ export const OUTBOX_ALLOWLIST = [
|
|
|
29
29
|
"receiptId",
|
|
30
30
|
"destinationCompanyUid",
|
|
31
31
|
"destinationCompanySlug",
|
|
32
|
+
"nextAttemptAt",
|
|
32
33
|
];
|
|
34
|
+
/** Base delay for outbox retry backoff (attempt 1 → 30s). */
|
|
35
|
+
export const OUTBOX_RETRY_BASE_MS = 30_000;
|
|
36
|
+
/** Cap for outbox retry backoff (6 hours). */
|
|
37
|
+
export const OUTBOX_RETRY_MAX_MS = 6 * 60 * 60 * 1000;
|
|
38
|
+
/** Quarantine after this many delivery attempts on retryable failures. */
|
|
39
|
+
export const OUTBOX_MAX_ATTEMPTS = 50;
|
|
40
|
+
/** Max due operations processed per replayOutbox call. */
|
|
41
|
+
export const OUTBOX_REPLAY_MAX_OPS = 200;
|
|
33
42
|
const DEFAULT_PARALLEL = 4;
|
|
43
|
+
const outboxListCache = new Map();
|
|
44
|
+
function invalidateOutboxListCacheEntry(filePath) {
|
|
45
|
+
outboxListCache.delete(filePath);
|
|
46
|
+
}
|
|
47
|
+
export function clearOutboxListCache() {
|
|
48
|
+
outboxListCache.clear();
|
|
49
|
+
}
|
|
34
50
|
export function stableOperationId(clientOperationId, sessionId) {
|
|
35
51
|
const h = crypto
|
|
36
52
|
.createHash("sha256")
|
|
@@ -85,8 +101,18 @@ function projectOutbox(op) {
|
|
|
85
101
|
if (op.destinationCompanySlug) {
|
|
86
102
|
out.destinationCompanySlug = op.destinationCompanySlug;
|
|
87
103
|
}
|
|
104
|
+
if (op.nextAttemptAt)
|
|
105
|
+
out.nextAttemptAt = op.nextAttemptAt;
|
|
88
106
|
return out;
|
|
89
107
|
}
|
|
108
|
+
/** Delay before next attempt: min(BASE * 2^(attemptCount-1), MAX), then [0.5, 1.0] jitter. */
|
|
109
|
+
export function outboxRetryDelayMs(attemptCount, random = Math.random) {
|
|
110
|
+
const exp = Math.max(0, attemptCount - 1);
|
|
111
|
+
const delay = Math.min(OUTBOX_RETRY_BASE_MS * 2 ** exp, OUTBOX_RETRY_MAX_MS);
|
|
112
|
+
const unit = random();
|
|
113
|
+
const factor = 0.5 + 0.5 * Math.min(1, Math.max(0, unit));
|
|
114
|
+
return Math.floor(delay * factor);
|
|
115
|
+
}
|
|
90
116
|
/**
|
|
91
117
|
* Atomically enqueue (or idempotently return) an outbox operation.
|
|
92
118
|
* Disk/permission/lock failure → NotTrackingError (no network-only send).
|
|
@@ -182,6 +208,7 @@ export function enqueueOutbox(input, root) {
|
|
|
182
208
|
catch {
|
|
183
209
|
/* ignore */
|
|
184
210
|
}
|
|
211
|
+
invalidateOutboxListCacheEntry(dest);
|
|
185
212
|
return op;
|
|
186
213
|
}
|
|
187
214
|
catch (err) {
|
|
@@ -204,7 +231,9 @@ export function readOutboxOperation(operationId, root) {
|
|
|
204
231
|
}
|
|
205
232
|
}
|
|
206
233
|
export function updateOutboxOperation(op, root) {
|
|
207
|
-
|
|
234
|
+
const filePath = workContextOutboxPath(op.operationId, root);
|
|
235
|
+
atomicWriteJson(filePath, projectOutbox(op));
|
|
236
|
+
invalidateOutboxListCacheEntry(filePath);
|
|
208
237
|
}
|
|
209
238
|
export function markOutboxAcked(operationId, root, receiptId, now = () => new Date()) {
|
|
210
239
|
const op = readOutboxOperation(operationId, root);
|
|
@@ -216,14 +245,17 @@ export function markOutboxAcked(operationId, root, receiptId, now = () => new Da
|
|
|
216
245
|
updateOutboxOperation(op, root);
|
|
217
246
|
return op;
|
|
218
247
|
}
|
|
219
|
-
export function markOutboxQueued(operationId, root, errorCode, now = () => new Date()) {
|
|
248
|
+
export function markOutboxQueued(operationId, root, errorCode, now = () => new Date(), random = Math.random) {
|
|
220
249
|
const op = readOutboxOperation(operationId, root);
|
|
221
250
|
if (!op)
|
|
222
251
|
return null;
|
|
223
252
|
op.delivery = "queued";
|
|
224
253
|
op.lastErrorCode = errorCode;
|
|
225
254
|
op.attemptCount += 1;
|
|
226
|
-
|
|
255
|
+
const at = now();
|
|
256
|
+
op.updatedAt = at.toISOString();
|
|
257
|
+
const delayMs = outboxRetryDelayMs(op.attemptCount, random);
|
|
258
|
+
op.nextAttemptAt = new Date(at.getTime() + delayMs).toISOString();
|
|
227
259
|
updateOutboxOperation(op, root);
|
|
228
260
|
return op;
|
|
229
261
|
}
|
|
@@ -238,17 +270,58 @@ export function markOutboxQuarantined(operationId, root, errorCode, now = () =>
|
|
|
238
270
|
updateOutboxOperation(op, root);
|
|
239
271
|
return op;
|
|
240
272
|
}
|
|
241
|
-
export function listOutboxOperations(root) {
|
|
273
|
+
export function listOutboxOperations(root, deps = {}) {
|
|
274
|
+
const readFile = deps.readFile ?? ((filePath) => fs.readFileSync(filePath, "utf8"));
|
|
242
275
|
const dir = workContextOutboxDir(root);
|
|
243
276
|
if (!fs.existsSync(dir))
|
|
244
277
|
return [];
|
|
245
278
|
const ops = [];
|
|
279
|
+
const seen = new Set();
|
|
246
280
|
for (const name of fs.readdirSync(dir)) {
|
|
247
281
|
if (!name.endsWith(".json") || name.startsWith("."))
|
|
248
282
|
continue;
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
283
|
+
const operationId = name.replace(/\.json$/, "");
|
|
284
|
+
if (!isSafeWorkContextSegment(operationId))
|
|
285
|
+
continue;
|
|
286
|
+
const filePath = workContextOutboxPath(operationId, root);
|
|
287
|
+
seen.add(filePath);
|
|
288
|
+
let st;
|
|
289
|
+
try {
|
|
290
|
+
st = fs.statSync(filePath);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const cached = outboxListCache.get(filePath);
|
|
296
|
+
if (cached &&
|
|
297
|
+
cached.ino === st.ino &&
|
|
298
|
+
cached.mtimeMs === st.mtimeMs &&
|
|
299
|
+
cached.ctimeMs === st.ctimeMs &&
|
|
300
|
+
cached.size === st.size) {
|
|
301
|
+
ops.push(cached.op);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const op = JSON.parse(readFile(filePath));
|
|
306
|
+
outboxListCache.set(filePath, {
|
|
307
|
+
ino: st.ino,
|
|
308
|
+
mtimeMs: st.mtimeMs,
|
|
309
|
+
ctimeMs: st.ctimeMs,
|
|
310
|
+
size: st.size,
|
|
311
|
+
op,
|
|
312
|
+
});
|
|
251
313
|
ops.push(op);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
/* ignore corrupt */
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Drop cache entries for paths no longer present under this listing pass
|
|
320
|
+
// only when they sit in this outbox dir (other roots may share the module cache).
|
|
321
|
+
for (const key of outboxListCache.keys()) {
|
|
322
|
+
if (key.startsWith(dir + path.sep) && !seen.has(key)) {
|
|
323
|
+
outboxListCache.delete(key);
|
|
324
|
+
}
|
|
252
325
|
}
|
|
253
326
|
return ops.sort((a, b) => {
|
|
254
327
|
const byTime = a.createdAt.localeCompare(b.createdAt);
|
|
@@ -266,21 +339,68 @@ export function outboxStats(root) {
|
|
|
266
339
|
acked: ops.filter((o) => o.delivery === "acked").length,
|
|
267
340
|
};
|
|
268
341
|
}
|
|
342
|
+
function isOutboxDue(op, nowIso) {
|
|
343
|
+
return !op.nextAttemptAt || op.nextAttemptAt <= nowIso;
|
|
344
|
+
}
|
|
345
|
+
function outboxFifoCompare(a, b) {
|
|
346
|
+
const byCreated = a.createdAt.localeCompare(b.createdAt);
|
|
347
|
+
if (byCreated !== 0)
|
|
348
|
+
return byCreated;
|
|
349
|
+
return a.operationId.localeCompare(b.operationId);
|
|
350
|
+
}
|
|
351
|
+
/** Contiguous leading due ops; stops at the first not-yet-due op (no skip-ahead). */
|
|
352
|
+
function duePrefixForSession(ops, nowIso) {
|
|
353
|
+
const prefix = [];
|
|
354
|
+
for (const op of ops) {
|
|
355
|
+
if (!isOutboxDue(op, nowIso))
|
|
356
|
+
break;
|
|
357
|
+
prefix.push(op);
|
|
358
|
+
}
|
|
359
|
+
return prefix;
|
|
360
|
+
}
|
|
269
361
|
/**
|
|
270
362
|
* Replay queued (and recover lost-receipt) operations.
|
|
271
363
|
* FIFO per sessionId; bounded parallelism across sessions.
|
|
364
|
+
* Skips ops whose nextAttemptAt is in the future; caps work per call.
|
|
272
365
|
*/
|
|
273
366
|
export async function replayOutbox(root, deliver, opts = {}) {
|
|
274
367
|
const parallel = opts.parallel ?? DEFAULT_PARALLEL;
|
|
275
368
|
const now = opts.now ?? (() => new Date());
|
|
369
|
+
const random = opts.random ?? Math.random;
|
|
370
|
+
const maxOps = opts.maxOps ?? OUTBOX_REPLAY_MAX_OPS;
|
|
371
|
+
const nowIso = now().toISOString();
|
|
276
372
|
const pending = listOutboxOperations(root).filter((o) => o.delivery === "queued" || (o.delivery === "acked" && !o.receiptId));
|
|
277
|
-
// Group by session
|
|
278
|
-
const
|
|
373
|
+
// Group ALL pending by session first (FIFO), then take each session's due prefix.
|
|
374
|
+
const pendingBySession = new Map();
|
|
279
375
|
for (const op of pending) {
|
|
280
|
-
const list =
|
|
376
|
+
const list = pendingBySession.get(op.sessionId) ?? [];
|
|
281
377
|
list.push(op);
|
|
282
|
-
|
|
378
|
+
pendingBySession.set(op.sessionId, list);
|
|
379
|
+
}
|
|
380
|
+
for (const list of pendingBySession.values()) {
|
|
381
|
+
list.sort(outboxFifoCompare);
|
|
283
382
|
}
|
|
383
|
+
const sessionPrefixes = [];
|
|
384
|
+
for (const [sessionId, list] of pendingBySession) {
|
|
385
|
+
const prefix = duePrefixForSession(list, nowIso);
|
|
386
|
+
if (prefix.length > 0) {
|
|
387
|
+
sessionPrefixes.push({ sessionId, ops: prefix });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Cap by head age: oldest session heads first; take leading due ops only.
|
|
391
|
+
sessionPrefixes.sort((a, b) => outboxFifoCompare(a.ops[0], b.ops[0]));
|
|
392
|
+
const bySession = new Map();
|
|
393
|
+
let selectedCount = 0;
|
|
394
|
+
let eligibleDue = 0;
|
|
395
|
+
for (const { sessionId, ops } of sessionPrefixes) {
|
|
396
|
+
eligibleDue += ops.length;
|
|
397
|
+
if (selectedCount >= maxOps)
|
|
398
|
+
continue;
|
|
399
|
+
const take = Math.min(ops.length, maxOps - selectedCount);
|
|
400
|
+
bySession.set(sessionId, ops.slice(0, take));
|
|
401
|
+
selectedCount += take;
|
|
402
|
+
}
|
|
403
|
+
const skipped = Math.max(0, eligibleDue - selectedCount);
|
|
284
404
|
let delivered = 0;
|
|
285
405
|
let queued = 0;
|
|
286
406
|
let quarantined = 0;
|
|
@@ -297,8 +417,14 @@ export async function replayOutbox(root, deliver, opts = {}) {
|
|
|
297
417
|
delivered += 1;
|
|
298
418
|
}
|
|
299
419
|
else if (result.retryable) {
|
|
300
|
-
|
|
301
|
-
|
|
420
|
+
if (op.attemptCount + 1 >= OUTBOX_MAX_ATTEMPTS) {
|
|
421
|
+
markOutboxQuarantined(op.operationId, root, `${result.code}_MAX_ATTEMPTS`, now);
|
|
422
|
+
quarantined += 1;
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
markOutboxQueued(op.operationId, root, result.code, now, random);
|
|
426
|
+
queued += 1;
|
|
427
|
+
}
|
|
302
428
|
// Stop this session's FIFO on transient failure.
|
|
303
429
|
break;
|
|
304
430
|
}
|
|
@@ -320,7 +446,7 @@ export async function replayOutbox(root, deliver, opts = {}) {
|
|
|
320
446
|
}
|
|
321
447
|
const workers = Array.from({ length: Math.min(parallel, sessions.length) }, () => worker());
|
|
322
448
|
await Promise.all(workers);
|
|
323
|
-
return { delivered, queued, quarantined };
|
|
449
|
+
return { delivered, queued, quarantined, skipped };
|
|
324
450
|
}
|
|
325
451
|
/** Remove acked ops older than retention (optional GC; not required by AC). */
|
|
326
452
|
export function removeAckedOutbox(root, olderThanMs, now = Date.now()) {
|
|
@@ -330,8 +456,10 @@ export function removeAckedOutbox(root, olderThanMs, now = Date.now()) {
|
|
|
330
456
|
continue;
|
|
331
457
|
const age = now - Date.parse(op.updatedAt);
|
|
332
458
|
if (Number.isFinite(age) && age > olderThanMs) {
|
|
459
|
+
const filePath = workContextOutboxPath(op.operationId, root);
|
|
333
460
|
try {
|
|
334
|
-
fs.unlinkSync(
|
|
461
|
+
fs.unlinkSync(filePath);
|
|
462
|
+
invalidateOutboxListCacheEntry(filePath);
|
|
335
463
|
removed += 1;
|
|
336
464
|
}
|
|
337
465
|
catch {
|
|
@@ -349,6 +477,7 @@ export function quarantineCorruptOutboxFile(filePath, root) {
|
|
|
349
477
|
const dest = path.join(workContextOutboxDir(root), `.quarantine.${base}`);
|
|
350
478
|
try {
|
|
351
479
|
fs.renameSync(filePath, dest);
|
|
480
|
+
invalidateOutboxListCacheEntry(filePath);
|
|
352
481
|
}
|
|
353
482
|
catch {
|
|
354
483
|
/* ignore */
|