@teamvibe/poller 0.6.2 → 0.7.1
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/dist/brain-manager.d.ts +43 -1
- package/dist/brain-manager.js +259 -6
- package/dist/brains-bootstrap.d.ts +45 -0
- package/dist/brains-bootstrap.js +217 -0
- package/dist/classify-failed-iteration.d.ts +1 -1
- package/dist/classify-failed-iteration.js +33 -1
- package/dist/claude-spawner.d.ts +179 -1
- package/dist/claude-spawner.js +697 -92
- package/dist/config.d.ts +40 -0
- package/dist/config.js +32 -2
- package/dist/cron-telemetry.d.ts +3 -0
- package/dist/cron-telemetry.js +16 -0
- package/dist/event-queue.d.ts +30 -0
- package/dist/event-queue.js +4 -0
- package/dist/idle-restart.d.ts +12 -0
- package/dist/idle-restart.js +200 -0
- package/dist/inbox-manager.d.ts +25 -0
- package/dist/inbox-manager.js +47 -0
- package/dist/maintenance-activity.d.ts +18 -0
- package/dist/maintenance-activity.js +28 -0
- package/dist/maintenance-cron.d.ts +73 -0
- package/dist/maintenance-cron.js +85 -1
- package/dist/metrics/collector.d.ts +2 -0
- package/dist/metrics/collector.js +7 -0
- package/dist/poller.d.ts +12 -0
- package/dist/poller.js +345 -8
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/brain-manager.d.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
export declare function withBrainLock<T>(brainId: string, fn: () => Promise<T>): Promise<T>;
|
|
1
2
|
interface BrainConfig {
|
|
2
3
|
brainId: string;
|
|
3
4
|
gitRepoUrl: string;
|
|
4
5
|
branch: string;
|
|
5
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Remove stale git lock files (tv#76) so an orphaned lock from a killed
|
|
9
|
+
* process (e.g. the 2026-05-01 outage) doesn't block brain updates/pushes
|
|
10
|
+
* indefinitely. Fresh locks (younger than the threshold) are left alone —
|
|
11
|
+
* they may belong to a git operation that's genuinely still running.
|
|
12
|
+
* A missing lock (stat fails) is a silent no-op. A genuine removal failure
|
|
13
|
+
* (unlink fails, e.g. permission denied) is logged via logger.warn with the
|
|
14
|
+
* path and error — that's the exact "lock exists but can't be cleared"
|
|
15
|
+
* scenario this fix exists to make visible.
|
|
16
|
+
*
|
|
17
|
+
* Exported for testing — production call sites go through updateBrain /
|
|
18
|
+
* pushBrainChanges below.
|
|
19
|
+
*/
|
|
20
|
+
export declare function cleanupStaleLocks(brainDir: string): Promise<void>;
|
|
6
21
|
export declare function getBrainPath(brain?: BrainConfig, channelId?: string, workspaceId?: string): Promise<string>;
|
|
7
22
|
/**
|
|
8
23
|
* Update a brain repo (respects cooldown). Exported for testing — production
|
|
@@ -18,11 +33,38 @@ export declare function ensureBaseBrain(): Promise<void>;
|
|
|
18
33
|
* Get the path to the base brain directory.
|
|
19
34
|
*/
|
|
20
35
|
export declare function getBaseBrainPath(): string;
|
|
36
|
+
/**
|
|
37
|
+
* Scan the commits about to be pushed for blobs over `maxBytes`. Returns the
|
|
38
|
+
* offending paths/sizes, or an empty array if everything is under the limit.
|
|
39
|
+
*
|
|
40
|
+
* Scans `@{u}..HEAD` when an upstream exists (the actual new content being
|
|
41
|
+
* pushed); falls back to all objects reachable from HEAD for a branch's first
|
|
42
|
+
* push (no upstream yet), which is equivalent since nothing has been pushed.
|
|
43
|
+
*/
|
|
44
|
+
export declare function findOversizedBlobs(brainDir: string, maxBytes: number): Promise<Array<{
|
|
45
|
+
path: string;
|
|
46
|
+
size: number;
|
|
47
|
+
}>>;
|
|
21
48
|
/**
|
|
22
49
|
* Commit and push any changes in a brain repo.
|
|
23
50
|
* Silently skips if there are no changes or no remote.
|
|
51
|
+
*
|
|
52
|
+
* On non-fast-forward rejection (tv#232 push-race — remote advanced between
|
|
53
|
+
* local commit and push, e.g. a concurrent session or scheduled-tick already
|
|
54
|
+
* pushed), fetch+rebase and retry, bounded by BRAIN_PUSH_RETRY_MAX. A genuine
|
|
55
|
+
* rebase conflict (or an autostash pop that silently leaves changes stashed —
|
|
56
|
+
* git does not surface that as a rebase error) is treated as a hard failure
|
|
57
|
+
* needing manual sync, not retried further.
|
|
58
|
+
*
|
|
59
|
+
* `retryOptions` defaults to the BRAIN_PUSH_RETRY_* config; exposed as a
|
|
60
|
+
* parameter (rather than reading `config` directly inline) so tests can
|
|
61
|
+
* exercise the retry-exhaustion boundary deterministically without relying on
|
|
62
|
+
* process-wide env/module-load-order tricks.
|
|
24
63
|
*/
|
|
25
|
-
export declare function pushBrainChanges(brainDir: string, brainId: string, workspaceId?: string
|
|
64
|
+
export declare function pushBrainChanges(brainDir: string, brainId: string, workspaceId?: string, retryOptions?: {
|
|
65
|
+
maxAttempts?: number;
|
|
66
|
+
backoffMs?: number;
|
|
67
|
+
}): Promise<void>;
|
|
26
68
|
/**
|
|
27
69
|
* Ensure base paths exist
|
|
28
70
|
*/
|
package/dist/brain-manager.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { exec } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import { existsSync, mkdirSync } from 'fs';
|
|
4
|
-
import {
|
|
4
|
+
import { readdir, stat, unlink } from 'fs/promises';
|
|
5
|
+
import { join, basename } from 'path';
|
|
5
6
|
import { config } from './config.js';
|
|
6
7
|
import { logger } from './logger.js';
|
|
7
8
|
import { emitError } from './event-queue.js';
|
|
@@ -20,8 +21,84 @@ if (config.PERSISTENT_STORAGE_PATH) {
|
|
|
20
21
|
function gitExec(command, options) {
|
|
21
22
|
return execAsync(command, { ...options, env: gitEnv });
|
|
22
23
|
}
|
|
24
|
+
// Node's child_process.exec() defaults to a 1MB stdout cap. This bounds the
|
|
25
|
+
// text output of a git plumbing command (e.g. one line per object listed),
|
|
26
|
+
// not any single blob's size -- it has no relationship to
|
|
27
|
+
// config.BRAIN_MAX_BLOB_BYTES and must stay its own named constant so the
|
|
28
|
+
// two don't drift into looking coupled just because they're both 50MB today.
|
|
29
|
+
const GIT_SCAN_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
23
30
|
// Cooldown tracker per brain
|
|
24
31
|
const lastUpdateTimes = new Map();
|
|
32
|
+
// Per-brainId mutex serializing updateBrain/pushBrainChanges for a given
|
|
33
|
+
// brain. Two threads of the SAME brain running concurrently in this process
|
|
34
|
+
// share one brainDir (tv#76/tv#277 review) — without this, cleanupStaleLocks()
|
|
35
|
+
// can unlink a git lock file that a still-in-flight sibling call's own git
|
|
36
|
+
// operation legitimately holds, corrupting the index. Poller instances never
|
|
37
|
+
// share a physical brainDir across processes/machines (BRAINS_PATH is
|
|
38
|
+
// $HOME-derived and each poller is a single process — see tv#287), so this
|
|
39
|
+
// in-process mutex fully closes the race; no DynamoDB-level lock is needed.
|
|
40
|
+
const brainLocks = new Map();
|
|
41
|
+
// Exported for testing — production call sites go through updateBrain / pushBrainChanges above.
|
|
42
|
+
export function withBrainLock(brainId, fn) {
|
|
43
|
+
const prior = brainLocks.get(brainId) ?? Promise.resolve();
|
|
44
|
+
const run = prior.then(fn, fn);
|
|
45
|
+
brainLocks.set(brainId, run.then(() => undefined, () => undefined));
|
|
46
|
+
return run;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove stale git lock files (tv#76) so an orphaned lock from a killed
|
|
50
|
+
* process (e.g. the 2026-05-01 outage) doesn't block brain updates/pushes
|
|
51
|
+
* indefinitely. Fresh locks (younger than the threshold) are left alone —
|
|
52
|
+
* they may belong to a git operation that's genuinely still running.
|
|
53
|
+
* A missing lock (stat fails) is a silent no-op. A genuine removal failure
|
|
54
|
+
* (unlink fails, e.g. permission denied) is logged via logger.warn with the
|
|
55
|
+
* path and error — that's the exact "lock exists but can't be cleared"
|
|
56
|
+
* scenario this fix exists to make visible.
|
|
57
|
+
*
|
|
58
|
+
* Exported for testing — production call sites go through updateBrain /
|
|
59
|
+
* pushBrainChanges below.
|
|
60
|
+
*/
|
|
61
|
+
export async function cleanupStaleLocks(brainDir) {
|
|
62
|
+
const candidates = [
|
|
63
|
+
{ path: join(brainDir, '.git', 'index.lock'), maxAgeMs: config.GIT_LOCK_MAX_AGE_MS },
|
|
64
|
+
];
|
|
65
|
+
const modulesDir = join(brainDir, '.git', 'modules');
|
|
66
|
+
try {
|
|
67
|
+
const entries = await readdir(modulesDir, { recursive: true });
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (basename(entry) === 'config.lock') {
|
|
70
|
+
candidates.push({ path: join(modulesDir, entry), maxAgeMs: config.GIT_SUBMODULE_LOCK_MAX_AGE_MS });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// No .git/modules dir (no submodules yet) — nothing to scan.
|
|
76
|
+
}
|
|
77
|
+
for (const { path: lockPath, maxAgeMs } of candidates) {
|
|
78
|
+
let lockStat;
|
|
79
|
+
try {
|
|
80
|
+
lockStat = await stat(lockPath);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Lock doesn't exist — normal no-op, nothing worth logging.
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const ageMs = Date.now() - lockStat.mtimeMs;
|
|
87
|
+
if (ageMs <= maxAgeMs)
|
|
88
|
+
continue;
|
|
89
|
+
try {
|
|
90
|
+
await unlink(lockPath);
|
|
91
|
+
logger.warn(`Removed stale git lock: ${lockPath} (age: ${Math.round(ageMs / 1000)}s, threshold: ${Math.round(maxAgeMs / 1000)}s)`);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// Real removal failure (e.g. permission denied) — this is the exact
|
|
95
|
+
// 2026-05-01 outage scenario, so it must leave a diagnostic trail
|
|
96
|
+
// instead of silently skipping like a plain "lock doesn't exist".
|
|
97
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
98
|
+
logger.warn(`Failed to remove stale git lock: ${lockPath} (age: ${Math.round(ageMs / 1000)}s): ${errorMessage}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
25
102
|
/**
|
|
26
103
|
* Get or clone brain repo. Returns the working directory path.
|
|
27
104
|
*/
|
|
@@ -94,6 +171,9 @@ export async function getBrainPath(brain, channelId, workspaceId) {
|
|
|
94
171
|
* call sites should go through `getBrainPath` / `ensureBaseBrain`.
|
|
95
172
|
*/
|
|
96
173
|
export async function updateBrain(brainDir, brainId, branch) {
|
|
174
|
+
return withBrainLock(brainId, () => updateBrainLocked(brainDir, brainId, branch));
|
|
175
|
+
}
|
|
176
|
+
async function updateBrainLocked(brainDir, brainId, branch) {
|
|
97
177
|
const now = Date.now();
|
|
98
178
|
const lastUpdate = lastUpdateTimes.get(brainId) || 0;
|
|
99
179
|
if (now - lastUpdate < config.BRAIN_UPDATE_COOLDOWN_MS) {
|
|
@@ -137,6 +217,7 @@ export async function updateBrain(brainDir, brainId, branch) {
|
|
|
137
217
|
logger.warn(`Brain ${brainId} pre-check failed, skipping update: ${errorMessage}`);
|
|
138
218
|
return;
|
|
139
219
|
}
|
|
220
|
+
await cleanupStaleLocks(brainDir);
|
|
140
221
|
try {
|
|
141
222
|
logger.info(`Updating brain ${brainId} at ${brainDir}...`);
|
|
142
223
|
await gitExec(`git fetch origin ${branch} && git reset --hard origin/${branch} && git submodule update --init --recursive`, {
|
|
@@ -173,11 +254,73 @@ export async function ensureBaseBrain() {
|
|
|
173
254
|
export function getBaseBrainPath() {
|
|
174
255
|
return config.BASE_BRAIN_PATH;
|
|
175
256
|
}
|
|
257
|
+
// Matches git's non-fast-forward push rejection ("! [rejected] ... (fetch first)"
|
|
258
|
+
// or "... (non-fast-forward)"). Distinguishes a push-race (remote advanced,
|
|
259
|
+
// reconcilable via rebase) from other push failures (network, auth, oversized
|
|
260
|
+
// blob pre-receive decline — see tv#246) which retrying can never fix.
|
|
261
|
+
const NON_FF_REJECTION_RE = /\[rejected\][\s\S]*(fetch first|non-fast-forward)/;
|
|
262
|
+
async function gitStashList(brainDir) {
|
|
263
|
+
const { stdout } = await gitExec('git stash list', { cwd: brainDir });
|
|
264
|
+
return stdout.trim();
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Scan the commits about to be pushed for blobs over `maxBytes`. Returns the
|
|
268
|
+
* offending paths/sizes, or an empty array if everything is under the limit.
|
|
269
|
+
*
|
|
270
|
+
* Scans `@{u}..HEAD` when an upstream exists (the actual new content being
|
|
271
|
+
* pushed); falls back to all objects reachable from HEAD for a branch's first
|
|
272
|
+
* push (no upstream yet), which is equivalent since nothing has been pushed.
|
|
273
|
+
*/
|
|
274
|
+
export async function findOversizedBlobs(brainDir, maxBytes) {
|
|
275
|
+
let range = 'HEAD';
|
|
276
|
+
try {
|
|
277
|
+
await gitExec('git rev-parse @{u}', { cwd: brainDir });
|
|
278
|
+
range = '@{u}..HEAD';
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// No upstream tracking ref yet — first push for this branch, scan all of HEAD.
|
|
282
|
+
}
|
|
283
|
+
// A full-HEAD scan (the fallback path this guard needs to work on) can
|
|
284
|
+
// exceed Node's default 1MB stdout cap on a repo with many objects.
|
|
285
|
+
// Without a larger maxBuffer the scan throws ENOBUFS and the guard
|
|
286
|
+
// silently disables itself (see catch below) on exactly the histories most
|
|
287
|
+
// likely to contain an oversized blob.
|
|
288
|
+
const { stdout } = await gitExec(`git rev-list --objects ${range} | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)'`, { cwd: brainDir, maxBuffer: GIT_SCAN_MAX_BUFFER_BYTES });
|
|
289
|
+
const oversized = [];
|
|
290
|
+
for (const line of stdout.split('\n')) {
|
|
291
|
+
if (!line.trim())
|
|
292
|
+
continue;
|
|
293
|
+
const [type, , sizeStr, ...pathParts] = line.split(' ');
|
|
294
|
+
if (type !== 'blob')
|
|
295
|
+
continue;
|
|
296
|
+
const size = Number(sizeStr);
|
|
297
|
+
if (size > maxBytes) {
|
|
298
|
+
oversized.push({ path: pathParts.join(' ') || '(unknown path)', size });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return oversized;
|
|
302
|
+
}
|
|
176
303
|
/**
|
|
177
304
|
* Commit and push any changes in a brain repo.
|
|
178
305
|
* Silently skips if there are no changes or no remote.
|
|
306
|
+
*
|
|
307
|
+
* On non-fast-forward rejection (tv#232 push-race — remote advanced between
|
|
308
|
+
* local commit and push, e.g. a concurrent session or scheduled-tick already
|
|
309
|
+
* pushed), fetch+rebase and retry, bounded by BRAIN_PUSH_RETRY_MAX. A genuine
|
|
310
|
+
* rebase conflict (or an autostash pop that silently leaves changes stashed —
|
|
311
|
+
* git does not surface that as a rebase error) is treated as a hard failure
|
|
312
|
+
* needing manual sync, not retried further.
|
|
313
|
+
*
|
|
314
|
+
* `retryOptions` defaults to the BRAIN_PUSH_RETRY_* config; exposed as a
|
|
315
|
+
* parameter (rather than reading `config` directly inline) so tests can
|
|
316
|
+
* exercise the retry-exhaustion boundary deterministically without relying on
|
|
317
|
+
* process-wide env/module-load-order tricks.
|
|
179
318
|
*/
|
|
180
|
-
export async function pushBrainChanges(brainDir, brainId, workspaceId) {
|
|
319
|
+
export async function pushBrainChanges(brainDir, brainId, workspaceId, retryOptions) {
|
|
320
|
+
return withBrainLock(brainId, () => pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptions));
|
|
321
|
+
}
|
|
322
|
+
async function pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptions) {
|
|
323
|
+
await cleanupStaleLocks(brainDir);
|
|
181
324
|
try {
|
|
182
325
|
// Check if this is a git repo with a remote
|
|
183
326
|
try {
|
|
@@ -203,11 +346,121 @@ export async function pushBrainChanges(brainDir, brainId, workspaceId) {
|
|
|
203
346
|
logger.debug(`Brain ${brainId} has no unpushed commits`);
|
|
204
347
|
return;
|
|
205
348
|
}
|
|
349
|
+
// Guard: refuse to push if any blob about to be pushed exceeds the size limit.
|
|
350
|
+
// GitHub hard-rejects blobs over its limit, and once such a blob is committed
|
|
351
|
+
// the push can never succeed by retrying — it needs a history rewrite (tv#246).
|
|
352
|
+
// Skipping the push here keeps the brain retrying safely (and alerting) instead
|
|
353
|
+
// of hammering GitHub with a doomed push and losing the failure into poller.error.
|
|
354
|
+
try {
|
|
355
|
+
const oversized = await findOversizedBlobs(brainDir, config.BRAIN_MAX_BLOB_BYTES);
|
|
356
|
+
if (oversized.length > 0) {
|
|
357
|
+
const limitMb = (config.BRAIN_MAX_BLOB_BYTES / (1024 * 1024)).toFixed(0);
|
|
358
|
+
const details = oversized.map((o) => `${o.path} (${(o.size / (1024 * 1024)).toFixed(1)} MB)`).join(', ');
|
|
359
|
+
const message = `Brain ${brainId} push blocked — blob(s) over ${limitMb}MB limit in unpushed commits: ${details}`;
|
|
360
|
+
logger.error(message);
|
|
361
|
+
emitError('brain', message, { context: { brainId, oversizedPaths: oversized.map((o) => o.path) } });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
// Guard itself failed (e.g. git plumbing hiccup) — don't block a legitimate
|
|
367
|
+
// push on that; fall through to the push attempt as before this guard existed.
|
|
368
|
+
// Still surface it as an error (not just a warn log): a silently-disabled
|
|
369
|
+
// guard defeats the whole point of tv#246, so it needs the same alerting
|
|
370
|
+
// visibility as the case it's meant to catch.
|
|
371
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
372
|
+
const message = `Brain ${brainId} oversized-blob guard failed, proceeding without it: ${errorMessage}`;
|
|
373
|
+
logger.error(message);
|
|
374
|
+
emitError('brain', message, { context: { brainId } });
|
|
375
|
+
}
|
|
206
376
|
const branch = (await gitExec('git branch --show-current', { cwd: brainDir })).stdout.trim() || 'main';
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
377
|
+
const maxAttempts = retryOptions?.maxAttempts ?? config.BRAIN_PUSH_RETRY_MAX;
|
|
378
|
+
const backoffMs = retryOptions?.backoffMs ?? config.BRAIN_PUSH_RETRY_BACKOFF_MS;
|
|
379
|
+
let attempt = 0;
|
|
380
|
+
let lastErrorMessage = '';
|
|
381
|
+
while (attempt < maxAttempts) {
|
|
382
|
+
attempt++;
|
|
383
|
+
try {
|
|
384
|
+
await gitExec(`git push -u origin ${branch}`, { cwd: brainDir });
|
|
385
|
+
if (attempt > 1) {
|
|
386
|
+
logger.info(`Brain ${brainId} push-race auto-resolved after ${attempt} attempts`);
|
|
387
|
+
emitError('brain', `Brain ${brainId} push-race auto-resolved after ${attempt} attempts`, {
|
|
388
|
+
severity: 'warn',
|
|
389
|
+
context: { brainId, errorCode: 'PUSH_RACE_CONVERGED', attempts: attempt },
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
logger.info(`Brain ${brainId} changes pushed successfully`);
|
|
394
|
+
}
|
|
395
|
+
if (workspaceId)
|
|
396
|
+
reportBrainSync(brainId, workspaceId, 'pushed');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
lastErrorMessage = error instanceof Error ? error.message : String(error);
|
|
401
|
+
if (!NON_FF_REJECTION_RE.test(lastErrorMessage) || attempt >= maxAttempts)
|
|
402
|
+
break;
|
|
403
|
+
logger.info(`Brain ${brainId} push rejected (non-fast-forward), rebasing and retrying (attempt ${attempt}/${maxAttempts})...`);
|
|
404
|
+
try {
|
|
405
|
+
await gitExec(`git fetch origin ${branch}`, { cwd: brainDir });
|
|
406
|
+
}
|
|
407
|
+
catch (fetchError) {
|
|
408
|
+
// No rebase has started yet at this point, so `git rebase --abort`
|
|
409
|
+
// below would itself fail ("no rebase in progress") and mislabel a
|
|
410
|
+
// plain network/fetch failure as PUSH_RACE_ABORT_FAILED. Keep fetch
|
|
411
|
+
// failures in their own errorCode instead.
|
|
412
|
+
const fetchMessage = fetchError instanceof Error ? fetchError.message : String(fetchError);
|
|
413
|
+
logger.error(`Brain ${brainId} push-race: git fetch failed: ${fetchMessage}`);
|
|
414
|
+
emitError('brain', `Brain ${brainId} push-race: fetch failed, cannot rebase: ${fetchMessage}`, {
|
|
415
|
+
severity: 'error',
|
|
416
|
+
context: { brainId, errorCode: 'PUSH_RACE_FETCH_FAILED', attempts: attempt },
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const stashBefore = await gitStashList(brainDir);
|
|
421
|
+
try {
|
|
422
|
+
await gitExec(`git rebase --autostash origin/${branch}`, { cwd: brainDir });
|
|
423
|
+
// `git rebase --autostash` does NOT surface a stash-pop conflict as a
|
|
424
|
+
// rebase error — it just leaves the change stashed and warns on
|
|
425
|
+
// stderr. Detect that silently-failed pop by diffing the stash list;
|
|
426
|
+
// otherwise the loop would proceed to push with local work missing.
|
|
427
|
+
const stashAfter = await gitStashList(brainDir);
|
|
428
|
+
if (stashAfter !== stashBefore) {
|
|
429
|
+
logger.error(`Brain ${brainId} push-race: autostash pop left changes stashed after rebase, treating as conflict`);
|
|
430
|
+
emitError('brain', `Brain ${brainId} push-race autostash pop conflict — manual sync needed (stash: ${stashAfter.split('\n')[0]})`, { severity: 'error', context: { brainId, errorCode: 'PUSH_RACE_CONFLICT', attempts: attempt, reason: 'autostash_pop_conflict' } });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (rebaseError) {
|
|
435
|
+
try {
|
|
436
|
+
await gitExec('git rebase --abort', { cwd: brainDir });
|
|
437
|
+
}
|
|
438
|
+
catch (abortError) {
|
|
439
|
+
const abortMessage = abortError instanceof Error ? abortError.message : String(abortError);
|
|
440
|
+
logger.error(`Brain ${brainId} push-race: git rebase --abort itself failed, repo may be left mid-rebase: ${abortMessage}`);
|
|
441
|
+
emitError('brain', `Brain ${brainId} push-race: rebase abort failed, repo possibly stuck mid-rebase: ${abortMessage}`, {
|
|
442
|
+
severity: 'error',
|
|
443
|
+
context: { brainId, errorCode: 'PUSH_RACE_ABORT_FAILED', attempts: attempt },
|
|
444
|
+
});
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const rebaseMessage = rebaseError instanceof Error ? rebaseError.message : String(rebaseError);
|
|
448
|
+
logger.error(`Brain ${brainId} push-race rebase conflict: ${rebaseMessage}`);
|
|
449
|
+
emitError('brain', `Brain ${brainId} push-race rebase conflict — manual sync needed: ${rebaseMessage}`, {
|
|
450
|
+
severity: 'error',
|
|
451
|
+
context: { brainId, errorCode: 'PUSH_RACE_CONFLICT', attempts: attempt },
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const errorCode = NON_FF_REJECTION_RE.test(lastErrorMessage) ? 'PUSH_RACE_UNRESOLVED' : 'PUSH_FAILED';
|
|
459
|
+
logger.warn(`Failed to push brain ${brainId} changes: ${lastErrorMessage}`);
|
|
460
|
+
emitError('brain', `Failed to push brain ${brainId} changes: ${lastErrorMessage}`, {
|
|
461
|
+
severity: errorCode === 'PUSH_RACE_UNRESOLVED' ? 'error' : 'warn',
|
|
462
|
+
context: { brainId, errorCode, attempts: attempt },
|
|
463
|
+
});
|
|
211
464
|
}
|
|
212
465
|
catch (error) {
|
|
213
466
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** @internal test-only */
|
|
2
|
+
export declare function __detachedRetryForTest(): Promise<void> | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Fetch the full list of brains this poller is responsible for from the
|
|
5
|
+
* backend (GET /poller/brains, source of truth = DDB — teamvibe.ai#127
|
|
6
|
+
* Option B) and seed `trackedBrains` (maintenance-cron.ts) before the
|
|
7
|
+
* maintenance/heartbeat cron starts, so a brain that's been quiet since
|
|
8
|
+
* before this poller's most recent restart is still picked up instead of
|
|
9
|
+
* staying invisible until a human happens to message it.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort: on any failure (network, timeout, non-2xx, malformed body,
|
|
12
|
+
* missing pollerId/token) this logs a warning, emits a telemetry event, and
|
|
13
|
+
* returns without throwing. Startup must not hang or crash on this — the
|
|
14
|
+
* pre-#127 behavior (trackedBrains starts empty until real messages
|
|
15
|
+
* trickle in) is the fallback here, not a fatal state.
|
|
16
|
+
*
|
|
17
|
+
* DevGuru round-3 review: the awaited part of this function does exactly
|
|
18
|
+
* ONE fetch attempt, bounded by CRITICAL_PATH_TIMEOUT_MS (~10s) — not the
|
|
19
|
+
* full 30s client timeout, and not the retry. Every poller restart during a
|
|
20
|
+
* backend outage was blocking real user-message processing (pollLoop(),
|
|
21
|
+
* called after this in startPoller()) for up to ~62s (2 attempts * 30s +
|
|
22
|
+
* 2s delay) — and restarts correlate with backend deploys, exactly when
|
|
23
|
+
* this is most likely to happen. If the first attempt fails and is
|
|
24
|
+
* retryable (network error, timeout, or 5xx — not a 4xx or malformed body,
|
|
25
|
+
* which a retry can't fix), the retry is kicked off detached — an
|
|
26
|
+
* immediately-invoked async function whose promise is stored in a
|
|
27
|
+
* module-level variable (see `__detachedRetryForTest` below; test-only,
|
|
28
|
+
* production code never reads it) rather than `void`-discarded, but never
|
|
29
|
+
* awaited by this function — the same fire-and-forget shape
|
|
30
|
+
* maintenance-cron.ts's registerBrain() already uses for its immediate
|
|
31
|
+
* metrics collect+send — so `bootstrapTrackedBrains()` returns and
|
|
32
|
+
* startPoller() can proceed to
|
|
33
|
+
* startMaintenanceLoop/startAutoUpdateCron/startMetricsCron/pollLoop()
|
|
34
|
+
* without waiting on it. This is safe because neither downstream consumer
|
|
35
|
+
* needs bootstrap complete synchronously: the maintenance cron only fires
|
|
36
|
+
* at its own scheduled time (default 03:30, not at startup), and the inbox
|
|
37
|
+
* watcher just needs trackedBrains populated "one tick later" — both read
|
|
38
|
+
* the same shared, mutable Map (getTrackedBrains()) whenever their own tick
|
|
39
|
+
* fires, not a snapshot taken at startup.
|
|
40
|
+
*
|
|
41
|
+
* No-ops in direct (non-token) mode, same gating as sendMetrics
|
|
42
|
+
* (metrics/sender.ts) — there's no backend endpoint to call without
|
|
43
|
+
* TEAMVIBE_API_URL + TEAMVIBE_POLLER_TOKEN.
|
|
44
|
+
*/
|
|
45
|
+
export declare function bootstrapTrackedBrains(): Promise<void>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { config } from './config.js';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
import { getPollerId } from './auth-provider.js';
|
|
4
|
+
import { seedTrackedBrains } from './maintenance-cron.js';
|
|
5
|
+
import { emitEvent, emitError } from './event-queue.js';
|
|
6
|
+
// Client-side timeout kept comfortably above the server-side route timeout
|
|
7
|
+
// (infra/poller-auth.ts: GET /poller/brains, 25s) so the poller never aborts
|
|
8
|
+
// a request the Lambda could still have answered (DevGuru round-2 review —
|
|
9
|
+
// with the old 10s client timeout vs a 20s route timeout, a transient slow
|
|
10
|
+
// response aborted client-side while the Lambda kept running to nothing,
|
|
11
|
+
// burning the one bootstrap attempt with no retry until the next restart).
|
|
12
|
+
// Used ONLY for the off-critical-path retry attempt below — DevGuru round-3:
|
|
13
|
+
// "don't touch the 25s/30s numbers themselves", so this stays exactly as
|
|
14
|
+
// round-2 left it, just no longer used for the first attempt.
|
|
15
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
16
|
+
// Short deadline for the ONE attempt that's actually awaited on
|
|
17
|
+
// startPoller()'s critical path (DevGuru round-3 review item 2). Judgment
|
|
18
|
+
// call — not spelled out beyond "~10s" — picked 10s exactly as suggested.
|
|
19
|
+
// Rationale for why a short deadline is safe here even though it's shorter
|
|
20
|
+
// than the 25s the Lambda might legitimately take: this is only the FIRST
|
|
21
|
+
// attempt. If it doesn't finish inside 10s, we don't conclude failure and
|
|
22
|
+
// give up — we fall through to the same retry path used for any other
|
|
23
|
+
// transient failure, just detached from startup so a slow-but-alive
|
|
24
|
+
// backend doesn't cost the poller a lost user-message-processing minute.
|
|
25
|
+
const CRITICAL_PATH_TIMEOUT_MS = 10_000;
|
|
26
|
+
// Judgment call (not specified by DevGuru's review, flagged for reviewer):
|
|
27
|
+
// one retry, 2s fixed delay, unchanged from round 2. Since the retry is now
|
|
28
|
+
// off the critical path (see bootstrapTrackedBrains below), the 2s delay no
|
|
29
|
+
// longer costs startup time — it only affects how soon the detached retry
|
|
30
|
+
// fires, which is not time-critical (the maintenance cron doesn't run until
|
|
31
|
+
// its own scheduled time, and the inbox watcher just needs trackedBrains
|
|
32
|
+
// populated "one tick later", not synchronously — DevGuru round-3).
|
|
33
|
+
const RETRY_DELAY_MS = 2_000;
|
|
34
|
+
function sleep(ms) {
|
|
35
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
36
|
+
}
|
|
37
|
+
// Test-only handle to the detached retry promise (see bootstrapTrackedBrains
|
|
38
|
+
// below) kicked off by the most recent call. Undefined until a retryable
|
|
39
|
+
// first-attempt failure schedules one; reset to undefined at the top of
|
|
40
|
+
// every bootstrapTrackedBrains() call so a stale promise from a PRIOR call
|
|
41
|
+
// is never mistaken for the current one. Same test-only-export convention
|
|
42
|
+
// as maintenance-cron.ts's __resetTrackedBrainsForTest() — never read by
|
|
43
|
+
// production code, exists purely so tests can deterministically wait for
|
|
44
|
+
// the detached background work to finish instead of guessing with a fixed
|
|
45
|
+
// sleep (DevGuru review: the fixed-sleep approach was two separate
|
|
46
|
+
// latent-flakiness bugs — see brains-bootstrap.test.ts).
|
|
47
|
+
let detachedRetryForTest;
|
|
48
|
+
/** @internal test-only */
|
|
49
|
+
export function __detachedRetryForTest() {
|
|
50
|
+
return detachedRetryForTest;
|
|
51
|
+
}
|
|
52
|
+
async function attemptFetch(url, token, timeoutMs) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(url, {
|
|
57
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: 'non-2xx',
|
|
64
|
+
detail: `status ${response.status}`,
|
|
65
|
+
retryable: response.status >= 500,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
let body;
|
|
69
|
+
try {
|
|
70
|
+
body = await response.json();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: 'invalid-json',
|
|
76
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
77
|
+
retryable: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!body || typeof body !== 'object' || !Array.isArray(body.brains)) {
|
|
81
|
+
return { ok: false, reason: 'malformed-body', detail: 'missing brains array', retryable: false };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, brains: body.brains };
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const isTimeout = err instanceof Error && err.name === 'AbortError';
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: isTimeout ? 'timeout' : 'network-error',
|
|
90
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
91
|
+
retryable: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Seed trackedBrains from a successful fetch and emit success telemetry.
|
|
99
|
+
* Shared by both the critical-path attempt and the detached retry below. */
|
|
100
|
+
function applySuccess(brains) {
|
|
101
|
+
const seeded = seedTrackedBrains(brains);
|
|
102
|
+
logger.info(`[Bootstrap] Seeded ${seeded} brain(s) into trackedBrains from backend (${brains.length} returned)`);
|
|
103
|
+
emitEvent('poller.brains_bootstrap.success', 'system', {
|
|
104
|
+
seeded,
|
|
105
|
+
returned: brains.length,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Report a terminal (no-more-retries) failure. Distinct telemetry artifact
|
|
109
|
+
* (DevGuru round-2 review) — from outside, "bootstrap crashed" and
|
|
110
|
+
* "bootstrap ran fine, no quiet brains to seed" were previously
|
|
111
|
+
* indistinguishable (both show seeded=0 in a log line nobody's watching).
|
|
112
|
+
* This feeds the same FLEET_ERROR pipeline as maintenance-cron.ts's
|
|
113
|
+
* emitError call (triggerAllBrains, per-brain maintenance failure) — same
|
|
114
|
+
* category, same shape. Only called once per bootstrap attempt sequence
|
|
115
|
+
* (non-retryable first-attempt failure, or a failed retry) — never for a
|
|
116
|
+
* retryable first-attempt failure, since that one isn't terminal yet. */
|
|
117
|
+
function reportFailure(outcome) {
|
|
118
|
+
emitError('maintenance', `trackedBrains bootstrap failed: ${outcome.reason}`, {
|
|
119
|
+
severity: 'warn',
|
|
120
|
+
context: { reason: outcome.reason, detail: outcome.detail },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Fetch the full list of brains this poller is responsible for from the
|
|
125
|
+
* backend (GET /poller/brains, source of truth = DDB — teamvibe.ai#127
|
|
126
|
+
* Option B) and seed `trackedBrains` (maintenance-cron.ts) before the
|
|
127
|
+
* maintenance/heartbeat cron starts, so a brain that's been quiet since
|
|
128
|
+
* before this poller's most recent restart is still picked up instead of
|
|
129
|
+
* staying invisible until a human happens to message it.
|
|
130
|
+
*
|
|
131
|
+
* Best-effort: on any failure (network, timeout, non-2xx, malformed body,
|
|
132
|
+
* missing pollerId/token) this logs a warning, emits a telemetry event, and
|
|
133
|
+
* returns without throwing. Startup must not hang or crash on this — the
|
|
134
|
+
* pre-#127 behavior (trackedBrains starts empty until real messages
|
|
135
|
+
* trickle in) is the fallback here, not a fatal state.
|
|
136
|
+
*
|
|
137
|
+
* DevGuru round-3 review: the awaited part of this function does exactly
|
|
138
|
+
* ONE fetch attempt, bounded by CRITICAL_PATH_TIMEOUT_MS (~10s) — not the
|
|
139
|
+
* full 30s client timeout, and not the retry. Every poller restart during a
|
|
140
|
+
* backend outage was blocking real user-message processing (pollLoop(),
|
|
141
|
+
* called after this in startPoller()) for up to ~62s (2 attempts * 30s +
|
|
142
|
+
* 2s delay) — and restarts correlate with backend deploys, exactly when
|
|
143
|
+
* this is most likely to happen. If the first attempt fails and is
|
|
144
|
+
* retryable (network error, timeout, or 5xx — not a 4xx or malformed body,
|
|
145
|
+
* which a retry can't fix), the retry is kicked off detached — an
|
|
146
|
+
* immediately-invoked async function whose promise is stored in a
|
|
147
|
+
* module-level variable (see `__detachedRetryForTest` below; test-only,
|
|
148
|
+
* production code never reads it) rather than `void`-discarded, but never
|
|
149
|
+
* awaited by this function — the same fire-and-forget shape
|
|
150
|
+
* maintenance-cron.ts's registerBrain() already uses for its immediate
|
|
151
|
+
* metrics collect+send — so `bootstrapTrackedBrains()` returns and
|
|
152
|
+
* startPoller() can proceed to
|
|
153
|
+
* startMaintenanceLoop/startAutoUpdateCron/startMetricsCron/pollLoop()
|
|
154
|
+
* without waiting on it. This is safe because neither downstream consumer
|
|
155
|
+
* needs bootstrap complete synchronously: the maintenance cron only fires
|
|
156
|
+
* at its own scheduled time (default 03:30, not at startup), and the inbox
|
|
157
|
+
* watcher just needs trackedBrains populated "one tick later" — both read
|
|
158
|
+
* the same shared, mutable Map (getTrackedBrains()) whenever their own tick
|
|
159
|
+
* fires, not a snapshot taken at startup.
|
|
160
|
+
*
|
|
161
|
+
* No-ops in direct (non-token) mode, same gating as sendMetrics
|
|
162
|
+
* (metrics/sender.ts) — there's no backend endpoint to call without
|
|
163
|
+
* TEAMVIBE_API_URL + TEAMVIBE_POLLER_TOKEN.
|
|
164
|
+
*/
|
|
165
|
+
export async function bootstrapTrackedBrains() {
|
|
166
|
+
detachedRetryForTest = undefined;
|
|
167
|
+
if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const pollerId = getPollerId();
|
|
171
|
+
if (!pollerId) {
|
|
172
|
+
logger.warn('[Bootstrap] Skipping trackedBrains bootstrap: no pollerId (auth not completed?)');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const token = config.TEAMVIBE_POLLER_TOKEN;
|
|
176
|
+
const url = `${config.TEAMVIBE_API_URL}/poller/brains?pollerId=${encodeURIComponent(pollerId)}`;
|
|
177
|
+
const outcome = await attemptFetch(url, token, CRITICAL_PATH_TIMEOUT_MS);
|
|
178
|
+
if (outcome.ok) {
|
|
179
|
+
applySuccess(outcome.brains);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
logger.warn(`[Bootstrap] GET /poller/brains failed on first attempt: ${outcome.reason} (${outcome.detail})`);
|
|
183
|
+
if (!outcome.retryable) {
|
|
184
|
+
reportFailure(outcome);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Off the critical path from here — startPoller() is not waiting on this
|
|
188
|
+
// promise. Wrapped in try/catch (DevGuru round-4 review) rather than just
|
|
189
|
+
// asserting "nothing below throws": attemptFetch() itself always returns
|
|
190
|
+
// an outcome object instead of throwing, but applySuccess() (seedTrackedBrains
|
|
191
|
+
// + emitEvent) and reportFailure() (emitError) are still real function
|
|
192
|
+
// calls — if either ever throws for any reason (a future refactor, an
|
|
193
|
+
// unexpected edge case), an uncaught throw inside this detached function
|
|
194
|
+
// becomes an unhandled promise rejection and crashes the whole poller
|
|
195
|
+
// process. Cheap insurance: catch and log a warning, matching this file's
|
|
196
|
+
// existing logger.warn style, instead of letting it propagate.
|
|
197
|
+
//
|
|
198
|
+
// Stored in detachedRetryForTest (DevGuru round-5 review) rather than
|
|
199
|
+
// `void`-discarded, purely so tests can await it deterministically — see
|
|
200
|
+
// that variable's own comment above. Storing the reference doesn't change
|
|
201
|
+
// runtime behavior here: this function still never awaits it itself.
|
|
202
|
+
detachedRetryForTest = (async () => {
|
|
203
|
+
try {
|
|
204
|
+
await sleep(RETRY_DELAY_MS);
|
|
205
|
+
const retryOutcome = await attemptFetch(url, token, FETCH_TIMEOUT_MS);
|
|
206
|
+
if (retryOutcome.ok) {
|
|
207
|
+
applySuccess(retryOutcome.brains);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
logger.warn(`[Bootstrap] GET /poller/brains failed on retry: ${retryOutcome.reason} (${retryOutcome.detail})`);
|
|
211
|
+
reportFailure(retryOutcome);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
logger.warn(`[Bootstrap] Detached retry threw unexpectedly: ${err instanceof Error ? err.message : String(err)}`);
|
|
215
|
+
}
|
|
216
|
+
})();
|
|
217
|
+
}
|