@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 +1 -1
- package/src/commands/agent.js +46 -6
- package/src/lib/adapters/claude.js +10 -1
- package/src/lib/spawn-retry.js +160 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -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
|
-
//
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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,
|
|
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)
|
|
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
|
+
};
|