@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.18",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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
- if (ADDRESSED_EVENT_TYPES.has(event.type)) {
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
- + 'proceeding peer-aware (this seat was directly addressed)',
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: 10,
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;