@myvillage/cli 1.89.2 → 1.90.0
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
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// ── Agent log shipper ────────────────────────────────────────────────────────
|
|
2
|
+
// Ships a rolling tail of each leased agent's logs to the control plane, so an
|
|
3
|
+
// agent running on this machine can be read from Studio on another one without
|
|
4
|
+
// SSH.
|
|
5
|
+
//
|
|
6
|
+
// Design notes worth knowing before editing:
|
|
7
|
+
//
|
|
8
|
+
// * Recent, not complete. Only the newest lines are shipped and the server
|
|
9
|
+
// keeps a bounded window per agent. Full history stays here on disk and is
|
|
10
|
+
// read with `myvillage agent logs`. This is what makes the whole thing scale
|
|
11
|
+
// with fleet size instead of uptime.
|
|
12
|
+
//
|
|
13
|
+
// * Batched per machine. One request carries every leased agent's delta, so
|
|
14
|
+
// request rate tracks machine count, not agent count.
|
|
15
|
+
//
|
|
16
|
+
// * Never on the heartbeat. Lease renewal is correctness-critical; log volume
|
|
17
|
+
// must not be able to slow it down or fail it.
|
|
18
|
+
//
|
|
19
|
+
// * Secrets are masked HERE, before anything leaves the box. Tool arguments
|
|
20
|
+
// and results do ship (they are the point), but .env values and the OAuth
|
|
21
|
+
// token never do.
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import os from 'os';
|
|
25
|
+
import { getAgentDir } from '../utils/local-agent.js';
|
|
26
|
+
import { getAccessToken } from '../utils/auth.js';
|
|
27
|
+
|
|
28
|
+
const CURSOR_FILE = path.join(os.homedir(), '.myvillage', 'log-cursors.json');
|
|
29
|
+
|
|
30
|
+
/** Per-stream ship caps. Must not exceed the server's retention, or we would be
|
|
31
|
+
* uploading lines it is about to delete. */
|
|
32
|
+
const SHIP_CAP = { ACTIVITY: 300, STDERR: 100 };
|
|
33
|
+
|
|
34
|
+
/** Hard ceiling on one request across all agents; mirrors the server's cap. */
|
|
35
|
+
const MAX_LINES_PER_PUSH = 2000;
|
|
36
|
+
|
|
37
|
+
/** Shortest .env value we will mask. Below this you get false positives that
|
|
38
|
+
* black out ordinary words — a var set to "true" or a family name. */
|
|
39
|
+
const MIN_SECRET_LEN = 8;
|
|
40
|
+
|
|
41
|
+
const DATE_LOG_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
42
|
+
|
|
43
|
+
// ── Cursor persistence ───────────────────────────────────────────────────────
|
|
44
|
+
// Keyed `${agentProfileId}:${stream}` → { file, offset }. Persisted so a runner
|
|
45
|
+
// restart resumes mid-file instead of re-shipping a tail we already sent.
|
|
46
|
+
|
|
47
|
+
function readCursors() {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf-8'));
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeCursors(cursors) {
|
|
56
|
+
try {
|
|
57
|
+
fs.mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
|
|
58
|
+
fs.writeFileSync(CURSOR_FILE, JSON.stringify(cursors, null, 2), { mode: 0o600 });
|
|
59
|
+
} catch {
|
|
60
|
+
/* best-effort: a lost cursor costs one duplicated tail, not correctness */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const key = (agentProfileId, stream) => `${agentProfileId}:${stream}`;
|
|
65
|
+
|
|
66
|
+
// ── Redaction ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Literal secrets for one agent: its own .env values plus this machine's OAuth
|
|
70
|
+
* token. Values only — keys stay readable, so `GITHUB_TOKEN=••••` still tells
|
|
71
|
+
* you which credential is in play.
|
|
72
|
+
*/
|
|
73
|
+
function secretsForAgent(agentName) {
|
|
74
|
+
const out = [];
|
|
75
|
+
try {
|
|
76
|
+
const envPath = path.join(getAgentDir(agentName), '.env');
|
|
77
|
+
const text = fs.readFileSync(envPath, 'utf-8');
|
|
78
|
+
for (const line of text.split('\n')) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
81
|
+
const eq = trimmed.indexOf('=');
|
|
82
|
+
if (eq <= 0) continue;
|
|
83
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
84
|
+
// Strip matching surrounding quotes so we mask the value, not the quoting.
|
|
85
|
+
if (value.length >= 2 && /^(".*"|'.*')$/s.test(value)) value = value.slice(1, -1);
|
|
86
|
+
if (value.length >= MIN_SECRET_LEN) out.push(value);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
/* no .env, or unreadable — nothing agent-specific to mask */
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const token = getAccessToken();
|
|
93
|
+
if (token && token.length >= MIN_SECRET_LEN) out.push(token);
|
|
94
|
+
} catch {
|
|
95
|
+
/* not signed in */
|
|
96
|
+
}
|
|
97
|
+
// Longest first, so a value containing another is masked whole.
|
|
98
|
+
return out.sort((a, b) => b.length - a.length);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Shapes that look like credentials even when we have never seen the value —
|
|
102
|
+
* a token echoed back by a third-party API, for instance. */
|
|
103
|
+
const SECRET_PATTERNS = [
|
|
104
|
+
/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
|
|
105
|
+
/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
|
|
106
|
+
/\bAKIA[0-9A-Z]{12,}\b/g,
|
|
107
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
108
|
+
/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, // JWT
|
|
109
|
+
/\b[Bb]earer\s+[A-Za-z0-9._~+/-]{20,}=*/g,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
function escapeRegExp(s) {
|
|
113
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mask every known secret and credential-shaped token in a line.
|
|
118
|
+
*
|
|
119
|
+
* Runs on every shipped line, so it must be cheap and must never throw — a
|
|
120
|
+
* redaction bug that crashes the shipper would take log visibility down, and one
|
|
121
|
+
* that silently returns the input would leak. On failure we drop the line rather
|
|
122
|
+
* than ship it unmasked.
|
|
123
|
+
*/
|
|
124
|
+
export function redactSecrets(line, secrets) {
|
|
125
|
+
let out = line;
|
|
126
|
+
for (const secret of secrets) {
|
|
127
|
+
if (!secret) continue;
|
|
128
|
+
out = out.split(secret).join('••••');
|
|
129
|
+
}
|
|
130
|
+
for (const re of SECRET_PATTERNS) out = out.replace(re, '••••');
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Tailing ──────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** The daemon writes `logs/<YYYY-MM-DD>.jsonl` and rolls over at UTC midnight.
|
|
137
|
+
* ISO names sort chronologically, so lexical max is the current file. */
|
|
138
|
+
function latestActivityFile(logsDir) {
|
|
139
|
+
try {
|
|
140
|
+
const names = fs.readdirSync(logsDir).filter((n) => DATE_LOG_RE.test(n));
|
|
141
|
+
if (names.length === 0) return null;
|
|
142
|
+
names.sort();
|
|
143
|
+
return names[names.length - 1];
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sourceFileFor(agentName, stream) {
|
|
150
|
+
const logsDir = path.join(getAgentDir(agentName), 'logs');
|
|
151
|
+
if (stream === 'STDERR') {
|
|
152
|
+
const f = path.join(logsDir, 'daemon-stderr.log');
|
|
153
|
+
return fs.existsSync(f) ? { dir: logsDir, name: 'daemon-stderr.log', full: f } : null;
|
|
154
|
+
}
|
|
155
|
+
const name = latestActivityFile(logsDir);
|
|
156
|
+
return name ? { dir: logsDir, name, full: path.join(logsDir, name) } : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Read the lines this agent has produced since our cursor.
|
|
161
|
+
*
|
|
162
|
+
* Resets to the tail (not the top) whenever continuity is broken: a new day's
|
|
163
|
+
* file, a truncated file, or no cursor at all. That is the backlog cap — a
|
|
164
|
+
* machine that was offline for a day ships its recent tail, never a day of
|
|
165
|
+
* backlog, so a fleet reconnecting after an outage cannot stampede the endpoint.
|
|
166
|
+
*/
|
|
167
|
+
function readNewLines(agentName, stream, cursor) {
|
|
168
|
+
const src = sourceFileFor(agentName, stream);
|
|
169
|
+
if (!src) return null;
|
|
170
|
+
|
|
171
|
+
let size;
|
|
172
|
+
try {
|
|
173
|
+
size = fs.statSync(src.full).size;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const continuous =
|
|
179
|
+
cursor && cursor.file === src.name && typeof cursor.offset === 'number' && cursor.offset <= size;
|
|
180
|
+
const start = continuous ? cursor.offset : 0;
|
|
181
|
+
if (continuous && start === size) return null; // nothing new
|
|
182
|
+
|
|
183
|
+
let text = '';
|
|
184
|
+
const len = size - start;
|
|
185
|
+
if (len > 0) {
|
|
186
|
+
let fd;
|
|
187
|
+
try {
|
|
188
|
+
fd = fs.openSync(src.full, 'r');
|
|
189
|
+
const buf = Buffer.alloc(len);
|
|
190
|
+
fs.readSync(fd, buf, 0, len, start);
|
|
191
|
+
text = buf.toString('utf8');
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
} finally {
|
|
195
|
+
if (fd !== undefined) {
|
|
196
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let lines = text.split('\n').filter((l) => l.trim().length > 0);
|
|
202
|
+
const cap = SHIP_CAP[stream];
|
|
203
|
+
// On a discontinuity we may be looking at a whole file; keep only the tail.
|
|
204
|
+
if (lines.length > cap) lines = lines.slice(-cap);
|
|
205
|
+
|
|
206
|
+
return { lines, file: src.name, offset: size };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Pull the line's own timestamp out of the JSONL, so a backlog shipped after a
|
|
210
|
+
* reconnect keeps its real times instead of collapsing onto arrival. */
|
|
211
|
+
function timestampOf(payload) {
|
|
212
|
+
if (payload.charCodeAt(0) !== 123 /* { */) return null;
|
|
213
|
+
try {
|
|
214
|
+
const obj = JSON.parse(payload);
|
|
215
|
+
const ts = obj.ts ?? obj.timestamp ?? obj.time;
|
|
216
|
+
return typeof ts === 'string' ? ts : null;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Shipper ──────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Ship one round for every agent this runner holds a lease on.
|
|
226
|
+
*
|
|
227
|
+
* `leases` is the runner's Map of agentProfileId → { name }. Only lease holders
|
|
228
|
+
* may ship, which the server enforces too — the same lease that guarantees
|
|
229
|
+
* single-active execution guarantees a single writer per agent's log.
|
|
230
|
+
*
|
|
231
|
+
* Bootstrap: on the first round after start we send a zero-line batch to learn
|
|
232
|
+
* the server's cursor. If it says this machine wrote it, we adopt the offset and
|
|
233
|
+
* carry on mid-file; otherwise we start from the tail. That is what keeps a
|
|
234
|
+
* runner restart from re-shipping (and thus duplicating) the last few hundred
|
|
235
|
+
* lines it had already sent.
|
|
236
|
+
*/
|
|
237
|
+
export async function shipLogsOnce(ctx) {
|
|
238
|
+
if (!ctx.leases || ctx.leases.size === 0) return { pushed: 0 };
|
|
239
|
+
|
|
240
|
+
const cursors = readCursors();
|
|
241
|
+
const bootstrap = !ctx.logShipperBootstrapped;
|
|
242
|
+
const batches = [];
|
|
243
|
+
let budget = MAX_LINES_PER_PUSH;
|
|
244
|
+
|
|
245
|
+
for (const [agentProfileId, lease] of ctx.leases) {
|
|
246
|
+
let secrets = null;
|
|
247
|
+
for (const stream of ['ACTIVITY', 'STDERR']) {
|
|
248
|
+
if (budget <= 0) break;
|
|
249
|
+
const k = key(agentProfileId, stream);
|
|
250
|
+
|
|
251
|
+
// First round: ask for the cursor without shipping anything.
|
|
252
|
+
if (bootstrap && !cursors[k]) {
|
|
253
|
+
batches.push({ agentProfileId, stream, lines: [] });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const found = readNewLines(lease.name, stream, cursors[k]);
|
|
258
|
+
if (!found || found.lines.length === 0) continue;
|
|
259
|
+
|
|
260
|
+
if (secrets === null) secrets = secretsForAgent(lease.name);
|
|
261
|
+
|
|
262
|
+
const lines = [];
|
|
263
|
+
for (const raw of found.lines) {
|
|
264
|
+
if (budget <= 0) break;
|
|
265
|
+
let payload;
|
|
266
|
+
try {
|
|
267
|
+
payload = redactSecrets(raw, secrets);
|
|
268
|
+
} catch {
|
|
269
|
+
continue; // never ship a line we failed to mask
|
|
270
|
+
}
|
|
271
|
+
lines.push({ payload, ts: timestampOf(raw) });
|
|
272
|
+
budget--;
|
|
273
|
+
}
|
|
274
|
+
if (lines.length === 0) continue;
|
|
275
|
+
|
|
276
|
+
batches.push({
|
|
277
|
+
agentProfileId,
|
|
278
|
+
stream,
|
|
279
|
+
lines,
|
|
280
|
+
sourceFile: found.file,
|
|
281
|
+
sourceOffset: found.offset,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (batches.length === 0) {
|
|
287
|
+
ctx.logShipperBootstrapped = true;
|
|
288
|
+
return { pushed: 0 };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const res = await ctx.cp(`/machines/${ctx.machineId}/logs`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
body: { agents: batches },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Leave cursors untouched on failure so the next round retries the same
|
|
297
|
+
// range. Re-sending is safe; losing lines is not.
|
|
298
|
+
if (!res.ok) return { pushed: 0, error: res.json?.error || res.status };
|
|
299
|
+
|
|
300
|
+
// Advance our cursors only for batches the server accepted.
|
|
301
|
+
const rejected = new Set(
|
|
302
|
+
(res.json?.data?.cursors || [])
|
|
303
|
+
.filter((c) => c && c.rejected)
|
|
304
|
+
.map((c) => key(c.agentProfileId, c.stream)),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
let pushed = 0;
|
|
308
|
+
for (const b of batches) {
|
|
309
|
+
const k = key(b.agentProfileId, b.stream);
|
|
310
|
+
if (rejected.has(k)) continue;
|
|
311
|
+
if (b.sourceFile) {
|
|
312
|
+
cursors[k] = { file: b.sourceFile, offset: b.sourceOffset };
|
|
313
|
+
pushed += b.lines.length;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Adopt the server's position for bootstrap batches, but only where this
|
|
318
|
+
// machine is the one that wrote it — another machine's byte offset means
|
|
319
|
+
// nothing against our filesystem.
|
|
320
|
+
if (bootstrap) {
|
|
321
|
+
for (const c of res.json?.data?.cursors || []) {
|
|
322
|
+
if (!c || c.rejected) continue;
|
|
323
|
+
const k = key(c.agentProfileId, c.stream);
|
|
324
|
+
if (cursors[k]) continue;
|
|
325
|
+
if (c.lastMachineId === ctx.machineId && c.lastFile) {
|
|
326
|
+
cursors[k] = { file: c.lastFile, offset: c.lastOffset ?? 0 };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
ctx.logShipperBootstrapped = true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
writeCursors(cursors);
|
|
333
|
+
return { pushed };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Forget an agent's cursors — used when a lease is released, so if the agent
|
|
337
|
+
* comes back here later we re-establish position rather than trusting an offset
|
|
338
|
+
* into a file another machine has since been writing. */
|
|
339
|
+
export function forgetLogCursor(agentProfileId) {
|
|
340
|
+
const cursors = readCursors();
|
|
341
|
+
let changed = false;
|
|
342
|
+
for (const stream of ['ACTIVITY', 'STDERR']) {
|
|
343
|
+
const k = key(agentProfileId, stream);
|
|
344
|
+
if (cursors[k]) {
|
|
345
|
+
delete cursors[k];
|
|
346
|
+
changed = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (changed) writeCursors(cursors);
|
|
350
|
+
}
|
|
@@ -26,11 +26,18 @@ import { getAccessToken, isTokenExpired } from '../utils/auth.js';
|
|
|
26
26
|
import { refreshAccessToken } from '../utils/api.js';
|
|
27
27
|
import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
|
|
28
28
|
import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
|
|
29
|
+
import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
|
|
29
30
|
|
|
30
31
|
const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
|
|
31
32
|
const DEFAULT_HEARTBEAT_MS = 20_000;
|
|
32
33
|
const LEASE_TTL_SECONDS = 90;
|
|
33
34
|
|
|
35
|
+
// Log shipping runs on its own clock, faster than reconcile so a remote tail
|
|
36
|
+
// feels live, and in its own loop so a slow or failing log push can never delay
|
|
37
|
+
// a heartbeat. Lease renewal rides the heartbeat, and single-active correctness
|
|
38
|
+
// depends on it — logs must never be able to hold it up.
|
|
39
|
+
const LOG_SHIP_MS = 5_000;
|
|
40
|
+
|
|
34
41
|
function log(msg, extra) {
|
|
35
42
|
const line = `[runner] ${new Date().toISOString()} ${msg}`;
|
|
36
43
|
if (extra !== undefined) console.log(line, extra);
|
|
@@ -402,6 +409,10 @@ async function reconcileOnce(ctx) {
|
|
|
402
409
|
try { stopDaemon(lease.name); } catch { /* best-effort */ }
|
|
403
410
|
await ctx.cp(`/agents/${profileId}/lease`, { method: 'POST', body: { action: 'release' } });
|
|
404
411
|
ctx.leases.delete(profileId);
|
|
412
|
+
// Drop our log position too: if this agent is placed here again later,
|
|
413
|
+
// another machine will have been writing its tail in the meantime, so our
|
|
414
|
+
// old byte offset would be meaningless.
|
|
415
|
+
forgetLogCursor(profileId);
|
|
405
416
|
log(`stopped + released ${lease.name} (no longer assigned here)`);
|
|
406
417
|
}
|
|
407
418
|
}
|
|
@@ -449,6 +460,23 @@ export async function runRunner(opts = {}) {
|
|
|
449
460
|
|
|
450
461
|
const intervalMs = opts.intervalMs || DEFAULT_HEARTBEAT_MS;
|
|
451
462
|
log(`reconcile loop every ${Math.round(intervalMs / 1000)}s (lease TTL ${LEASE_TTL_SECONDS}s)`);
|
|
463
|
+
|
|
464
|
+
// Independent loop, deliberately not awaited by the reconcile loop below.
|
|
465
|
+
// A log push that hangs or errors must not delay lease renewal.
|
|
466
|
+
const shipLoop = (async () => {
|
|
467
|
+
while (!controller.signal.aborted) {
|
|
468
|
+
await sleep(LOG_SHIP_MS, controller.signal);
|
|
469
|
+
if (controller.signal.aborted) break;
|
|
470
|
+
try {
|
|
471
|
+
await shipLogsOnce(ctx);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
// Logs are best-effort. Cursors only advance on a confirmed write, so a
|
|
474
|
+
// failed round is retried next tick rather than losing lines.
|
|
475
|
+
log('log ship error', err?.message || err);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
})();
|
|
479
|
+
|
|
452
480
|
while (!controller.signal.aborted) {
|
|
453
481
|
try {
|
|
454
482
|
await reconcileOnce(ctx);
|
|
@@ -457,5 +485,6 @@ export async function runRunner(opts = {}) {
|
|
|
457
485
|
}
|
|
458
486
|
if (!controller.signal.aborted) await sleep(intervalMs, controller.signal);
|
|
459
487
|
}
|
|
488
|
+
await shipLoop;
|
|
460
489
|
log('runner stopped');
|
|
461
490
|
}
|
|
@@ -46,8 +46,23 @@ export function isWithinActiveHours(schedule) {
|
|
|
46
46
|
return currentTime >= startTime || currentTime <= endTime;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Floor on how often an agent may loop, in minutes.
|
|
51
|
+
*
|
|
52
|
+
* These agents act on human-paced events — a knowledge submission, a activity
|
|
53
|
+
* completion, an email — so anything under a minute is burning tokens and log
|
|
54
|
+
* volume against a world that has not changed. It also protects the fleet: log
|
|
55
|
+
* shipping and MVT spend both scale with agents × loops-per-hour, so a single
|
|
56
|
+
* misconfigured 15-second agent multiplied across a large fleet is what turns a
|
|
57
|
+
* comfortable load into an expensive one.
|
|
58
|
+
*
|
|
59
|
+
* Not an error, deliberately. An existing agent set faster than this keeps
|
|
60
|
+
* running, just at the floor, rather than failing to start.
|
|
61
|
+
*/
|
|
62
|
+
export const MIN_CHECK_IN_MINUTES = 1;
|
|
63
|
+
|
|
49
64
|
export function getNextCheckInMs(schedule) {
|
|
50
65
|
const interval = schedule?.check_in_interval;
|
|
51
66
|
if (!interval || interval <= 0) return 0; // manual only
|
|
52
|
-
return interval * 60 * 1000;
|
|
67
|
+
return Math.max(interval, MIN_CHECK_IN_MINUTES) * 60 * 1000;
|
|
53
68
|
}
|