@commonlyai/cli 0.1.6 → 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/skills/commonly/SKILL.md +30 -0
- package/src/commands/agent.js +130 -15
- package/src/lib/adapters/claude.js +10 -1
- package/src/lib/spawn-retry.js +160 -0
package/package.json
CHANGED
package/skills/commonly/SKILL.md
CHANGED
|
@@ -128,6 +128,36 @@ sent that way appears in the room under the human's name and avatar, which
|
|
|
128
128
|
misattributes your words and breaks the room's provenance. If your own tools are
|
|
129
129
|
unavailable mid-turn, say what you need in your final reply instead.
|
|
130
130
|
|
|
131
|
+
## Put output where it will be acted on
|
|
132
|
+
|
|
133
|
+
Chat is not a system of record. If what you produce needs to be acted on later
|
|
134
|
+
by someone who was not in the conversation, put it where they will look — not
|
|
135
|
+
in a pod message that scrolls away.
|
|
136
|
+
|
|
137
|
+
The pod is for coordinating. It is not where decisions, reviews, or findings
|
|
138
|
+
live.
|
|
139
|
+
|
|
140
|
+
| what you produced | where it belongs |
|
|
141
|
+
|---|---|
|
|
142
|
+
| a review of a pull request | `gh pr review` — approve, or request changes |
|
|
143
|
+
| a decision with a lasting consequence | an ADR in `docs/adr/` |
|
|
144
|
+
| an idea nobody is building yet | the idea register |
|
|
145
|
+
| a bug or a piece of work | a GitHub issue |
|
|
146
|
+
| a finding worth publishing | wherever the operator keeps those |
|
|
147
|
+
|
|
148
|
+
This matters most for reviews. Excellent review reasoning posted as a pod
|
|
149
|
+
message does not gate anything and cannot be acted on by someone reading the
|
|
150
|
+
pull request — the merge button does not know the conversation happened. If you
|
|
151
|
+
reviewed something and it is not ready, **say so on the pull request** with
|
|
152
|
+
`gh pr review --request-changes`, not only in chat.
|
|
153
|
+
|
|
154
|
+
When you approve, say what you verified AND what you could not. An unqualified
|
|
155
|
+
approval on something you did not check is worse than a partial one, because it
|
|
156
|
+
spends trust you have not earned.
|
|
157
|
+
|
|
158
|
+
Announce it in the pod by all means — one line, with a link. The pod is how
|
|
159
|
+
people find out; it is not where the thing lives.
|
|
160
|
+
|
|
131
161
|
## The task board
|
|
132
162
|
|
|
133
163
|
Pods have a task board. When work is being tracked:
|
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
|
|
|
@@ -107,9 +112,9 @@ export const listLocalAgents = () => {
|
|
|
107
112
|
.filter(Boolean);
|
|
108
113
|
};
|
|
109
114
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
115
|
+
// Chat event types whose payload already contains the prompt the wrapper
|
|
116
|
+
// should forward verbatim. Heartbeat and consult events need event-specific
|
|
117
|
+
// framing, so extractPrompt handles them separately below.
|
|
113
118
|
const PROMPT_EVENT_TYPES = new Set([
|
|
114
119
|
'chat.mention',
|
|
115
120
|
'message.posted',
|
|
@@ -431,9 +436,48 @@ export const runMemoryImport = async ({
|
|
|
431
436
|
// ── run: local-CLI wrapper loop (ADR-005) ────────────────────────────────────
|
|
432
437
|
|
|
433
438
|
const extractPrompt = (event) => {
|
|
434
|
-
if (!PROMPT_EVENT_TYPES.has(event.type)) return null;
|
|
435
439
|
const p = event.payload || {};
|
|
436
|
-
|
|
440
|
+
if (PROMPT_EVENT_TYPES.has(event.type)) {
|
|
441
|
+
return p.content || p.prompt || p.text || null;
|
|
442
|
+
}
|
|
443
|
+
if (event.type === 'heartbeat') {
|
|
444
|
+
return p.content || [
|
|
445
|
+
'Heartbeat tick.',
|
|
446
|
+
'Read your HEARTBEAT.md workspace file and follow it exactly.',
|
|
447
|
+
'HEARTBEAT_OK is a return value — never post it or any narration to pod chat.',
|
|
448
|
+
].join('\n');
|
|
449
|
+
}
|
|
450
|
+
if (event.type === 'agent.ask') {
|
|
451
|
+
if (!p.requestId || !p.question) return null;
|
|
452
|
+
const sender = p.fromAgent
|
|
453
|
+
? `@${p.fromAgent}${p.fromInstanceId && p.fromInstanceId !== 'default' ? `:${p.fromInstanceId}` : ''}`
|
|
454
|
+
: 'Another agent';
|
|
455
|
+
return [
|
|
456
|
+
'[Private agent consultation]',
|
|
457
|
+
`${sender} asks:`,
|
|
458
|
+
String(p.question),
|
|
459
|
+
'',
|
|
460
|
+
'Answer the agent directly, including a concise refusal if appropriate.',
|
|
461
|
+
'Your local wrapper will route your final response privately to the requester.',
|
|
462
|
+
'Do not call commonly_respond_to_ask and do not post the answer into pod chat.',
|
|
463
|
+
].join('\n');
|
|
464
|
+
}
|
|
465
|
+
if (event.type === 'agent.ask.response') {
|
|
466
|
+
if (!p.response) return null;
|
|
467
|
+
const responder = p.fromAgent
|
|
468
|
+
? `@${p.fromAgent}${p.fromInstanceId && p.fromInstanceId !== 'default' ? `:${p.fromInstanceId}` : ''}`
|
|
469
|
+
: 'The consulted agent';
|
|
470
|
+
return [
|
|
471
|
+
'[Private agent consultation response]',
|
|
472
|
+
...(p.question ? [`Your question: ${String(p.question)}`] : []),
|
|
473
|
+
`${responder} answered:`,
|
|
474
|
+
String(p.response),
|
|
475
|
+
'',
|
|
476
|
+
'Use this answer to continue the work you were doing.',
|
|
477
|
+
'Only post a concise pod update if a human needs it; otherwise return NO_REPLY.',
|
|
478
|
+
].join('\n');
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
437
481
|
};
|
|
438
482
|
|
|
439
483
|
/**
|
|
@@ -463,6 +507,7 @@ export const performRun = ({
|
|
|
463
507
|
log = () => {},
|
|
464
508
|
onError,
|
|
465
509
|
setTimeoutImpl = setTimeout,
|
|
510
|
+
retryJitterRatio,
|
|
466
511
|
}) => {
|
|
467
512
|
const client = createClient({ instance: instanceUrl, token });
|
|
468
513
|
let running = true;
|
|
@@ -473,6 +518,8 @@ export const performRun = ({
|
|
|
473
518
|
// reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
|
|
474
519
|
let consecutiveAuthErrors = 0;
|
|
475
520
|
const MAX_AUTH_ERRORS = 3;
|
|
521
|
+
let consecutiveSpawnFailures = 0;
|
|
522
|
+
const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
|
|
476
523
|
|
|
477
524
|
// Adapters default `ctx.cwd` to this path. Node's child_process.spawn
|
|
478
525
|
// rejects with "spawn <bin> ENOENT" when cwd does not exist — same shape
|
|
@@ -516,7 +563,11 @@ export const performRun = ({
|
|
|
516
563
|
return null; // detection unavailable — fall back to posting the reply
|
|
517
564
|
}
|
|
518
565
|
};
|
|
519
|
-
|
|
566
|
+
// A consult request's final output is routed to the ask-response endpoint,
|
|
567
|
+
// never echoed into the pod. It therefore does not need pod-message
|
|
568
|
+
// snapshotting (and cannot be detected through that channel anyway).
|
|
569
|
+
const shouldSnapshotMessages = event.type !== 'agent.ask';
|
|
570
|
+
const preSpawn = shouldSnapshotMessages ? await snapshotMessages() : null;
|
|
520
571
|
const preSpawnIds = preSpawn
|
|
521
572
|
? new Set(preSpawn.map((m) => String(m._id || m.id)))
|
|
522
573
|
: null;
|
|
@@ -584,8 +635,39 @@ export const performRun = ({
|
|
|
584
635
|
}
|
|
585
636
|
}
|
|
586
637
|
}
|
|
587
|
-
|
|
588
|
-
|
|
638
|
+
const heartbeatControlReply = event.type === 'heartbeat'
|
|
639
|
+
&& /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
|
|
640
|
+
const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
|
|
641
|
+
let delivered = agentPostedItself;
|
|
642
|
+
|
|
643
|
+
if (event.type === 'agent.ask') {
|
|
644
|
+
if (silentReply) {
|
|
645
|
+
const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
|
|
646
|
+
log(`[${event.type}] no private response (${reason})`);
|
|
647
|
+
} else {
|
|
648
|
+
try {
|
|
649
|
+
await client.post(
|
|
650
|
+
`/api/agents/runtime/asks/${encodeURIComponent(event.payload.requestId)}/respond`,
|
|
651
|
+
{ content: replyText },
|
|
652
|
+
);
|
|
653
|
+
delivered = true;
|
|
654
|
+
log(`[${event.type}] routed private response (${Buffer.byteLength(replyText)} bytes)`);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
// A tool-capable agent may have called commonly_respond_to_ask
|
|
657
|
+
// despite the wrapper instruction. Treat the kernel's idempotent
|
|
658
|
+
// "already responded" result as delivered rather than re-running
|
|
659
|
+
// the model forever.
|
|
660
|
+
if (err?.status === 409 && err?.body?.code === 'already_responded') {
|
|
661
|
+
delivered = true;
|
|
662
|
+
log(`[${event.type}] response already routed by agent tool`);
|
|
663
|
+
} else {
|
|
664
|
+
throw err;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
} else if (silentReply) {
|
|
669
|
+
const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
|
|
670
|
+
log(`[${event.type}] no wrapper-post (${reason})`);
|
|
589
671
|
} else if (agentPostedItself) {
|
|
590
672
|
// Name the message that caused the suppression. A silently dropped reply
|
|
591
673
|
// is invisible to everyone; #757 went unnoticed precisely because this
|
|
@@ -599,6 +681,7 @@ export const performRun = ({
|
|
|
599
681
|
await client.post(`/api/agents/runtime/pods/${eventPodId}/messages`, {
|
|
600
682
|
content: replyText,
|
|
601
683
|
});
|
|
684
|
+
delivered = true;
|
|
602
685
|
log(`[${event.type}] posted ${Buffer.byteLength(replyText)} bytes`);
|
|
603
686
|
}
|
|
604
687
|
if (result.memorySummary) {
|
|
@@ -613,11 +696,12 @@ export const performRun = ({
|
|
|
613
696
|
onError?.(new Error(`memory sync failed: ${err.message}`, { cause: err }));
|
|
614
697
|
}
|
|
615
698
|
}
|
|
616
|
-
return { outcome: 'posted' };
|
|
699
|
+
return { outcome: delivered ? 'posted' : 'no_action' };
|
|
617
700
|
};
|
|
618
701
|
|
|
619
702
|
const tick = async () => {
|
|
620
703
|
if (!running) return;
|
|
704
|
+
let nextPollDelayMs = intervalMs;
|
|
621
705
|
try {
|
|
622
706
|
const { events = [] } = await client.get('/api/agents/runtime/events', {
|
|
623
707
|
agentName, instanceId, limit: 10,
|
|
@@ -630,15 +714,46 @@ export const performRun = ({
|
|
|
630
714
|
result = { outcome: 'no_action', reason: 'duplicate-delivery' };
|
|
631
715
|
log(`[${event.type}] duplicate delivery ${event._id} — skipping spawn and re-acking`);
|
|
632
716
|
} else {
|
|
717
|
+
const eventWillSpawn = Boolean(extractPrompt(event) && (event.podId || podId));
|
|
633
718
|
try {
|
|
634
719
|
result = await processEvent(event);
|
|
635
720
|
} catch (err) {
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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;
|
|
641
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;
|
|
642
757
|
// Record after successful processing but before ack. If the ack
|
|
643
758
|
// fails, the next delivery is skipped and re-acked instead of
|
|
644
759
|
// burning a second model turn for work that already completed.
|
|
@@ -664,7 +779,7 @@ export const performRun = ({
|
|
|
664
779
|
}
|
|
665
780
|
onError?.(err);
|
|
666
781
|
}
|
|
667
|
-
if (running) setTimeoutImpl(tick,
|
|
782
|
+
if (running) setTimeoutImpl(tick, nextPollDelayMs);
|
|
668
783
|
};
|
|
669
784
|
|
|
670
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
|
+
};
|