@commonlyai/cli 0.1.15 → 0.1.23
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 +4 -2
- package/src/commands/agent.js +167 -8
- package/src/commands/login.js +3 -3
- package/src/lib/adapters/claude.js +9 -5
- package/src/lib/adapters/codex.js +9 -5
- package/src/lib/api.js +7 -1
- package/src/lib/enforcement.js +112 -15
- package/src/lib/environment.js +1 -1
- package/src/lib/memory-bridge.js +57 -9
- package/src/lib/poll-retry.js +81 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"start": "node src/index.js",
|
|
31
|
-
"lint": "eslint src
|
|
31
|
+
"lint": "eslint src --ext .js",
|
|
32
|
+
"lint:fix": "eslint src --ext .js --fix",
|
|
32
33
|
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
33
34
|
"prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
|
|
34
35
|
},
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"commander": "^12.0.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
40
|
+
"eslint": "^8.56.0",
|
|
39
41
|
"jest": "^29.7.0"
|
|
40
42
|
},
|
|
41
43
|
"jest": {
|
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';
|
|
@@ -350,6 +351,25 @@ export const assertSandboxDeclaredForPublicPod = async ({
|
|
|
350
351
|
);
|
|
351
352
|
};
|
|
352
353
|
|
|
354
|
+
// ── wake: toggle ADR-018 ambient wake on an attached agent ──────────────────
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Set `config.wakeOnMessage.enabled` on an installation via the registry
|
|
358
|
+
* PATCH route (user JWT, pod member or creator). Pure core — takes the token
|
|
359
|
+
* record so the command doesn't need to ask for the pod again.
|
|
360
|
+
*/
|
|
361
|
+
export const setWakeOnMessage = async ({ client, record, enabled }) => {
|
|
362
|
+
if (!record?.podId || !record?.agentName) {
|
|
363
|
+
throw new Error('token record is missing podId/agentName — re-attach the agent');
|
|
364
|
+
}
|
|
365
|
+
const instanceId = record.instanceId || 'default';
|
|
366
|
+
await client.patch(
|
|
367
|
+
`/api/registry/pods/${record.podId}/agents/${record.agentName}`,
|
|
368
|
+
{ instanceId, config: { wakeOnMessage: { enabled: Boolean(enabled) } } },
|
|
369
|
+
);
|
|
370
|
+
return { agentName: record.agentName, podId: record.podId, instanceId, enabled: Boolean(enabled) };
|
|
371
|
+
};
|
|
372
|
+
|
|
353
373
|
// ── attach: register a local-CLI-wrapped agent (ADR-005) ────────────────────
|
|
354
374
|
|
|
355
375
|
/**
|
|
@@ -363,6 +383,7 @@ export const performAttach = async ({
|
|
|
363
383
|
podId,
|
|
364
384
|
displayName,
|
|
365
385
|
envPath = null,
|
|
386
|
+
wakeOnMessage = false,
|
|
366
387
|
log = () => {},
|
|
367
388
|
}) => {
|
|
368
389
|
const adapter = getAdapter(adapterName);
|
|
@@ -505,6 +526,10 @@ export const performAttach = async ({
|
|
|
505
526
|
host: 'byo',
|
|
506
527
|
},
|
|
507
528
|
...(environment ? { environment } : {}),
|
|
529
|
+
// ADR-018: ambient wake is a per-install opt-in read by
|
|
530
|
+
// agentMentionService (`config.wakeOnMessage.enabled === true`, default
|
|
531
|
+
// OFF). Until now the only way to set it for a BYO seat was a DB write.
|
|
532
|
+
...(wakeOnMessage ? { wakeOnMessage: { enabled: true } } : {}),
|
|
508
533
|
},
|
|
509
534
|
scopes: ['context:read', 'messages:write', 'memory:read', 'memory:write'],
|
|
510
535
|
});
|
|
@@ -732,6 +757,7 @@ export const performRun = ({
|
|
|
732
757
|
// reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
|
|
733
758
|
let consecutiveAuthErrors = 0;
|
|
734
759
|
const MAX_AUTH_ERRORS = 3;
|
|
760
|
+
let consecutivePollFailures = 0;
|
|
735
761
|
let consecutiveSpawnFailures = 0;
|
|
736
762
|
const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
|
|
737
763
|
// Per-seat cascade state — lives with the process, like the session store.
|
|
@@ -945,10 +971,20 @@ export const performRun = ({
|
|
|
945
971
|
});
|
|
946
972
|
const claim = await claimKeeper.acquire();
|
|
947
973
|
if (!claim.claimed && !claim.failOpen) {
|
|
948
|
-
|
|
974
|
+
// Per-event EVIDENCE, deliberately not a widening of
|
|
975
|
+
// ADDRESSED_EVENT_TYPES (that set is a pricing table): the backend
|
|
976
|
+
// stamps repliesToYourMessage when the woken message replies to or
|
|
977
|
+
// threads on a message THIS seat authored. Without it, a peer's
|
|
978
|
+
// claim on its own reply ordered the replied-to author out of its
|
|
979
|
+
// own conversation (Sage stood down twice on Anvil's thread
|
|
980
|
+
// replies, 2026-08-24). Interim for TASK-058.
|
|
981
|
+
const repliesToThisSeat = event.payload?.repliesToYourMessage === true;
|
|
982
|
+
if (ADDRESSED_EVENT_TYPES.has(event.type) || repliesToThisSeat) {
|
|
949
983
|
log(
|
|
950
984
|
`[${event.type}] message ${claimMessageId} held by ${claim.holder} — `
|
|
951
|
-
+
|
|
985
|
+
+ (repliesToThisSeat
|
|
986
|
+
? 'proceeding peer-aware (the message replies to this seat\'s own message)'
|
|
987
|
+
: 'proceeding peer-aware (this seat was directly addressed)'),
|
|
952
988
|
);
|
|
953
989
|
peerFrame = peerHoldsFrame(claim.holder, claimMessageId);
|
|
954
990
|
claimKeeper = null; // nothing held: no renewal, no release, no isLost gate
|
|
@@ -1057,6 +1093,7 @@ export const performRun = ({
|
|
|
1057
1093
|
&& /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
|
|
1058
1094
|
const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
|
|
1059
1095
|
let delivered = agentPostedItself;
|
|
1096
|
+
let deliveryRefusal = null;
|
|
1060
1097
|
|
|
1061
1098
|
if (event.type === 'agent.ask') {
|
|
1062
1099
|
if (silentReply) {
|
|
@@ -1142,11 +1179,32 @@ export const performRun = ({
|
|
|
1142
1179
|
uploadName: `${agentName}-reply-${event._id}.md`,
|
|
1143
1180
|
log: (line) => log(`[${event.type}] ${line}`),
|
|
1144
1181
|
});
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1182
|
+
if (delivery.refused) {
|
|
1183
|
+
// A run-cap refusal is a successful HTTP request but not a delivery.
|
|
1184
|
+
// Ack it so the kernel does not replay the same text (the server
|
|
1185
|
+
// guidance expressly says not to retry unchanged), while preserving
|
|
1186
|
+
// the refusal and its partial-post count for the seat and event ledger.
|
|
1187
|
+
deliveryRefusal = delivery;
|
|
1188
|
+
const guidance = delivery.guidance || 'The server refused this message; do not retry it unchanged.';
|
|
1189
|
+
const detail = `after ${delivery.messages}/${delivery.attemptedMessages} message${delivery.attemptedMessages === 1 ? '' : 's'}`;
|
|
1190
|
+
log(`[${event.type}] wrapper delivery refused ${detail} (${delivery.reason}): ${guidance}`);
|
|
1191
|
+
onError?.(Object.assign(
|
|
1192
|
+
new Error(`${event.type} wrapper delivery refused ${detail}: ${guidance}`),
|
|
1193
|
+
{
|
|
1194
|
+
code: 'agent_delivery_refused',
|
|
1195
|
+
reason: delivery.reason,
|
|
1196
|
+
eventId: event._id,
|
|
1197
|
+
postedMessages: delivery.messages,
|
|
1198
|
+
attemptedMessages: delivery.attemptedMessages,
|
|
1199
|
+
},
|
|
1200
|
+
));
|
|
1201
|
+
} else {
|
|
1202
|
+
delivered = true;
|
|
1203
|
+
log(
|
|
1204
|
+
`[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as `
|
|
1205
|
+
+ `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1150
1208
|
}
|
|
1151
1209
|
if (result.memorySummary) {
|
|
1152
1210
|
try {
|
|
@@ -1164,6 +1222,21 @@ export const performRun = ({
|
|
|
1164
1222
|
// streak. Recording only on completion means a spawn failure that gets
|
|
1165
1223
|
// redelivered never double-counts toward the cap.
|
|
1166
1224
|
cascadeGovernor.record(eventPodId, trigger);
|
|
1225
|
+
if (deliveryRefusal) {
|
|
1226
|
+
return {
|
|
1227
|
+
outcome: 'no_action',
|
|
1228
|
+
reason: deliveryRefusal.reason,
|
|
1229
|
+
details: {
|
|
1230
|
+
mode: deliveryRefusal.mode,
|
|
1231
|
+
postedMessages: deliveryRefusal.messages,
|
|
1232
|
+
attemptedMessages: deliveryRefusal.attemptedMessages,
|
|
1233
|
+
...(typeof deliveryRefusal.consecutive === 'number'
|
|
1234
|
+
? { consecutive: deliveryRefusal.consecutive }
|
|
1235
|
+
: {}),
|
|
1236
|
+
...(deliveryRefusal.guidance ? { guidance: deliveryRefusal.guidance } : {}),
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1167
1240
|
return { outcome: delivered ? 'posted' : 'no_action' };
|
|
1168
1241
|
};
|
|
1169
1242
|
|
|
@@ -1171,10 +1244,32 @@ export const performRun = ({
|
|
|
1171
1244
|
if (!running) return;
|
|
1172
1245
|
let nextPollDelayMs = intervalMs;
|
|
1173
1246
|
try {
|
|
1247
|
+
// ONE event per fetch, because the fetch IS the claim.
|
|
1248
|
+
//
|
|
1249
|
+
// `AgentEventService.list()` does not hand back a preview — it marks
|
|
1250
|
+
// every candidate `delivered` with `$inc: { attempts: 1 }` before
|
|
1251
|
+
// returning. This loop then processes them SERIALLY, one full model
|
|
1252
|
+
// turn each. So asking for 10 claims 10 and starts 1.
|
|
1253
|
+
//
|
|
1254
|
+
// The nine it cannot start are then reclaimed out from under it: the
|
|
1255
|
+
// backend requeues `delivered` rows older than
|
|
1256
|
+
// `requeueDeliveredMinutes` (default 10, swept on `*/10`), and turns
|
|
1257
|
+
// routinely outlast that — measured on the pod-architect seat over
|
|
1258
|
+
// 11.5h: median 128s, p90 669s, 13 turns over 600s, max 1153s. Each
|
|
1259
|
+
// sweep returns the untouched siblings to `pending` at `attempts + 1`,
|
|
1260
|
+
// and `attempts >= 3` retires an event to `failed`, which is terminal
|
|
1261
|
+
// and invisible to `list()`. That cap exists to bound POISON events;
|
|
1262
|
+
// over-claiming feeds it work no model ever saw, so a mention can be
|
|
1263
|
+
// dropped without once being read.
|
|
1264
|
+
//
|
|
1265
|
+
// `limit: 1` costs nothing: capacity here is one turn at a time
|
|
1266
|
+
// regardless, and the poll interval is 5s. It only stops the loop
|
|
1267
|
+
// claiming work it has no way to begin.
|
|
1174
1268
|
const { events = [] } = await client.get('/api/agents/runtime/events', {
|
|
1175
|
-
agentName, instanceId, limit:
|
|
1269
|
+
agentName, instanceId, limit: 1,
|
|
1176
1270
|
});
|
|
1177
1271
|
consecutiveAuthErrors = 0;
|
|
1272
|
+
consecutivePollFailures = 0;
|
|
1178
1273
|
for (const event of events) {
|
|
1179
1274
|
if (!running) break;
|
|
1180
1275
|
let result;
|
|
@@ -1265,6 +1360,33 @@ export const performRun = ({
|
|
|
1265
1360
|
running = false;
|
|
1266
1361
|
return;
|
|
1267
1362
|
}
|
|
1363
|
+
} else {
|
|
1364
|
+
// TASK-025: everything that is not an auth rejection used to fall
|
|
1365
|
+
// through to `onError` with `nextPollDelayMs` still at `intervalMs`,
|
|
1366
|
+
// because that variable is only reassigned in the spawn-retry branch
|
|
1367
|
+
// above and a failed fetch never reaches it. So a network outage
|
|
1368
|
+
// retried flat at the poll interval, forever, with one indistinct
|
|
1369
|
+
// line per attempt — 797 consecutive `fetch failed` across three
|
|
1370
|
+
// seats, ~66 minutes, and nothing that read as an outage.
|
|
1371
|
+
//
|
|
1372
|
+
// Deliberately NOT a stop. `MAX_AUTH_ERRORS` halts because a rejected
|
|
1373
|
+
// token cannot heal itself; a network failure usually can, and a seat
|
|
1374
|
+
// that stops on one is dead until a human notices.
|
|
1375
|
+
consecutivePollFailures += 1;
|
|
1376
|
+
const retry = pollRetryPolicy({
|
|
1377
|
+
consecutiveFailures: consecutivePollFailures,
|
|
1378
|
+
intervalMs,
|
|
1379
|
+
jitterRatio: spawnJitterRatio,
|
|
1380
|
+
});
|
|
1381
|
+
nextPollDelayMs = retry.delayMs;
|
|
1382
|
+
if (retry.escalate) {
|
|
1383
|
+
log(
|
|
1384
|
+
`poll failed ${consecutivePollFailures}x in a row `
|
|
1385
|
+
+ `(${err?.message || 'unknown error'}) — backing off to `
|
|
1386
|
+
+ `${formatRetryDelay(retry.delayMs)}`
|
|
1387
|
+
+ `${retry.atCeiling ? ', at the ceiling' : ''}`,
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1268
1390
|
}
|
|
1269
1391
|
onError?.(err);
|
|
1270
1392
|
}
|
|
@@ -1663,6 +1785,7 @@ Docs:
|
|
|
1663
1785
|
.option('--env <path>', 'Path to environment.json (ADR-008 — sandbox/skills/MCP)')
|
|
1664
1786
|
.option('--import-memory [path]', 'Import local memory (CLAUDE.md / MEMORY.md / ~/.claude project memory, or a given file/dir) into the agent after attach')
|
|
1665
1787
|
.option('--yes', 'Skip the import confirmation prompt')
|
|
1788
|
+
.option('--wake-on-message', 'Wake this agent on every message in the pod, not only @mentions (ADR-018 ambient wake; default off). Change later with: commonly agent wake <name> on|off')
|
|
1666
1789
|
.option('--instance <url>', 'Target Commonly instance')
|
|
1667
1790
|
.action(async (adapterName, opts) => {
|
|
1668
1791
|
const instanceUrl = resolveInstanceUrl(opts.instance);
|
|
@@ -1683,6 +1806,7 @@ Docs:
|
|
|
1683
1806
|
podId: opts.pod,
|
|
1684
1807
|
displayName: opts.display,
|
|
1685
1808
|
envPath: envAbsPath,
|
|
1809
|
+
wakeOnMessage: Boolean(opts.wakeOnMessage),
|
|
1686
1810
|
log: (line) => console.warn(`[attach] ${line}`),
|
|
1687
1811
|
});
|
|
1688
1812
|
|
|
@@ -1705,6 +1829,9 @@ Docs:
|
|
|
1705
1829
|
if (workspace) {
|
|
1706
1830
|
console.log(`✓ Workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
|
|
1707
1831
|
}
|
|
1832
|
+
if (opts.wakeOnMessage) {
|
|
1833
|
+
console.log('✓ Wake-on-message: ON (sees every pod message; answers only when addressed or genuinely useful)');
|
|
1834
|
+
}
|
|
1708
1835
|
|
|
1709
1836
|
// Retention plan Phase C: the agent arrives whole. Import failure is
|
|
1710
1837
|
// a warning, never an attach failure — the attach already succeeded
|
|
@@ -2113,6 +2240,38 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
|
|
|
2113
2240
|
}
|
|
2114
2241
|
});
|
|
2115
2242
|
|
|
2243
|
+
// ── wake (ADR-018 ambient wake toggle) ───────────────────────────────────
|
|
2244
|
+
agent
|
|
2245
|
+
.command('wake <name> <on|off>')
|
|
2246
|
+
.description('Turn wake-on-message on/off for an attached agent (off = @mentions and heartbeats only)')
|
|
2247
|
+
.option('--instance <url>', 'Target Commonly instance')
|
|
2248
|
+
.action(async (name, state, opts) => {
|
|
2249
|
+
const enabled = state === 'on';
|
|
2250
|
+
if (!enabled && state !== 'off') {
|
|
2251
|
+
console.error(`Expected "on" or "off", got "${state}"`);
|
|
2252
|
+
process.exit(1);
|
|
2253
|
+
}
|
|
2254
|
+
const record = loadAgentToken(name);
|
|
2255
|
+
if (!record) {
|
|
2256
|
+
console.error(`No token file for '${name}' — is it attached on this machine? (commonly agent list --local)`);
|
|
2257
|
+
process.exit(1);
|
|
2258
|
+
}
|
|
2259
|
+
const instanceUrl = resolveInstanceUrl(opts.instance || record.instanceUrl);
|
|
2260
|
+
const token = getToken(opts.instance || record.instanceUrl);
|
|
2261
|
+
if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
|
|
2262
|
+
const client = createClient({ instance: instanceUrl, token });
|
|
2263
|
+
try {
|
|
2264
|
+
const result = await setWakeOnMessage({ client, record, enabled });
|
|
2265
|
+
console.log(`✓ Wake-on-message ${result.enabled ? 'ON' : 'OFF'} for ${result.agentName} in pod ${result.podId}`);
|
|
2266
|
+
console.log(result.enabled
|
|
2267
|
+
? ' The agent now sees every message in the pod; its runtime decides whether to answer (NO_REPLY otherwise). No restart needed.'
|
|
2268
|
+
: ' The agent now wakes only on @mentions, replies to it, and heartbeats. No restart needed.');
|
|
2269
|
+
} catch (err) {
|
|
2270
|
+
console.error(`Failed: ${err.message}`);
|
|
2271
|
+
process.exit(1);
|
|
2272
|
+
}
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2116
2275
|
// ── heartbeat ─────────────────────────────────────────────────────────────
|
|
2117
2276
|
agent
|
|
2118
2277
|
.command('heartbeat <name>')
|
package/src/commands/login.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { createInterface } from 'readline';
|
|
9
9
|
import { login as apiLogin } from '../lib/api.js';
|
|
10
|
-
import { saveInstance
|
|
10
|
+
import { saveInstance } from '../lib/config.js';
|
|
11
11
|
|
|
12
12
|
const prompt = (rl, question) => new Promise((resolve) => rl.question(question, resolve));
|
|
13
13
|
|
|
@@ -88,8 +88,8 @@ export const registerWhoami = (program) => {
|
|
|
88
88
|
.command('whoami')
|
|
89
89
|
.description('Show current auth state')
|
|
90
90
|
.option('--instance <url>', 'Target instance')
|
|
91
|
-
.action(async (
|
|
92
|
-
const {
|
|
91
|
+
.action(async () => {
|
|
92
|
+
const { listInstances } = await import('../lib/config.js');
|
|
93
93
|
const instances = listInstances();
|
|
94
94
|
|
|
95
95
|
if (instances.length === 0) {
|
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
publicClaudeStateRoot,
|
|
67
67
|
wrapArgvWithSeatbelt,
|
|
68
68
|
} from '../sandbox/seatbelt.js';
|
|
69
|
+
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
69
70
|
|
|
70
71
|
// See codex.js for the rationale on bumping the default + env override.
|
|
71
72
|
// Keeping both adapters in lockstep so any wrapper agent runtime has the
|
|
@@ -78,10 +79,7 @@ const DEFAULT_TIMEOUT_MS = (() => {
|
|
|
78
79
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
79
80
|
})();
|
|
80
81
|
|
|
81
|
-
const buildPrompt =
|
|
82
|
-
if (!memoryLongTerm) return prompt;
|
|
83
|
-
return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
|
|
84
|
-
};
|
|
82
|
+
const buildPrompt = buildMemoryPreamble;
|
|
85
83
|
|
|
86
84
|
const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
|
|
87
85
|
const PUBLIC_DENIED_TOOLS = [
|
|
@@ -472,7 +470,13 @@ export default {
|
|
|
472
470
|
async spawn(prompt, ctx = {}) {
|
|
473
471
|
const isResume = !!ctx.sessionId;
|
|
474
472
|
const sessionId = ctx.sessionId || randomUUID();
|
|
475
|
-
|
|
473
|
+
// Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
|
|
474
|
+
// `null || ''` is `''` — which routed the unreadable case straight into
|
|
475
|
+
// the empty-memory branch and made the whole distinction unreachable from
|
|
476
|
+
// the only two paths that build a real prompt. buildPrompt handles
|
|
477
|
+
// undefined and '' as absence itself; it does not need a guard, it needs
|
|
478
|
+
// the value.
|
|
479
|
+
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm);
|
|
476
480
|
const sessionFlag = isResume ? '--resume' : '--session-id';
|
|
477
481
|
// Model pin from the ADR-008 environment spec. Absent it, claude picks its
|
|
478
482
|
// own default — which is how a fleet of ten agents ended up running three
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
join,
|
|
57
57
|
resolve as pathResolve,
|
|
58
58
|
} from 'path';
|
|
59
|
+
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
59
60
|
|
|
60
61
|
// Default timeout for a single codex spawn (exec mode).
|
|
61
62
|
//
|
|
@@ -81,10 +82,7 @@ const DEFAULT_TIMEOUT_MS = (() => {
|
|
|
81
82
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
82
83
|
})();
|
|
83
84
|
|
|
84
|
-
const buildPrompt =
|
|
85
|
-
if (!memoryLongTerm) return prompt;
|
|
86
|
-
return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
|
|
87
|
-
};
|
|
85
|
+
const buildPrompt = buildMemoryPreamble;
|
|
88
86
|
|
|
89
87
|
// ── MCP wiring — codex consumes MCP servers via `-c mcp_servers.*` overrides ─
|
|
90
88
|
//
|
|
@@ -417,7 +415,13 @@ export default {
|
|
|
417
415
|
},
|
|
418
416
|
|
|
419
417
|
async spawn(prompt, ctx = {}) {
|
|
420
|
-
|
|
418
|
+
// Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
|
|
419
|
+
// `null || ''` is `''` — which routed the unreadable case straight into
|
|
420
|
+
// the empty-memory branch and made the whole distinction unreachable from
|
|
421
|
+
// the only two paths that build a real prompt. buildPrompt handles
|
|
422
|
+
// undefined and '' as absence itself; it does not need a guard, it needs
|
|
423
|
+
// the value.
|
|
424
|
+
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm);
|
|
421
425
|
|
|
422
426
|
// Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
|
|
423
427
|
// so a crash in the middle of the spawn doesn't leak files in $TMPDIR.
|
package/src/lib/api.js
CHANGED
|
@@ -43,6 +43,12 @@ export const createClient = ({ instance = null, token = null } = {}) => {
|
|
|
43
43
|
body: JSON.stringify(body),
|
|
44
44
|
}).then(handleResponse);
|
|
45
45
|
|
|
46
|
+
const patch = (path, body = {}) => fetch(`${baseUrl}${path}`, {
|
|
47
|
+
method: 'PATCH',
|
|
48
|
+
headers: headers(authToken),
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
}).then(handleResponse);
|
|
51
|
+
|
|
46
52
|
const del = (path) => fetch(`${baseUrl}${path}`, {
|
|
47
53
|
method: 'DELETE',
|
|
48
54
|
headers: headers(authToken),
|
|
@@ -70,7 +76,7 @@ export const createClient = ({ instance = null, token = null } = {}) => {
|
|
|
70
76
|
};
|
|
71
77
|
|
|
72
78
|
return {
|
|
73
|
-
get, post, del, upload, baseUrl,
|
|
79
|
+
get, post, patch, del, upload, baseUrl,
|
|
74
80
|
};
|
|
75
81
|
};
|
|
76
82
|
|
package/src/lib/enforcement.js
CHANGED
|
@@ -540,16 +540,35 @@ export const splitForChat = (text, { limit = 400 } = {}) => {
|
|
|
540
540
|
* fits in one message → post as-is
|
|
541
541
|
* splits into ≤ maxChunks → post the chunks in order ("two short
|
|
542
542
|
* messages beat one wall")
|
|
543
|
-
* longer than a split answer →
|
|
544
|
-
* the
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
543
|
+
* longer than a split answer → post the FIRST chunk to the channel and
|
|
544
|
+
* continue the rest in a thread under it.
|
|
545
|
+
* one indivisible oversize unit → it is a genuine document: upload the FULL
|
|
546
|
+
* text and post one message — the reply's
|
|
547
|
+
* own opening plus the file card.
|
|
548
548
|
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
549
|
+
* The thread rung is new (Sam 57691) and it replaces attachment as the answer
|
|
550
|
+
* for PROSE overflow. Before threads existed, a long analysis had nowhere to
|
|
551
|
+
* go but a file, and that was the right workaround. It is now the wrong one:
|
|
552
|
+
* an attachment is un-quotable, un-followable, and an all-or-nothing read,
|
|
553
|
+
* so overflowing into one buries the tail of a reply in a surface nobody can
|
|
554
|
+
* respond to. A thread keeps every word addressable and scopes the read.
|
|
555
|
+
*
|
|
556
|
+
* This is also the layer the rule has to live at. The pod-context cue already
|
|
557
|
+
* tells agents "prose overflow goes in a thread, not an attachment" (#1176),
|
|
558
|
+
* and the cue could not have been obeyed: the model does not choose the
|
|
559
|
+
* delivery mode, THIS FUNCTION does, and it only knew how to attach. A cue
|
|
560
|
+
* that promises what the wrapper contradicts teaches the agent it is failing
|
|
561
|
+
* at something it never controlled.
|
|
562
|
+
*
|
|
563
|
+
* Attachment is kept for the case it was always right for — a single atomic
|
|
564
|
+
* unit over `attachThreshold` (a long fence, an unbreakable run). That is a
|
|
565
|
+
* document by construction, not prose that outgrew a message.
|
|
566
|
+
*
|
|
567
|
+
* If threading fails (older server with no threadRootId support, no id in the
|
|
568
|
+
* response), fall back to attach, then to posting every chunk: a message
|
|
569
|
+
* flood is a tone violation, silence or truncation is a correctness
|
|
570
|
+
* violation, and the contract ranks content above tone ("NEVER hit that by
|
|
571
|
+
* cutting content").
|
|
553
572
|
*/
|
|
554
573
|
export const deliverChatReply = async ({
|
|
555
574
|
client,
|
|
@@ -563,6 +582,24 @@ export const deliverChatReply = async ({
|
|
|
563
582
|
}) => {
|
|
564
583
|
const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
|
|
565
584
|
const chunks = splitForChat(text, { limit });
|
|
585
|
+
// The runtime route uses HTTP 200 for a policy refusal: it is a completed
|
|
586
|
+
// request, but no message was created. Keep that distinction at the client
|
|
587
|
+
// boundary so every delivery mode shares it rather than treating a resolved
|
|
588
|
+
// promise as proof of a post.
|
|
589
|
+
const postMessage = (body) => client.post(messagesPath, body);
|
|
590
|
+
const refused = (response, messages, attemptedMessages) => ({
|
|
591
|
+
mode: 'refused',
|
|
592
|
+
messages,
|
|
593
|
+
attemptedMessages,
|
|
594
|
+
refused: true,
|
|
595
|
+
reason: response.reason || 'message_refused',
|
|
596
|
+
...(typeof response.guidance === 'string' && response.guidance
|
|
597
|
+
? { guidance: response.guidance }
|
|
598
|
+
: {}),
|
|
599
|
+
...(typeof response.consecutive === 'number'
|
|
600
|
+
? { consecutive: response.consecutive }
|
|
601
|
+
: {}),
|
|
602
|
+
});
|
|
566
603
|
// An atomic unit (a fenced block, an unbreakable word-run) can exceed the
|
|
567
604
|
// limit by construction — splitForChat keeps it whole rather than breaking
|
|
568
605
|
// its rendering. The tone contract's own rule covers it: over ~800 chars of
|
|
@@ -571,15 +608,71 @@ export const deliverChatReply = async ({
|
|
|
571
608
|
// the gate (found by the fleet's implementation audit, Sharpen msg 53018).
|
|
572
609
|
const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
|
|
573
610
|
if (chunks.length <= 1 && !hasIndivisibleOversize) {
|
|
574
|
-
await
|
|
611
|
+
const response = await postMessage({ content: chunks[0] ?? text });
|
|
612
|
+
if (response?.refused === true) return refused(response, 0, 1);
|
|
575
613
|
return { mode: 'single', messages: 1 };
|
|
576
614
|
}
|
|
577
615
|
if (chunks.length <= maxChunks && !hasIndivisibleOversize) {
|
|
616
|
+
let messages = 0;
|
|
578
617
|
for (const chunk of chunks) {
|
|
579
618
|
// eslint-disable-next-line no-await-in-loop
|
|
580
|
-
await
|
|
619
|
+
const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
|
|
620
|
+
if (response?.refused === true) return refused(response, messages, chunks.length);
|
|
621
|
+
messages += 1;
|
|
622
|
+
}
|
|
623
|
+
return { mode: 'split', messages };
|
|
624
|
+
}
|
|
625
|
+
// PROSE OVERFLOW → THREAD. Only when nothing is indivisibly oversize: a
|
|
626
|
+
// fence too big to split is a document and belongs in the attach rung below.
|
|
627
|
+
if (!hasIndivisibleOversize) {
|
|
628
|
+
// A COUNT, not a flag. The recovery below resumes from here, and a boolean
|
|
629
|
+
// can only distinguish "nothing posted" from "something posted" — it cannot
|
|
630
|
+
// say how much. Fail a continuation at chunk 3 with a boolean and chunks 1
|
|
631
|
+
// and 2 are already in the thread, then get posted again top-level; the
|
|
632
|
+
// reader sees them twice. Getting this wrong is silent, which is also why
|
|
633
|
+
// the attach rung below leads with `chunks[0]`: falling through after a
|
|
634
|
+
// successful headline duplicates the opening line. Two of this suite's
|
|
635
|
+
// existing tests caught that one, and none caught this one, because both
|
|
636
|
+
// fail at the root-id step before any continuation has posted.
|
|
637
|
+
let posted = 0;
|
|
638
|
+
try {
|
|
639
|
+
const rootRes = await postMessage({ content: chunks[0] });
|
|
640
|
+
if (rootRes?.refused === true) return refused(rootRes, 0, chunks.length);
|
|
641
|
+
posted = 1;
|
|
642
|
+
// The runtime route answers `res.json(result)` with the created row on
|
|
643
|
+
// `result.message`. Accept either id field; refuse to guess if neither
|
|
644
|
+
// is present, because a continuation posted with a missing root would
|
|
645
|
+
// silently become another top-level message — the exact flood this rung
|
|
646
|
+
// exists to prevent.
|
|
647
|
+
const rootId = rootRes?.message?.id ?? rootRes?.message?._id ?? rootRes?.id ?? rootRes?._id;
|
|
648
|
+
if (!rootId) throw new Error('no message id in post response — cannot root the thread');
|
|
649
|
+
for (const chunk of chunks.slice(1)) {
|
|
650
|
+
// eslint-disable-next-line no-await-in-loop
|
|
651
|
+
const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
|
|
652
|
+
if (response?.refused === true) return refused(response, posted, chunks.length);
|
|
653
|
+
posted += 1;
|
|
654
|
+
}
|
|
655
|
+
return { mode: 'thread', messages: chunks.length, threadRootId: String(rootId) };
|
|
656
|
+
} catch (err) {
|
|
657
|
+
if (posted > 0) {
|
|
658
|
+
// The opening is already in the room. Post the REMAINDER top-level —
|
|
659
|
+
// never the whole text again. This is the old flood, minus the
|
|
660
|
+
// duplicate, and it is still preferable to attaching: content ranks
|
|
661
|
+
// above tone, and the reader would otherwise see the same paragraph
|
|
662
|
+
// twice with the rest hidden in a file.
|
|
663
|
+
log(`thread continuation failed (${err.message}) — posting the remainder top-level`);
|
|
664
|
+
for (const chunk of chunks.slice(posted)) {
|
|
665
|
+
// eslint-disable-next-line no-await-in-loop
|
|
666
|
+
const response = await postMessage({ content: chunk });
|
|
667
|
+
if (response?.refused === true) return refused(response, posted, chunks.length);
|
|
668
|
+
posted += 1;
|
|
669
|
+
}
|
|
670
|
+
return { mode: 'thread-fallback', messages: chunks.length };
|
|
671
|
+
}
|
|
672
|
+
// Nothing reached the room, so the attach rung below is free to lead
|
|
673
|
+
// with the opening as it always did.
|
|
674
|
+
log(`thread headline failed (${err.message}) — falling back to attach`);
|
|
581
675
|
}
|
|
582
|
-
return { mode: 'split', messages: chunks.length };
|
|
583
676
|
}
|
|
584
677
|
try {
|
|
585
678
|
const uploaded = await client.upload(`/api/agents/runtime/pods/${podId}/uploads`, {
|
|
@@ -596,14 +689,18 @@ export const deliverChatReply = async ({
|
|
|
596
689
|
const lead = chunks[0] && chunks[0].length <= limit
|
|
597
690
|
? chunks[0]
|
|
598
691
|
: '(reply too large for chat — attached in full)';
|
|
599
|
-
await
|
|
692
|
+
const response = await postMessage({ content: `${lead}\n\n${directive}` });
|
|
693
|
+
if (response?.refused === true) return refused(response, 0, 1);
|
|
600
694
|
return { mode: 'attach', messages: 1 };
|
|
601
695
|
} catch (err) {
|
|
602
696
|
log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`);
|
|
697
|
+
let messages = 0;
|
|
603
698
|
for (const chunk of chunks) {
|
|
604
699
|
// eslint-disable-next-line no-await-in-loop
|
|
605
|
-
await
|
|
700
|
+
const response = await postMessage({ content: chunk });
|
|
701
|
+
if (response?.refused === true) return refused(response, messages, chunks.length);
|
|
702
|
+
messages += 1;
|
|
606
703
|
}
|
|
607
|
-
return { mode: 'split-fallback', messages
|
|
704
|
+
return { mode: 'split-fallback', messages };
|
|
608
705
|
}
|
|
609
706
|
};
|
package/src/lib/environment.js
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile,
|
|
19
19
|
} from 'fs/promises';
|
|
20
20
|
import { existsSync } from 'fs';
|
|
21
|
-
import {
|
|
21
|
+
import { isAbsolute, join, resolve as pathResolve, basename } from 'path';
|
|
22
22
|
import { homedir } from 'os';
|
|
23
23
|
|
|
24
24
|
// ── Schema — keep the allow-list narrow; ADR-008 §invariants #1+#2 ──────────
|
package/src/lib/memory-bridge.js
CHANGED
|
@@ -21,20 +21,68 @@
|
|
|
21
21
|
|
|
22
22
|
export const SOURCE_RUNTIME = 'local-cli';
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The turn preamble, shared by every adapter so the two cannot drift.
|
|
26
|
+
*
|
|
27
|
+
* Non-empty memory is prepended verbatim — that path is load-bearing and is
|
|
28
|
+
* not touched here.
|
|
29
|
+
*
|
|
30
|
+
* The EMPTY case used to return the bare prompt, which made two very different
|
|
31
|
+
* states byte-identical from inside the session: an agent that has never saved
|
|
32
|
+
* anything, and an agent on a runtime with no memory bridge at all. A seat
|
|
33
|
+
* cannot adopt a habit whose surface it has no evidence exists, so the empty
|
|
34
|
+
* case now says so.
|
|
35
|
+
*
|
|
36
|
+
* It names `long_term` specifically because that is the ONLY section read back
|
|
37
|
+
* (`readLongTerm` returns `sections.long_term.content`). A write to `daily` —
|
|
38
|
+
* the section whose name most invites exactly this use — succeeds, returns 200,
|
|
39
|
+
* and is never seen again; the write and the silence are indistinguishable from
|
|
40
|
+
* a correct round trip. Naming the section is the whole point of the cue.
|
|
41
|
+
*/
|
|
42
|
+
export const buildMemoryPreamble = (prompt, memoryLongTerm) => {
|
|
43
|
+
// `null` is the UNREADABLE signal, and it is deliberately not the same value
|
|
44
|
+
// as `''`. Telling a seat whose token was revoked that "nothing has ever been
|
|
45
|
+
// saved here" is a false claim about its own history, and it is the same
|
|
46
|
+
// defect this cue exists to fix — one state over. When we could not read, say
|
|
47
|
+
// that, and say nothing about what is stored.
|
|
48
|
+
if (memoryLongTerm === null) {
|
|
49
|
+
return `=== Context (your persistent memory) ===\n`
|
|
50
|
+
+ `(unreadable this turn — the memory read failed, so this says NOTHING `
|
|
51
|
+
+ `about what you have saved. Do not treat it as empty and do not re-save `
|
|
52
|
+
+ `state you may already hold.)\n`
|
|
53
|
+
+ `=== Current turn ===\n${prompt}`;
|
|
54
|
+
}
|
|
55
|
+
if (memoryLongTerm) {
|
|
56
|
+
return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
|
|
57
|
+
}
|
|
58
|
+
return `=== Context (your persistent memory) ===\n`
|
|
59
|
+
+ `(empty — nothing has ever been saved here)\n`
|
|
60
|
+
+ `Only the \`long_term\` section is read back into this prompt. To make `
|
|
61
|
+
+ `something survive your next session, call commonly_save_my_memory({ `
|
|
62
|
+
+ `section: 'long_term', content: '...' }). A write to any other section `
|
|
63
|
+
+ `succeeds and is never shown to you again.\n`
|
|
64
|
+
+ `=== Current turn ===\n${prompt}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
24
67
|
export const readLongTerm = async (client, { onError } = {}) => {
|
|
25
68
|
try {
|
|
26
69
|
const body = await client.get('/api/agents/runtime/memory');
|
|
27
70
|
return body?.sections?.long_term?.content || '';
|
|
28
71
|
} catch (err) {
|
|
29
|
-
// A
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
72
|
+
// A 404 is the ONLY error that means what '' means: a fresh agent with no
|
|
73
|
+
// memory row yet. The kernel upserts on first write, so this is genuine
|
|
74
|
+
// absence and the empty cue is true.
|
|
75
|
+
if (err?.status === 404) return '';
|
|
76
|
+
// Everything else — auth revoked, 500, connection refused — is a failure to
|
|
77
|
+
// READ, which tells us nothing about what is stored. Returning '' here made
|
|
78
|
+
// the caller assert emptiness on no evidence.
|
|
79
|
+
//
|
|
80
|
+
// `err.status` is undefined for a transport failure, so the old guard
|
|
81
|
+
// (`err?.status && err.status !== 404`) skipped onError precisely when the
|
|
82
|
+
// backend was unreachable: the loudest condition was the silent one, and
|
|
83
|
+
// nothing contradicted the false cue.
|
|
84
|
+
onError?.(err);
|
|
85
|
+
return null;
|
|
38
86
|
}
|
|
39
87
|
};
|
|
40
88
|
|
|
@@ -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;
|