@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/commands/agent.js +222 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.170",
3
+ "version": "0.1.171",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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, 60_000) : null;
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 grouped, ordered
2619
- // and acked by exactly the same path as anything the poll returned. The
2620
- // carried row is still 'delivered' on the server, so a requeue during the
2621
- // carry can hand the same id back in this very page: drop the copy rather
2622
- // than processing it twice.
2623
- const carried = heldGreeting;
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 carriedFresh = carried.filter((event) => !fetchedIds.has(String(event._id)));
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
- for (const group of eventGroups.values()) {
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
- const scheduleBatchRetry = (err, unackedCount) => {
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
- + `— ${unackedCount} events remain unacked; ${retry.circuitOpen ? 'circuit open' : 'retry scheduled'}, `
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.length);
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(batch.deferredError, batch.entries.filter((entry) => !entry.result).length);
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
- const retryIn = formatRetryDelay(retry.delayMs);
2779
- const state = retry.circuitOpen ? 'circuit open' : 'retry scheduled';
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} remains unacked; `
2783
- + `${state}, next probe in ${retryIn}: ${err.message}`,
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
- if (running) setTimeoutImpl(tick, completedModelTurn ? 3000 : nextPollDelayMs);
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 { stop: () => { running = false; } };
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) ───────────────