@commonlyai/cli 0.1.170 → 0.1.171
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/package.json +1 -1
- package/src/commands/agent.js +222 -21
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -487,6 +487,28 @@ const inboxPriority = (event) => {
|
|
|
487
487
|
// event that represents the shared pod inbox is batched below.
|
|
488
488
|
const PRIVATE_RESPONSE_EVENT_TYPES = new Set(['agent.ask', 'agent.ask.response']);
|
|
489
489
|
|
|
490
|
+
// How often a live delivery lease is renewed: during a turn, and while a failed
|
|
491
|
+
// turn's events wait for their retry. The server requeues a delivery whose
|
|
492
|
+
// lease is 10+ minutes old (AGENT_EVENT_REQUEUE_DELIVERED_MINUTES).
|
|
493
|
+
const DELIVERY_RENEW_INTERVAL_MS = 60_000;
|
|
494
|
+
|
|
495
|
+
// A failed turn's events are carried in memory and re-run when the retry timer
|
|
496
|
+
// fires (see `holdForRetry` in performRun). Each local re-run is a delivery
|
|
497
|
+
// attempt the server's requeue cap (AGENT_EVENT_REQUEUE_MAX_ATTEMPTS, 3) never
|
|
498
|
+
// counts, so a carry with no bound would retry poison content forever and
|
|
499
|
+
// block every later event behind it. At this many consecutive failures of the
|
|
500
|
+
// same event the carry lets it go: renewal stops, and the server's requeue
|
|
501
|
+
// sweep redelivers it under the cap it already enforces. Three matches the
|
|
502
|
+
// point where spawnRetryPolicy stops treating an unknown failure as transient.
|
|
503
|
+
const HELD_RETRY_MAX_FAILURES = 3;
|
|
504
|
+
|
|
505
|
+
const deliveryIdOf = (event) => {
|
|
506
|
+
const deliveryId = event?.payload?.deliveryId;
|
|
507
|
+
return typeof deliveryId === 'string' && deliveryId ? deliveryId : null;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const countEvents = (count) => `${count} event${count === 1 ? '' : 's'}`;
|
|
511
|
+
|
|
490
512
|
// ── default environment for adapters that benefit from auto-MCP wiring ─────
|
|
491
513
|
|
|
492
514
|
// Which adapters can consume `mcp[]`, and the server declaration they get by
|
|
@@ -2301,6 +2323,145 @@ export const performRun = ({
|
|
|
2301
2323
|
let heldGreeting = [];
|
|
2302
2324
|
let greetingHoldSpent = false;
|
|
2303
2325
|
|
|
2326
|
+
// The same is true of a batch whose turn FAILED: its events are claimed, so
|
|
2327
|
+
// scheduling a later poll retries nothing — the next poll cannot return
|
|
2328
|
+
// them, and the old "retry scheduled" was really a 10–20 minute wait for the
|
|
2329
|
+
// requeue sweep with the process idle in between (sprint-review,
|
|
2330
|
+
// 2026-10-25). So a failed turn's events are carried exactly like the
|
|
2331
|
+
// greeting and rejoin the inbox when the retry timer fires. Their leases are
|
|
2332
|
+
// renewed while they wait, because an ack under a lapsed lease is a 409
|
|
2333
|
+
// `stale_delivery`, and that stops the run. The rule (CTO ruling,
|
|
2334
|
+
// 2026-10-25): a scheduled retry either fires, re-running the held events,
|
|
2335
|
+
// or releases them; it never leaves a live process idle on unacked work.
|
|
2336
|
+
let heldRetry = [];
|
|
2337
|
+
// eventId → consecutive local failures, for HELD_RETRY_MAX_FAILURES.
|
|
2338
|
+
const heldRetryFailures = new Map();
|
|
2339
|
+
let heldRenewTimer = null;
|
|
2340
|
+
const clearHeldRenewal = () => {
|
|
2341
|
+
if (heldRenewTimer !== null) clearIntervalImpl(heldRenewTimer);
|
|
2342
|
+
heldRenewTimer = null;
|
|
2343
|
+
};
|
|
2344
|
+
|
|
2345
|
+
// Renew the nonce-bound leases of carried events. Returns the ids the server
|
|
2346
|
+
// confirmed are still this runner's delivery, or null when it cannot say:
|
|
2347
|
+
// nothing carried a nonce, the request failed, or an older server answered
|
|
2348
|
+
// without a `renewed` list.
|
|
2349
|
+
const renewHeldDeliveries = async (events) => {
|
|
2350
|
+
const deliveries = events.filter(deliveryIdOf)
|
|
2351
|
+
.map((event) => ({ eventId: String(event._id), deliveryId: deliveryIdOf(event) }));
|
|
2352
|
+
if (deliveries.length === 0) return null;
|
|
2353
|
+
const confirmed = new Set();
|
|
2354
|
+
try {
|
|
2355
|
+
for (let offset = 0; offset < deliveries.length; offset += 50) {
|
|
2356
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2357
|
+
const response = await client.post('/api/agents/runtime/events/renew', {
|
|
2358
|
+
events: deliveries.slice(offset, offset + 50),
|
|
2359
|
+
});
|
|
2360
|
+
if (!Array.isArray(response?.renewed)) return null;
|
|
2361
|
+
for (const id of response.renewed) confirmed.add(String(id));
|
|
2362
|
+
}
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
log(`[inbox.batch] held delivery renewal failed: ${error.message}`);
|
|
2365
|
+
return null;
|
|
2366
|
+
}
|
|
2367
|
+
return confirmed;
|
|
2368
|
+
};
|
|
2369
|
+
// A nonce-bound event the server did not renew is no longer ours to ack:
|
|
2370
|
+
// the child checkpointed it mid-turn, the requeue took it, or the cap
|
|
2371
|
+
// retired it. Re-running it would at best repeat finished work, and at worst
|
|
2372
|
+
// end in a stale-delivery ack that stops the run.
|
|
2373
|
+
const stillHeld = (event, confirmed) => (
|
|
2374
|
+
confirmed === null || !deliveryIdOf(event) || confirmed.has(String(event._id))
|
|
2375
|
+
);
|
|
2376
|
+
|
|
2377
|
+
// Decide what a failed turn's retry carries. `failedEvents` were attempted
|
|
2378
|
+
// and count toward HELD_RETRY_MAX_FAILURES; `untriedEvents` are later groups
|
|
2379
|
+
// from the same page that the failure stopped before they ran (#782), and
|
|
2380
|
+
// are just as claimed, so they ride along without a strike.
|
|
2381
|
+
const holdForRetry = async (failedEvents, untriedEvents = []) => {
|
|
2382
|
+
const released = [];
|
|
2383
|
+
const carry = [];
|
|
2384
|
+
const seen = new Set();
|
|
2385
|
+
for (const event of failedEvents) {
|
|
2386
|
+
const id = String(event._id);
|
|
2387
|
+
if (!seen.has(id)) {
|
|
2388
|
+
seen.add(id);
|
|
2389
|
+
const failures = (heldRetryFailures.get(id) || 0) + 1;
|
|
2390
|
+
if (failures >= HELD_RETRY_MAX_FAILURES) released.push(event);
|
|
2391
|
+
else {
|
|
2392
|
+
heldRetryFailures.set(id, failures);
|
|
2393
|
+
carry.push(event);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
for (const event of untriedEvents) {
|
|
2398
|
+
const id = String(event._id);
|
|
2399
|
+
if (!seen.has(id)) {
|
|
2400
|
+
seen.add(id);
|
|
2401
|
+
carry.push(event);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
// A stopping run carries nothing and renews nothing: extending the lease
|
|
2405
|
+
// of a process on its way out only delays the replacement's redelivery.
|
|
2406
|
+
const confirmed = running && carry.length ? await renewHeldDeliveries(carry) : null;
|
|
2407
|
+
const held = running ? carry.filter((event) => stillHeld(event, confirmed)) : [];
|
|
2408
|
+
const dropped = running ? carry.filter((event) => !stillHeld(event, confirmed)) : [];
|
|
2409
|
+
const heldIds = new Set(held.map((event) => String(event._id)));
|
|
2410
|
+
for (const id of [...heldRetryFailures.keys()]) {
|
|
2411
|
+
if (!heldIds.has(id)) heldRetryFailures.delete(id);
|
|
2412
|
+
}
|
|
2413
|
+
heldRetry = held;
|
|
2414
|
+
if (dropped.length) {
|
|
2415
|
+
log(
|
|
2416
|
+
`[inbox.batch] ${countEvents(dropped.length)} no longer held by this runner on the server `
|
|
2417
|
+
+ '(checkpointed, requeued or retired) — not retried',
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
return { held, released };
|
|
2421
|
+
};
|
|
2422
|
+
|
|
2423
|
+
// Keep a long wait's leases alive. A wait shorter than one renewal interval
|
|
2424
|
+
// needs none: holdForRetry renewed at the start, and the re-run turn renews
|
|
2425
|
+
// its own batch from there.
|
|
2426
|
+
const keepHeldLeases = (delayMs) => {
|
|
2427
|
+
clearHeldRenewal();
|
|
2428
|
+
if (!running || delayMs <= DELIVERY_RENEW_INTERVAL_MS || !heldRetry.some(deliveryIdOf)) return;
|
|
2429
|
+
heldRenewTimer = setIntervalImpl(async () => {
|
|
2430
|
+
if (!running) {
|
|
2431
|
+
clearHeldRenewal();
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
const carry = heldRetry;
|
|
2435
|
+
const confirmed = await renewHeldDeliveries(carry);
|
|
2436
|
+
// The tick may already have taken this carry; never resurrect it.
|
|
2437
|
+
if (heldRetry !== carry || confirmed === null) return;
|
|
2438
|
+
heldRetry = carry.filter((event) => stillHeld(event, confirmed));
|
|
2439
|
+
if (heldRetry.length < carry.length) {
|
|
2440
|
+
log(
|
|
2441
|
+
`[inbox.batch] ${countEvents(carry.length - heldRetry.length)} no longer held by this runner `
|
|
2442
|
+
+ 'on the server during the retry wait — not retried',
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
2445
|
+
}, DELIVERY_RENEW_INTERVAL_MS);
|
|
2446
|
+
heldRenewTimer?.unref?.();
|
|
2447
|
+
};
|
|
2448
|
+
|
|
2449
|
+
// What the retry will actually do, for the failure report.
|
|
2450
|
+
const describeHold = ({ held, released }, retry) => {
|
|
2451
|
+
const parts = [];
|
|
2452
|
+
if (retry.circuitOpen) parts.push('circuit open');
|
|
2453
|
+
if (released.length) {
|
|
2454
|
+
parts.push(
|
|
2455
|
+
`releasing ${countEvents(released.length)} after ${HELD_RETRY_MAX_FAILURES} failed attempts `
|
|
2456
|
+
+ 'to the server\'s requeue (redelivered in ~10–20 min)',
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
parts.push(held.length
|
|
2460
|
+
? `retrying ${held.length} held event${held.length === 1 ? '' : 's'} in ${formatRetryDelay(retry.delayMs)}`
|
|
2461
|
+
: `next poll in ${formatRetryDelay(retry.delayMs)}`);
|
|
2462
|
+
return parts.join('; ');
|
|
2463
|
+
};
|
|
2464
|
+
|
|
2304
2465
|
const handleEventBatch = async (events, inboxCount = events.length) => {
|
|
2305
2466
|
const eventPodId = events[0]?.podId || podId;
|
|
2306
2467
|
if (!eventPodId || events.some((event) => (event.podId || podId) !== eventPodId)) {
|
|
@@ -2578,7 +2739,7 @@ export const performRun = ({
|
|
|
2578
2739
|
log(`[inbox.batch] delivery renewal failed: ${error.message}`);
|
|
2579
2740
|
} finally { renewing = false; }
|
|
2580
2741
|
};
|
|
2581
|
-
const timer = deliveries.length ? setIntervalImpl(renew,
|
|
2742
|
+
const timer = deliveries.length ? setIntervalImpl(renew, DELIVERY_RENEW_INTERVAL_MS) : null;
|
|
2582
2743
|
const stopRenewal = () => {
|
|
2583
2744
|
renewalStopped = true;
|
|
2584
2745
|
if (timer !== null) clearIntervalImpl(timer);
|
|
@@ -2600,6 +2761,7 @@ export const performRun = ({
|
|
|
2600
2761
|
if (!running) return;
|
|
2601
2762
|
let nextPollDelayMs = intervalMs;
|
|
2602
2763
|
let completedModelTurn = false;
|
|
2764
|
+
let retryScheduled = false;
|
|
2603
2765
|
try {
|
|
2604
2766
|
// ADR-024 D3: this is an inbox fetch, not a stream of interrupts. The
|
|
2605
2767
|
// backend returns one pod's oldest pending batch, so every delivered row
|
|
@@ -2615,15 +2777,25 @@ export const performRun = ({
|
|
|
2615
2777
|
// returns multiple pods in one page. Preserve pod privacy in that
|
|
2616
2778
|
// migration window by partitioning locally. Current servers produce one
|
|
2617
2779
|
// group, so the normal path remains one tick → one turn.
|
|
2618
|
-
// A carried greeting rejoins the inbox here, so it is
|
|
2619
|
-
// and acked by exactly the same path as anything the
|
|
2620
|
-
// carried row is still 'delivered' on the server, so a
|
|
2621
|
-
// carry can hand the same id back in this very page:
|
|
2622
|
-
// than processing it twice.
|
|
2623
|
-
|
|
2780
|
+
// A carried greeting or failed batch rejoins the inbox here, so it is
|
|
2781
|
+
// grouped, ordered and acked by exactly the same path as anything the
|
|
2782
|
+
// poll returned. The carried row is still 'delivered' on the server, so a
|
|
2783
|
+
// requeue during the carry can hand the same id back in this very page:
|
|
2784
|
+
// drop the carried copy rather than processing it twice. The fetched
|
|
2785
|
+
// copy wins because it carries the live deliveryId; the carried one's
|
|
2786
|
+
// nonce was cleared by that requeue.
|
|
2787
|
+
const carried = [...heldRetry, ...heldGreeting];
|
|
2624
2788
|
heldGreeting = [];
|
|
2789
|
+
heldRetry = [];
|
|
2790
|
+
clearHeldRenewal();
|
|
2625
2791
|
const fetchedIds = new Set(events.map((event) => String(event._id)));
|
|
2626
|
-
const
|
|
2792
|
+
const carriedIds = new Set();
|
|
2793
|
+
const carriedFresh = carried.filter((event) => {
|
|
2794
|
+
const id = String(event._id);
|
|
2795
|
+
if (fetchedIds.has(id) || carriedIds.has(id)) return false;
|
|
2796
|
+
carriedIds.add(id);
|
|
2797
|
+
return true;
|
|
2798
|
+
});
|
|
2627
2799
|
const pageEvents = carriedFresh.length ? [...carriedFresh, ...events] : events;
|
|
2628
2800
|
const pageInboxCount = (Number.isFinite(inboxCount) ? inboxCount : events.length)
|
|
2629
2801
|
+ carriedFresh.length;
|
|
@@ -2638,7 +2810,14 @@ export const performRun = ({
|
|
|
2638
2810
|
group.push(event);
|
|
2639
2811
|
eventGroups.set(key, group);
|
|
2640
2812
|
}
|
|
2641
|
-
|
|
2813
|
+
const groups = [...eventGroups.values()];
|
|
2814
|
+
// The claimed events a failure at `groupIndex` stops before they run.
|
|
2815
|
+
// A digest-only group was never claimed, so it is not carried.
|
|
2816
|
+
const untriedAfter = (groupIndex) => groups.slice(groupIndex + 1)
|
|
2817
|
+
.filter((later) => !later.every((event) => event.deliveryMode === 'digest'))
|
|
2818
|
+
.flat();
|
|
2819
|
+
for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
|
|
2820
|
+
const group = groups[groupIndex];
|
|
2642
2821
|
if (!running) break;
|
|
2643
2822
|
// A current kernel never claims a digest-only page. Guard a mixed
|
|
2644
2823
|
// version rollout too: an ambient batch must not start a model turn.
|
|
@@ -2659,7 +2838,9 @@ export const performRun = ({
|
|
|
2659
2838
|
}
|
|
2660
2839
|
// One failure report for a batch whose items stay unacked, whether
|
|
2661
2840
|
// the composite turn threw or an isolated item did after a refusal.
|
|
2662
|
-
|
|
2841
|
+
// The unacked items are carried to the retry, not left to the
|
|
2842
|
+
// requeue sweep, so the delay below is when they run again.
|
|
2843
|
+
const scheduleBatchRetry = async (err, failedEvents) => {
|
|
2663
2844
|
consecutiveSpawnFailures += 1;
|
|
2664
2845
|
const retry = spawnRetryPolicy({
|
|
2665
2846
|
error: err,
|
|
@@ -2668,11 +2849,13 @@ export const performRun = ({
|
|
|
2668
2849
|
jitterRatio: spawnJitterRatio,
|
|
2669
2850
|
});
|
|
2670
2851
|
nextPollDelayMs = retry.delayMs;
|
|
2852
|
+
retryScheduled = true;
|
|
2853
|
+
const hold = await holdForRetry(failedEvents, untriedAfter(groupIndex));
|
|
2854
|
+
keepHeldLeases(retry.delayMs);
|
|
2671
2855
|
const event = group[0];
|
|
2672
2856
|
const wrapped = new Error(
|
|
2673
2857
|
`inbox batch processing failed (${retry.failureClass}; ${consecutiveSpawnFailures} consecutive) `
|
|
2674
|
-
+ `— ${
|
|
2675
|
-
+ `next probe in ${formatRetryDelay(retry.delayMs)}: ${err.message}`,
|
|
2858
|
+
+ `— ${describeHold(hold, retry)}: ${err.message}`,
|
|
2676
2859
|
{ cause: err },
|
|
2677
2860
|
);
|
|
2678
2861
|
Object.assign(wrapped, {
|
|
@@ -2681,6 +2864,8 @@ export const performRun = ({
|
|
|
2681
2864
|
consecutiveFailures: consecutiveSpawnFailures,
|
|
2682
2865
|
retryAfterMs: retry.delayMs,
|
|
2683
2866
|
circuitOpen: retry.circuitOpen,
|
|
2867
|
+
heldCount: hold.held.length,
|
|
2868
|
+
releasedCount: hold.released.length,
|
|
2684
2869
|
eventId: event._id,
|
|
2685
2870
|
...(isPodFocusDiagnostic(err) ? {
|
|
2686
2871
|
focusDiagnostic: {
|
|
@@ -2704,7 +2889,7 @@ export const performRun = ({
|
|
|
2704
2889
|
pageInboxCount,
|
|
2705
2890
|
);
|
|
2706
2891
|
} catch (err) {
|
|
2707
|
-
scheduleBatchRetry(err, group
|
|
2892
|
+
await scheduleBatchRetry(err, group);
|
|
2708
2893
|
break;
|
|
2709
2894
|
}
|
|
2710
2895
|
const batchSpawned = batch.entries.some((entry) => (
|
|
@@ -2742,7 +2927,10 @@ export const performRun = ({
|
|
|
2742
2927
|
}
|
|
2743
2928
|
}
|
|
2744
2929
|
if (batch.deferredError) {
|
|
2745
|
-
scheduleBatchRetry(
|
|
2930
|
+
await scheduleBatchRetry(
|
|
2931
|
+
batch.deferredError,
|
|
2932
|
+
batch.entries.filter((entry) => !entry.result).map((entry) => entry.event),
|
|
2933
|
+
);
|
|
2746
2934
|
break;
|
|
2747
2935
|
}
|
|
2748
2936
|
continue;
|
|
@@ -2775,12 +2963,16 @@ export const performRun = ({
|
|
|
2775
2963
|
jitterRatio: spawnJitterRatio,
|
|
2776
2964
|
});
|
|
2777
2965
|
nextPollDelayMs = retry.delayMs;
|
|
2778
|
-
|
|
2779
|
-
|
|
2966
|
+
retryScheduled = true;
|
|
2967
|
+
// Carried to the retry like a failed batch: the event is claimed,
|
|
2968
|
+
// so the next poll would not return it.
|
|
2969
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2970
|
+
const hold = await holdForRetry([event], untriedAfter(groupIndex));
|
|
2971
|
+
keepHeldLeases(retry.delayMs);
|
|
2780
2972
|
const wrapped = new Error(
|
|
2781
2973
|
`${event.type} processing failed (${retry.failureClass}; `
|
|
2782
|
-
+ `${consecutiveSpawnFailures} consecutive) — event ${event._id}
|
|
2783
|
-
+ `${
|
|
2974
|
+
+ `${consecutiveSpawnFailures} consecutive) — event ${event._id} unacked; `
|
|
2975
|
+
+ `${describeHold(hold, retry)}: ${err.message}`,
|
|
2784
2976
|
{ cause: err },
|
|
2785
2977
|
);
|
|
2786
2978
|
Object.assign(wrapped, {
|
|
@@ -2789,6 +2981,8 @@ export const performRun = ({
|
|
|
2789
2981
|
consecutiveFailures: consecutiveSpawnFailures,
|
|
2790
2982
|
retryAfterMs: retry.delayMs,
|
|
2791
2983
|
circuitOpen: retry.circuitOpen,
|
|
2984
|
+
heldCount: hold.held.length,
|
|
2985
|
+
releasedCount: hold.released.length,
|
|
2792
2986
|
eventId: event._id,
|
|
2793
2987
|
...(isPodFocusDiagnostic(err) ? {
|
|
2794
2988
|
focusDiagnostic: {
|
|
@@ -2894,12 +3088,19 @@ export const performRun = ({
|
|
|
2894
3088
|
onError?.(err);
|
|
2895
3089
|
}
|
|
2896
3090
|
// Notices landing during a turn share one three-second collection window
|
|
2897
|
-
// before the next inbox read, independent of the idle polling cadence.
|
|
2898
|
-
|
|
3091
|
+
// before the next inbox read, independent of the idle polling cadence. A
|
|
3092
|
+
// retry scheduled in the same tick keeps its own delay: the failure report
|
|
3093
|
+
// names it, and a sibling's completed turn must not shorten the backoff.
|
|
3094
|
+
if (running) setTimeoutImpl(tick, completedModelTurn && !retryScheduled ? 3000 : nextPollDelayMs);
|
|
2899
3095
|
};
|
|
2900
3096
|
|
|
2901
3097
|
tick();
|
|
2902
|
-
return {
|
|
3098
|
+
return {
|
|
3099
|
+
stop: () => {
|
|
3100
|
+
running = false;
|
|
3101
|
+
clearHeldRenewal();
|
|
3102
|
+
},
|
|
3103
|
+
};
|
|
2903
3104
|
};
|
|
2904
3105
|
|
|
2905
3106
|
// ── init: scaffold a webhook-SDK agent (ADR-006 §Scaffolding) ───────────────
|