@commonlyai/cli 0.1.7 → 0.1.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -34,6 +34,11 @@ import { detectSkills, importSkills } from '../lib/skills-import.js';
34
34
  import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
35
35
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
36
36
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
37
+ import {
38
+ formatRetryDelay,
39
+ spawnRetryJitter,
40
+ spawnRetryPolicy,
41
+ } from '../lib/spawn-retry.js';
37
42
 
38
43
  // ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
39
44
 
@@ -502,6 +507,7 @@ export const performRun = ({
502
507
  log = () => {},
503
508
  onError,
504
509
  setTimeoutImpl = setTimeout,
510
+ retryJitterRatio,
505
511
  }) => {
506
512
  const client = createClient({ instance: instanceUrl, token });
507
513
  let running = true;
@@ -512,6 +518,8 @@ export const performRun = ({
512
518
  // reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
513
519
  let consecutiveAuthErrors = 0;
514
520
  const MAX_AUTH_ERRORS = 3;
521
+ let consecutiveSpawnFailures = 0;
522
+ const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
515
523
 
516
524
  // Adapters default `ctx.cwd` to this path. Node's child_process.spawn
517
525
  // rejects with "spawn <bin> ENOENT" when cwd does not exist — same shape
@@ -693,6 +701,7 @@ export const performRun = ({
693
701
 
694
702
  const tick = async () => {
695
703
  if (!running) return;
704
+ let nextPollDelayMs = intervalMs;
696
705
  try {
697
706
  const { events = [] } = await client.get('/api/agents/runtime/events', {
698
707
  agentName, instanceId, limit: 10,
@@ -705,15 +714,46 @@ export const performRun = ({
705
714
  result = { outcome: 'no_action', reason: 'duplicate-delivery' };
706
715
  log(`[${event.type}] duplicate delivery ${event._id} — skipping spawn and re-acking`);
707
716
  } else {
717
+ const eventWillSpawn = Boolean(extractPrompt(event) && (event.podId || podId));
708
718
  try {
709
719
  result = await processEvent(event);
710
720
  } catch (err) {
711
- // Spawn failed — do not record or ack, so the kernel can re-deliver
712
- // the event after the local runtime recovers (ADR-005).
713
- log(`[${event.type}] spawn error: ${err.message}`);
714
- onError?.(err);
715
- continue;
721
+ // Do not record or ack: the kernel must retain the event for
722
+ // at-least-once delivery. Stop this fetched batch immediately
723
+ // continuing could launch every one of the 10 returned events
724
+ // into the same provider outage before the next poll (#782).
725
+ consecutiveSpawnFailures += 1;
726
+ const retry = spawnRetryPolicy({
727
+ error: err,
728
+ consecutiveFailures: consecutiveSpawnFailures,
729
+ intervalMs,
730
+ jitterRatio: spawnJitterRatio,
731
+ });
732
+ nextPollDelayMs = retry.delayMs;
733
+ const retryIn = formatRetryDelay(retry.delayMs);
734
+ const state = retry.circuitOpen ? 'circuit open' : 'retry scheduled';
735
+ const wrapped = new Error(
736
+ `${event.type} processing failed (${retry.failureClass}; `
737
+ + `${consecutiveSpawnFailures} consecutive) — event ${event._id} remains unacked; `
738
+ + `${state}, next probe in ${retryIn}: ${err.message}`,
739
+ { cause: err },
740
+ );
741
+ Object.assign(wrapped, {
742
+ code: 'agent_spawn_retry_scheduled',
743
+ failureClass: retry.failureClass,
744
+ consecutiveFailures: consecutiveSpawnFailures,
745
+ retryAfterMs: retry.delayMs,
746
+ circuitOpen: retry.circuitOpen,
747
+ eventId: event._id,
748
+ });
749
+ log(`[${event.type}] ${wrapped.message}`);
750
+ onError?.(wrapped);
751
+ break;
716
752
  }
753
+ // Only a completed model turn proves the local runtime and delivery
754
+ // path recovered. A malformed/no-destination event is still acked,
755
+ // but must not erase the failure streak without exercising either.
756
+ if (eventWillSpawn) consecutiveSpawnFailures = 0;
717
757
  // Record after successful processing but before ack. If the ack
718
758
  // fails, the next delivery is skipped and re-acked instead of
719
759
  // burning a second model turn for work that already completed.
@@ -739,7 +779,7 @@ export const performRun = ({
739
779
  }
740
780
  onError?.(err);
741
781
  }
742
- if (running) setTimeoutImpl(tick, intervalMs);
782
+ if (running) setTimeoutImpl(tick, nextPollDelayMs);
743
783
  };
744
784
 
745
785
  tick();
@@ -244,7 +244,16 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
244
244
  proc.on('close', (code) => {
245
245
  clearTimeout(timer);
246
246
  if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
247
- if (code !== 0) return reject(new Error(`claude exited with code ${code}: ${stderr.trim()}`));
247
+ if (code !== 0) {
248
+ // Report stdout too, not just stderr. In `-p` mode claude writes terminal
249
+ // conditions (usage limits especially) to stdout and exits non-zero with
250
+ // stderr empty — 361 consecutive failures on 2026-08-03 carried no reason
251
+ // at all because of this. It is not only a diagnosability problem: the
252
+ // circuit breaker classifies from the error message, so a blank message
253
+ // downgrades a hard quota failure to RUNTIME and its shortest backoff.
254
+ const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join(' | ');
255
+ return reject(new Error(`claude exited with code ${code}: ${detail.slice(0, 2000)}`));
256
+ }
248
257
  resolve(stdout);
249
258
  });
250
259
  });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Retry policy for local wrapper event-processing failures (#782).
3
+ *
4
+ * The kernel deliberately re-delivers an event that the wrapper does not
5
+ * acknowledge. That preserves at-least-once handling, but a flat poll loop
6
+ * turns a model-provider outage into repeated subprocess launches. Keep the
7
+ * retry policy here so every adapter gets the same bounded behavior.
8
+ */
9
+
10
+ export const SPAWN_FAILURE_CLASS = Object.freeze({
11
+ QUOTA: 'quota',
12
+ RATE_LIMIT: 'rate_limit',
13
+ CONFIGURATION: 'configuration',
14
+ RUNTIME: 'runtime',
15
+ });
16
+
17
+ export const SPAWN_CIRCUIT_THRESHOLD = 3;
18
+ // Base ceiling before the stable per-agent 0–20% anti-herd offset.
19
+ export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
20
+ export const SPAWN_RETRY_JITTER_MAX_RATIO = 0.2;
21
+
22
+ // `out of credits` is codex's exact wording for an exhausted workspace balance
23
+ // ("Your workspace is out of credits. Ask your workspace owner to refill…").
24
+ // Without it that outage classified as RUNTIME and drew the shortest backoff —
25
+ // observed live on 2026-08-03 before this pattern was added.
26
+ const QUOTA_RE = /(?:quota|usage limit|credit balance|out of credits|billing|insufficient[_ -]?quota|resource exhausted|spending limit)/i;
27
+ const RATE_LIMIT_RE = /(?:rate[ -]?limit|too many requests|\b429\b|overloaded|capacity)/i;
28
+ const CONFIGURATION_RE = /(?:ENOENT|command not found|not on PATH|login required|not logged in|invalid api key|authentication failed|unauthori[sz]ed|forbidden|\b40[13]\b)/i;
29
+
30
+ const errorText = (error) => [
31
+ error?.message,
32
+ error?.stderr,
33
+ error?.body?.error,
34
+ error?.body?.message,
35
+ ]
36
+ .filter(Boolean)
37
+ .map(String)
38
+ .join('\n');
39
+
40
+ export const classifySpawnFailure = (error) => {
41
+ const text = errorText(error);
42
+ // Provider APIs commonly report an exhausted account quota as HTTP 429.
43
+ // Prefer the more specific body/message over the generic status code so a
44
+ // hard quota failure gets the long cooldown rather than a one-minute probe.
45
+ if (QUOTA_RE.test(text)) return SPAWN_FAILURE_CLASS.QUOTA;
46
+ if (error?.status === 429 || RATE_LIMIT_RE.test(text)) {
47
+ return SPAWN_FAILURE_CLASS.RATE_LIMIT;
48
+ }
49
+ if (
50
+ error?.code === 'ENOENT'
51
+ || error?.status === 401
52
+ || error?.status === 403
53
+ || CONFIGURATION_RE.test(text)
54
+ ) {
55
+ return SPAWN_FAILURE_CLASS.CONFIGURATION;
56
+ }
57
+ return SPAWN_FAILURE_CLASS.RUNTIME;
58
+ };
59
+
60
+ // A stable per-agent offset keeps a fleet from probing a recovering provider
61
+ // in lockstep. Stability matters: random jitter makes operator logs and tests
62
+ // harder to reason about, while distinct agent names already provide entropy.
63
+ export const spawnRetryJitter = (agentName) => {
64
+ let hash = 2166136261;
65
+ for (const char of String(agentName || 'agent')) {
66
+ hash ^= char.charCodeAt(0);
67
+ hash = Math.imul(hash, 16777619);
68
+ }
69
+ return ((hash >>> 0) % 2001) / 10000;
70
+ };
71
+
72
+ const applyJitter = (delayMs, jitterRatio) => {
73
+ const safeJitter = Number.isFinite(jitterRatio)
74
+ ? Math.min(SPAWN_RETRY_JITTER_MAX_RATIO, Math.max(0, jitterRatio))
75
+ : 0;
76
+ return Math.round(delayMs * (1 + safeJitter));
77
+ };
78
+
79
+ /**
80
+ * Return the next probe delay and whether the circuit is open.
81
+ *
82
+ * Known non-transient failures open immediately. Unknown runtime failures get
83
+ * two quick retries, then open the circuit on the third consecutive failure.
84
+ * Later probes back off exponentially to the same 15-minute base ceiling.
85
+ */
86
+ export const spawnRetryPolicy = ({
87
+ error,
88
+ consecutiveFailures,
89
+ intervalMs,
90
+ jitterRatio = 0,
91
+ }) => {
92
+ const failureClass = classifySpawnFailure(error);
93
+ const safeIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 5000;
94
+ const failureCount = Number.isInteger(consecutiveFailures) && consecutiveFailures > 0
95
+ ? consecutiveFailures
96
+ : 1;
97
+
98
+ if (
99
+ failureClass === SPAWN_FAILURE_CLASS.QUOTA
100
+ || failureClass === SPAWN_FAILURE_CLASS.CONFIGURATION
101
+ ) {
102
+ return {
103
+ failureClass,
104
+ circuitOpen: true,
105
+ delayMs: applyJitter(SPAWN_RETRY_MAX_MS, jitterRatio),
106
+ };
107
+ }
108
+
109
+ if (failureClass === SPAWN_FAILURE_CLASS.RATE_LIMIT) {
110
+ return {
111
+ failureClass,
112
+ circuitOpen: true,
113
+ delayMs: applyJitter(
114
+ Math.min(
115
+ SPAWN_RETRY_MAX_MS,
116
+ 60 * 1000 * (2 ** (failureCount - 1)),
117
+ ),
118
+ jitterRatio,
119
+ ),
120
+ };
121
+ }
122
+
123
+ if (failureCount < SPAWN_CIRCUIT_THRESHOLD) {
124
+ return {
125
+ failureClass,
126
+ circuitOpen: false,
127
+ delayMs: applyJitter(
128
+ Math.min(
129
+ SPAWN_RETRY_MAX_MS,
130
+ safeIntervalMs * (2 ** (failureCount - 1)),
131
+ ),
132
+ jitterRatio,
133
+ ),
134
+ };
135
+ }
136
+
137
+ return {
138
+ failureClass,
139
+ circuitOpen: true,
140
+ delayMs: applyJitter(
141
+ Math.min(
142
+ SPAWN_RETRY_MAX_MS,
143
+ 60 * 1000 * (2 ** (failureCount - SPAWN_CIRCUIT_THRESHOLD)),
144
+ ),
145
+ jitterRatio,
146
+ ),
147
+ };
148
+ };
149
+
150
+ export const formatRetryDelay = (delayMs) => {
151
+ if (delayMs >= 60000) {
152
+ const minutes = delayMs / 60000;
153
+ return `${Number.isInteger(minutes) ? minutes : minutes.toFixed(1)}m`;
154
+ }
155
+ if (delayMs >= 1000) {
156
+ const seconds = delayMs / 1000;
157
+ return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)}s`;
158
+ }
159
+ return `${delayMs}ms`;
160
+ };