@commonlyai/cli 0.1.15 → 0.1.18
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 +64 -3
- package/src/lib/poll-retry.js +81 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
recordHandledEvent,
|
|
30
30
|
} from '../lib/session-store.js';
|
|
31
31
|
import { readLongTerm, syncBack } from '../lib/memory-bridge.js';
|
|
32
|
+
import { pollRetryPolicy } from '../lib/poll-retry.js';
|
|
32
33
|
import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
|
|
33
34
|
import { detectSkills, importSkills } from '../lib/skills-import.js';
|
|
34
35
|
import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
|
|
@@ -732,6 +733,7 @@ export const performRun = ({
|
|
|
732
733
|
// reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
|
|
733
734
|
let consecutiveAuthErrors = 0;
|
|
734
735
|
const MAX_AUTH_ERRORS = 3;
|
|
736
|
+
let consecutivePollFailures = 0;
|
|
735
737
|
let consecutiveSpawnFailures = 0;
|
|
736
738
|
const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
|
|
737
739
|
// Per-seat cascade state — lives with the process, like the session store.
|
|
@@ -945,10 +947,20 @@ export const performRun = ({
|
|
|
945
947
|
});
|
|
946
948
|
const claim = await claimKeeper.acquire();
|
|
947
949
|
if (!claim.claimed && !claim.failOpen) {
|
|
948
|
-
|
|
950
|
+
// Per-event EVIDENCE, deliberately not a widening of
|
|
951
|
+
// ADDRESSED_EVENT_TYPES (that set is a pricing table): the backend
|
|
952
|
+
// stamps repliesToYourMessage when the woken message replies to or
|
|
953
|
+
// threads on a message THIS seat authored. Without it, a peer's
|
|
954
|
+
// claim on its own reply ordered the replied-to author out of its
|
|
955
|
+
// own conversation (Sage stood down twice on Anvil's thread
|
|
956
|
+
// replies, 2026-08-24). Interim for TASK-058.
|
|
957
|
+
const repliesToThisSeat = event.payload?.repliesToYourMessage === true;
|
|
958
|
+
if (ADDRESSED_EVENT_TYPES.has(event.type) || repliesToThisSeat) {
|
|
949
959
|
log(
|
|
950
960
|
`[${event.type}] message ${claimMessageId} held by ${claim.holder} — `
|
|
951
|
-
+
|
|
961
|
+
+ (repliesToThisSeat
|
|
962
|
+
? 'proceeding peer-aware (the message replies to this seat\'s own message)'
|
|
963
|
+
: 'proceeding peer-aware (this seat was directly addressed)'),
|
|
952
964
|
);
|
|
953
965
|
peerFrame = peerHoldsFrame(claim.holder, claimMessageId);
|
|
954
966
|
claimKeeper = null; // nothing held: no renewal, no release, no isLost gate
|
|
@@ -1171,10 +1183,32 @@ export const performRun = ({
|
|
|
1171
1183
|
if (!running) return;
|
|
1172
1184
|
let nextPollDelayMs = intervalMs;
|
|
1173
1185
|
try {
|
|
1186
|
+
// ONE event per fetch, because the fetch IS the claim.
|
|
1187
|
+
//
|
|
1188
|
+
// `AgentEventService.list()` does not hand back a preview — it marks
|
|
1189
|
+
// every candidate `delivered` with `$inc: { attempts: 1 }` before
|
|
1190
|
+
// returning. This loop then processes them SERIALLY, one full model
|
|
1191
|
+
// turn each. So asking for 10 claims 10 and starts 1.
|
|
1192
|
+
//
|
|
1193
|
+
// The nine it cannot start are then reclaimed out from under it: the
|
|
1194
|
+
// backend requeues `delivered` rows older than
|
|
1195
|
+
// `requeueDeliveredMinutes` (default 10, swept on `*/10`), and turns
|
|
1196
|
+
// routinely outlast that — measured on the pod-architect seat over
|
|
1197
|
+
// 11.5h: median 128s, p90 669s, 13 turns over 600s, max 1153s. Each
|
|
1198
|
+
// sweep returns the untouched siblings to `pending` at `attempts + 1`,
|
|
1199
|
+
// and `attempts >= 3` retires an event to `failed`, which is terminal
|
|
1200
|
+
// and invisible to `list()`. That cap exists to bound POISON events;
|
|
1201
|
+
// over-claiming feeds it work no model ever saw, so a mention can be
|
|
1202
|
+
// dropped without once being read.
|
|
1203
|
+
//
|
|
1204
|
+
// `limit: 1` costs nothing: capacity here is one turn at a time
|
|
1205
|
+
// regardless, and the poll interval is 5s. It only stops the loop
|
|
1206
|
+
// claiming work it has no way to begin.
|
|
1174
1207
|
const { events = [] } = await client.get('/api/agents/runtime/events', {
|
|
1175
|
-
agentName, instanceId, limit:
|
|
1208
|
+
agentName, instanceId, limit: 1,
|
|
1176
1209
|
});
|
|
1177
1210
|
consecutiveAuthErrors = 0;
|
|
1211
|
+
consecutivePollFailures = 0;
|
|
1178
1212
|
for (const event of events) {
|
|
1179
1213
|
if (!running) break;
|
|
1180
1214
|
let result;
|
|
@@ -1265,6 +1299,33 @@ export const performRun = ({
|
|
|
1265
1299
|
running = false;
|
|
1266
1300
|
return;
|
|
1267
1301
|
}
|
|
1302
|
+
} else {
|
|
1303
|
+
// TASK-025: everything that is not an auth rejection used to fall
|
|
1304
|
+
// through to `onError` with `nextPollDelayMs` still at `intervalMs`,
|
|
1305
|
+
// because that variable is only reassigned in the spawn-retry branch
|
|
1306
|
+
// above and a failed fetch never reaches it. So a network outage
|
|
1307
|
+
// retried flat at the poll interval, forever, with one indistinct
|
|
1308
|
+
// line per attempt — 797 consecutive `fetch failed` across three
|
|
1309
|
+
// seats, ~66 minutes, and nothing that read as an outage.
|
|
1310
|
+
//
|
|
1311
|
+
// Deliberately NOT a stop. `MAX_AUTH_ERRORS` halts because a rejected
|
|
1312
|
+
// token cannot heal itself; a network failure usually can, and a seat
|
|
1313
|
+
// that stops on one is dead until a human notices.
|
|
1314
|
+
consecutivePollFailures += 1;
|
|
1315
|
+
const retry = pollRetryPolicy({
|
|
1316
|
+
consecutiveFailures: consecutivePollFailures,
|
|
1317
|
+
intervalMs,
|
|
1318
|
+
jitterRatio: spawnJitterRatio,
|
|
1319
|
+
});
|
|
1320
|
+
nextPollDelayMs = retry.delayMs;
|
|
1321
|
+
if (retry.escalate) {
|
|
1322
|
+
log(
|
|
1323
|
+
`poll failed ${consecutivePollFailures}x in a row `
|
|
1324
|
+
+ `(${err?.message || 'unknown error'}) — backing off to `
|
|
1325
|
+
+ `${formatRetryDelay(retry.delayMs)}`
|
|
1326
|
+
+ `${retry.atCeiling ? ', at the ceiling' : ''}`,
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1268
1329
|
}
|
|
1269
1330
|
onError?.(err);
|
|
1270
1331
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry policy for the run loop's own FETCH failures (TASK-025).
|
|
3
|
+
*
|
|
4
|
+
* `spawn-retry.js` bounds failures of the subprocess a poll produces. This
|
|
5
|
+
* bounds failures of the poll itself — the request to
|
|
6
|
+
* `/api/agents/runtime/events` that has to succeed before there is anything to
|
|
7
|
+
* spawn. They are different failures with different remedies and, until now,
|
|
8
|
+
* only one of them had a policy.
|
|
9
|
+
*
|
|
10
|
+
* The gap, measured: `agent run`'s tick set `nextPollDelayMs = intervalMs` and
|
|
11
|
+
* only ever reassigned it inside the spawn-retry branch. A fetch that threw
|
|
12
|
+
* never reached that branch, so a network failure retried at a flat 5s
|
|
13
|
+
* forever — no backoff, no ceiling, and no escalation. 797 consecutive
|
|
14
|
+
* `fetch failed` ran across three seats that way: ~66 minutes at 5s, and the
|
|
15
|
+
* only trace was one `onError` line per attempt in a log nobody was tailing.
|
|
16
|
+
*
|
|
17
|
+
* Two deliberate differences from the auth path directly above it:
|
|
18
|
+
*
|
|
19
|
+
* 1. NO STOP. `MAX_AUTH_ERRORS` halts the loop because a rejected token does
|
|
20
|
+
* not heal on its own — retrying is pure cost and the operator must act.
|
|
21
|
+
* A network failure is usually transient, and a seat that stops on one is
|
|
22
|
+
* dead until someone notices. So this backs off and stays alive.
|
|
23
|
+
*
|
|
24
|
+
* 2. ESCALATION IS THE POINT. The harm in the measured outage was not the
|
|
25
|
+
* retry rate, it was that 797 failures produced no signal distinguishable
|
|
26
|
+
* from one failure. `escalate` fires on a small set of thresholds so a
|
|
27
|
+
* sustained outage announces itself at widening intervals instead of
|
|
28
|
+
* disappearing into per-attempt noise.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// Same ceiling as the spawn path: past this, more waiting buys nothing and a
|
|
32
|
+
// human is the only thing that resolves it.
|
|
33
|
+
export const POLL_RETRY_MAX_MS = 15 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
// Backoff starts only after the first failure has been retried once at the
|
|
36
|
+
// normal interval, so a single blip costs nothing.
|
|
37
|
+
export const POLL_BACKOFF_AFTER = 1;
|
|
38
|
+
|
|
39
|
+
// Failure counts that emit a loud line. Chosen against the measured outage:
|
|
40
|
+
// at intervalMs=5000 these land at roughly 15s, 1min, 5min, 20min and 1h of
|
|
41
|
+
// sustained failure, so an operator reading the log sees the shape of the
|
|
42
|
+
// outage rather than 797 identical lines.
|
|
43
|
+
export const POLL_ESCALATE_AT = Object.freeze([3, 10, 30, 60, 120]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Next poll delay after a failed fetch, and whether this attempt should be
|
|
47
|
+
* announced loudly.
|
|
48
|
+
*
|
|
49
|
+
* Exponential from `intervalMs`, bounded by POLL_RETRY_MAX_MS. Jitter is the
|
|
50
|
+
* same anti-herd offset the spawn path uses — without it, every seat that lost
|
|
51
|
+
* the same upstream retries in lockstep and re-creates the thundering herd on
|
|
52
|
+
* recovery.
|
|
53
|
+
*/
|
|
54
|
+
export const pollRetryPolicy = ({
|
|
55
|
+
consecutiveFailures,
|
|
56
|
+
intervalMs,
|
|
57
|
+
jitterRatio = 0,
|
|
58
|
+
}) => {
|
|
59
|
+
const safeIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 5000;
|
|
60
|
+
const failureCount = Number.isInteger(consecutiveFailures) && consecutiveFailures > 0
|
|
61
|
+
? consecutiveFailures
|
|
62
|
+
: 1;
|
|
63
|
+
|
|
64
|
+
const steps = Math.max(0, failureCount - POLL_BACKOFF_AFTER);
|
|
65
|
+
const raw = safeIntervalMs * (2 ** steps);
|
|
66
|
+
const bounded = Math.min(POLL_RETRY_MAX_MS, raw);
|
|
67
|
+
|
|
68
|
+
// Clamp rather than trust the caller: a jitterRatio above the cap would
|
|
69
|
+
// widen the herd window instead of narrowing it.
|
|
70
|
+
const safeJitter = Number.isFinite(jitterRatio)
|
|
71
|
+
? Math.min(0.2, Math.max(0, jitterRatio))
|
|
72
|
+
: 0;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
delayMs: Math.round(bounded * (1 + safeJitter)),
|
|
76
|
+
escalate: POLL_ESCALATE_AT.includes(failureCount),
|
|
77
|
+
atCeiling: bounded >= POLL_RETRY_MAX_MS,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export default pollRetryPolicy;
|