@teamvibe/poller 0.7.0 → 0.7.2
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 +8 -0
- package/dist/brain-manager.js +132 -6
- package/dist/brains-bootstrap.d.ts +45 -0
- package/dist/brains-bootstrap.js +217 -0
- package/dist/claude-spawner.d.ts +1 -0
- package/dist/claude-spawner.js +10 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +17 -0
- package/dist/idle-restart.d.ts +12 -0
- package/dist/idle-restart.js +200 -0
- package/dist/maintenance-cron.d.ts +66 -0
- package/dist/maintenance-cron.js +69 -0
- package/dist/poller.d.ts +3 -0
- package/dist/poller.js +204 -21
- package/package.json +1 -1
package/dist/poller.js
CHANGED
|
@@ -10,6 +10,7 @@ import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from '
|
|
|
10
10
|
import { postMaintenanceReports } from './maintenance-reporter.js';
|
|
11
11
|
import { initAuth, stopRefresh, getPollerId } from './auth-provider.js';
|
|
12
12
|
import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './maintenance-cron.js';
|
|
13
|
+
import { bootstrapTrackedBrains } from './brains-bootstrap.js';
|
|
13
14
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
|
|
14
15
|
import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
|
|
15
16
|
import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage } from './inbox-manager.js';
|
|
@@ -17,6 +18,7 @@ import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
|
|
|
17
18
|
import { Cron } from 'croner';
|
|
18
19
|
import { isControlMessage, handleControlMessage } from './control-handler.js';
|
|
19
20
|
import { checkForUpdate } from './auto-update.js';
|
|
21
|
+
import { checkIdleRestart, reconcileBgTasksOnBoot } from './idle-restart.js';
|
|
20
22
|
import { markAutoUpdateJobFired, markAutoUpdateJobSuccess } from './cron-telemetry.js';
|
|
21
23
|
import { exec } from 'child_process';
|
|
22
24
|
import { promisify } from 'util';
|
|
@@ -163,6 +165,35 @@ export function isIntentionalKillExitCode(exitCode) {
|
|
|
163
165
|
export function isPollerInitiatedFailure(result) {
|
|
164
166
|
return Boolean(result.killedByPoller) || isIntentionalKillExitCode(result.exitCode);
|
|
165
167
|
}
|
|
168
|
+
// tv#320 DevGuru round-1 review: runInboxLoop's retry-cap decision (below)
|
|
169
|
+
// no longer calls isPollerInitiatedFailure() directly — since that cap now
|
|
170
|
+
// applies to everything except 'shutdown', the only distinction it needs is
|
|
171
|
+
// `lastKilledByPoller === 'shutdown'`, and `killedByPoller === 'shutdown'`
|
|
172
|
+
// already implies isPollerInitiatedFailure() === true, making a combined
|
|
173
|
+
// check redundant there.
|
|
174
|
+
//
|
|
175
|
+
// tv#320 DevGuru round-2 review: isIntentionalKillExitCode() still has a real
|
|
176
|
+
// caller below (describeFailureReason()) — a kill with NO `killedByPoller`
|
|
177
|
+
// marker that DOES still report the legacy 143/137 exit code is an
|
|
178
|
+
// intentional kill, not an unclassified application failure, and needs to be
|
|
179
|
+
// labeled as such in give-up notices/logs/events (`lastKilledByPoller ??
|
|
180
|
+
// 'application failure'` used to conflate the two).
|
|
181
|
+
//
|
|
182
|
+
// tv#320 DevGuru round-3 review: this only covers the literal 143/137 case.
|
|
183
|
+
// Per the comment above (line ~185), a group-killed process's `close` event
|
|
184
|
+
// reports `code: null`, NOT 143/137 — so a marker-less OOM/external-SIGTERM/
|
|
185
|
+
// group-kill still falls through to "application failure" here, same as
|
|
186
|
+
// before this fix. Closing that gap for real needs claude-spawner.ts's
|
|
187
|
+
// `close` handler to stop discarding its `signal` argument and SpawnResult
|
|
188
|
+
// to carry it (neither does today) — tracked as a follow-up, not attempted
|
|
189
|
+
// here.
|
|
190
|
+
export function describeFailureReason(killedByPoller, exitCode) {
|
|
191
|
+
if (killedByPoller)
|
|
192
|
+
return killedByPoller;
|
|
193
|
+
if (isIntentionalKillExitCode(exitCode))
|
|
194
|
+
return `killed (exit ${exitCode})`;
|
|
195
|
+
return 'application failure';
|
|
196
|
+
}
|
|
166
197
|
// teamvibe.ai#284 (poller-brain#235): end-of-turn guard. A successful Claude
|
|
167
198
|
// run can end its turn having only written plain assistant text — no
|
|
168
199
|
// `mcp__slack__send_message` tool call — which the platform otherwise drops
|
|
@@ -176,12 +207,27 @@ export function isPollerInitiatedFailure(result) {
|
|
|
176
207
|
// Deliberately does NOT fire on a failed run (result.success === false):
|
|
177
208
|
// that path already has its own delivery via classifyFailedIteration +
|
|
178
209
|
// sendSlackMessage/sendSlackError, and firing here too would double-deliver.
|
|
210
|
+
//
|
|
211
|
+
// poller-brain#380: a turn whose only tool call is a reaction (or similar)
|
|
212
|
+
// can trigger Claude Code's own forced-continuation nudge ("no visible
|
|
213
|
+
// output, produce a user-visible response"), which previously always fired
|
|
214
|
+
// this same fallback — double-posting even when the reaction was the
|
|
215
|
+
// intended, complete reply. Base-brain CLAUDE.md instructs agents to answer
|
|
216
|
+
// that nudge with this exact sentinel when nothing further is needed; an
|
|
217
|
+
// exact match is treated as "nothing to deliver". Deliberately an exact
|
|
218
|
+
// string match, not a content heuristic — anything else (including real
|
|
219
|
+
// content an agent forgot to send via send_message) still delivers as
|
|
220
|
+
// before, so this can't silently drop a forgotten reply.
|
|
221
|
+
export const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = 'NO_FURTHER_RESPONSE_NEEDED';
|
|
179
222
|
export function endOfTurnFallbackText(hasSlackContext, result) {
|
|
180
223
|
if (!hasSlackContext || !result.success || result.sawSendMessageCall) {
|
|
181
224
|
return undefined;
|
|
182
225
|
}
|
|
183
226
|
const text = result.lastPlainTextContent?.trim();
|
|
184
|
-
|
|
227
|
+
if (!text || text === END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
return text;
|
|
185
231
|
}
|
|
186
232
|
export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
187
233
|
if (activeLoops.has(threadId)) {
|
|
@@ -328,6 +374,26 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
|
328
374
|
lastExitCode = result.exitCode;
|
|
329
375
|
lastKilledByPoller = result.killedByPoller;
|
|
330
376
|
sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
|
|
377
|
+
// tv#320 DevGuru round-2 review: a fleet-visible record of EVERY
|
|
378
|
+
// failed iteration (not just the eventual give-up) means a future
|
|
379
|
+
// "was this in one loop or across restarts" question is a DDB query
|
|
380
|
+
// (this event, by thread/filename/timestamp) instead of a CloudWatch
|
|
381
|
+
// log dig + SSO admin escalation — which is exactly what round-2's
|
|
382
|
+
// point 3b hit a dead end on for pb#337.
|
|
383
|
+
//
|
|
384
|
+
// tv#320 DevGuru round-3 review: excludes 'shutdown' — that fires on
|
|
385
|
+
// every poller restart/deploy for every in-flight message fleet-wide
|
|
386
|
+
// and isn't a retry-cap-relevant failure (it's not counted toward
|
|
387
|
+
// failureCounts either), so it would just be alerting noise for an
|
|
388
|
+
// expected event with no new information (the requeue below already
|
|
389
|
+
// covers it).
|
|
390
|
+
if (lastKilledByPoller !== 'shutdown') {
|
|
391
|
+
emitError('session', `Iteration #${iteration} failed for ${message.filename}`, {
|
|
392
|
+
severity: 'warn',
|
|
393
|
+
channelId: queueMessage.teamvibe.channelId,
|
|
394
|
+
context: { threadId, filename: message.filename, iteration, reason: describeFailureReason(lastKilledByPoller, lastExitCode) },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
331
397
|
if (hasSlackContext) {
|
|
332
398
|
const decision = classifyFailedIteration(result);
|
|
333
399
|
if (decision.kind === 'neutral') {
|
|
@@ -349,33 +415,96 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
|
349
415
|
}
|
|
350
416
|
else {
|
|
351
417
|
// Poller-initiated kill (timeout/idle/network/zombie/shutdown, via
|
|
352
|
-
// `killedByPoller`, or the legacy 143/137 exit-code fallback)
|
|
353
|
-
// counts as an application failure
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
418
|
+
// `killedByPoller`, or the legacy 143/137 exit-code fallback) never
|
|
419
|
+
// counts as an *application* failure — but tv#320 (poller-brain#337:
|
|
420
|
+
// one checkpoint file redelivered 18x/~10min, stopped only by a
|
|
421
|
+
// manual `rm`) showed that timeout/idle/network/zombie kills can be
|
|
422
|
+
// just as deterministically tied to this one message's content as a
|
|
423
|
+
// genuine app failure (e.g. an oversized file that reliably
|
|
424
|
+
// re-triggers the same idle/timeout watchdog on every immediate
|
|
425
|
+
// retry). So the same failureCounts/MAX_RETRIES/backoff cap as the
|
|
426
|
+
// application-failure path now applies to everything except
|
|
427
|
+
// 'shutdown' — not just those four named reasons: a legacy 143/137
|
|
428
|
+
// exit with no `killedByPoller` marker at all is just as capable of
|
|
429
|
+
// repeating forever and there's no reason to treat it differently.
|
|
430
|
+
// 'shutdown' alone stays excluded: `shuttingDown` is already true by
|
|
431
|
+
// the time this reads, so the loop condition above stops it from
|
|
432
|
+
// actually retrying inline — instead the branch below requeues the
|
|
433
|
+
// message from processing/ back to new/ (PR#308 round-5) so a future
|
|
434
|
+
// boot's inbox watcher finds it.
|
|
435
|
+
//
|
|
436
|
+
// tv#320 DevGuru round-1 review: this branches on `lastKilledByPoller
|
|
437
|
+
// !== 'shutdown'` directly rather than `isPollerInitiatedFailure(...)`
|
|
438
|
+
// — `killedByPoller === 'shutdown'` already implies
|
|
439
|
+
// isPollerInitiatedFailure() === true, so combining the two was a
|
|
440
|
+
// dead term. isPollerInitiatedFailure() itself is unchanged and still
|
|
441
|
+
// meaningful (exported, unit-tested in
|
|
442
|
+
// poller-retry-classification.test.ts) for any caller that needs the
|
|
443
|
+
// poller-vs-app distinction — this loop just no longer needs it,
|
|
444
|
+
// since the only distinction that changes behavior here is
|
|
445
|
+
// shutdown-vs-not.
|
|
446
|
+
if (lastKilledByPoller !== 'shutdown') {
|
|
365
447
|
const failures = (failureCounts.get(message.filename) || 0) + 1;
|
|
366
448
|
failureCounts.set(message.filename, failures);
|
|
367
449
|
const MAX_RETRIES = 3;
|
|
368
450
|
if (failures >= MAX_RETRIES) {
|
|
369
|
-
|
|
451
|
+
// tv#320 DevGuru round-1 review: the per-iteration notice sent
|
|
452
|
+
// above (classifyFailedIteration) for idle/zombie/network reads
|
|
453
|
+
// "send another message to resume/restart/retry" — true before
|
|
454
|
+
// this cap existed (retry was unconditional), false now that this
|
|
455
|
+
// is the last attempt and the message is about to be dropped.
|
|
456
|
+
// Give the user an explicit, accurate final word instead of
|
|
457
|
+
// leaving classifyFailedIteration's now-stale promise as the only
|
|
458
|
+
// thing they saw, and surface it as a real error event (not just
|
|
459
|
+
// a session-log line) so give-up isn't invisible to fleet
|
|
460
|
+
// monitoring either.
|
|
461
|
+
const reasonLabel = describeFailureReason(lastKilledByPoller, lastExitCode);
|
|
462
|
+
sessionLog.error(`[Loop] Message ${message.filename} failed ${failures} times (last reason: ${reasonLabel}), giving up`);
|
|
370
463
|
markFailed(kbPath, threadId, message.filename);
|
|
464
|
+
if (hasSlackContext) {
|
|
465
|
+
// tv#320 DevGuru round-2 review (BLOCKING): this whole else-if
|
|
466
|
+
// chain sits structurally after the per-iteration try/catch
|
|
467
|
+
// above (which closes at the `catch (error)` a few lines up),
|
|
468
|
+
// so an unhandled exception here — unlike the per-iteration
|
|
469
|
+
// classifyFailedIteration notices, which ARE inside that
|
|
470
|
+
// try/catch — would propagate straight out of the `while`
|
|
471
|
+
// loop, skip releaseSessionLock() below (it's in the try body,
|
|
472
|
+
// not a `finally`), and leave the DDB session lock held until
|
|
473
|
+
// TTL. sendSlackError() (slack-client.ts) has no internal catch
|
|
474
|
+
// of its own (unlike addReaction(), which does), so a Slack
|
|
475
|
+
// rate-limit/auth/network error here needs the same explicit
|
|
476
|
+
// try/catch the end-of-turn-guard fallback above already uses.
|
|
477
|
+
try {
|
|
478
|
+
await sendSlackError(queueMessage, `Giving up after ${failures} attempts (last reason: ${reasonLabel}) — this message was not processed. Please send it again.`);
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
sessionLog.error(`[Loop] Give-up notice delivery failed: ${err instanceof Error ? err.message : err}`);
|
|
482
|
+
}
|
|
483
|
+
await addReaction(queueMessage, 'x');
|
|
484
|
+
}
|
|
485
|
+
emitError('session', `Message ${message.filename} failed ${failures} times, giving up`, {
|
|
486
|
+
severity: 'error',
|
|
487
|
+
channelId: queueMessage.teamvibe.channelId,
|
|
488
|
+
context: { threadId, filename: message.filename, reason: reasonLabel },
|
|
489
|
+
});
|
|
371
490
|
}
|
|
372
491
|
else {
|
|
373
492
|
const backoffMs = Math.min(10_000 * Math.pow(2, failures - 1), 60_000);
|
|
374
493
|
sessionLog.info(`[Loop] Retry ${failures}/${MAX_RETRIES - 1} for ${message.filename}, backing off ${backoffMs}ms`);
|
|
375
|
-
|
|
494
|
+
// tv#320 DevGuru round-2 nit: this backoff was previously only
|
|
495
|
+
// reachable for genuine app failures; the broadened cap above
|
|
496
|
+
// means a timeout/idle/network/zombie kill can now land here too,
|
|
497
|
+
// including one racing an in-progress shutdown() (e.g. the idle
|
|
498
|
+
// watchdog and a shutdown SIGTERM firing close together). Skip
|
|
499
|
+
// the up-to-60s wait when already shutting down so the `while
|
|
500
|
+
// (!shuttingDown)` condition above can exit on the very next
|
|
501
|
+
// check instead of stalling runInboxLoop's return.
|
|
502
|
+
if (!shuttingDown) {
|
|
503
|
+
await sleep(backoffMs);
|
|
504
|
+
}
|
|
376
505
|
}
|
|
377
506
|
}
|
|
378
|
-
else
|
|
507
|
+
else {
|
|
379
508
|
// PR#308 round-5 review (teamvibe.ai#73): pickNextMessage() moves a
|
|
380
509
|
// message's file to processing/ as soon as it's picked up, not once
|
|
381
510
|
// it completes — so a message killed here by shutdown is left
|
|
@@ -387,9 +516,15 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
|
387
516
|
// shutdown notice text below, classify-failed-iteration.ts)
|
|
388
517
|
// literally true. It does NOT make resumption automatic: the
|
|
389
518
|
// watcher's checkInboxForNewSessions() (inbox-watcher.ts) only
|
|
390
|
-
// scans brains already in the in-memory trackedBrains map
|
|
391
|
-
//
|
|
392
|
-
//
|
|
519
|
+
// scans brains already in the in-memory trackedBrains map. Since
|
|
520
|
+
// teamvibe.ai#127, that map is best-effort seeded from the
|
|
521
|
+
// backend at startup (bootstrapTrackedBrains(), before
|
|
522
|
+
// startInboxWatcher() is called) — but it's still not a
|
|
523
|
+
// guarantee (network failure, backend outage, brain missing a
|
|
524
|
+
// Slack bot/token to resolve) and a brand-new brain from a real
|
|
525
|
+
// message that arrives after bootstrap but before its own first
|
|
526
|
+
// registerBrain() call is still momentarily untracked. See
|
|
527
|
+
// teamvibe.ai#310 for the still-open general case. Hence the
|
|
393
528
|
// notice text also naming "next message or scheduled run" rather
|
|
394
529
|
// than claiming automatic resumption.
|
|
395
530
|
//
|
|
@@ -581,6 +716,18 @@ async function pollLoop() {
|
|
|
581
716
|
consecutiveAuthFailures = 0;
|
|
582
717
|
}
|
|
583
718
|
if (messages.length === 0) {
|
|
719
|
+
// tv#317: only worth checking on an otherwise-empty poll — a poll that
|
|
720
|
+
// returned messages means activeSessions is about to go above 0 anyway.
|
|
721
|
+
// Routed through the real shutdown() (not a standalone flush+exit) so this
|
|
722
|
+
// sets shuttingDown, stops the inbox watcher, and releases locks — the same
|
|
723
|
+
// gate every other exit path goes through. DevGuru review, tv#317: a bare
|
|
724
|
+
// flushEventQueue()+process.exit(0) here let the inbox watcher spawn a new
|
|
725
|
+
// session in the window before exit, then kill it mid-work (#308's shape),
|
|
726
|
+
// and skipped releaseAllActiveLocks(), stranding locks for STALE_LOCK_TIMEOUT_MS.
|
|
727
|
+
if (checkIdleRestart()) {
|
|
728
|
+
logger.info('[IdleRestart] Restarting to let Railway reclaim page-cache memory...');
|
|
729
|
+
await shutdown('idle-restart', 0);
|
|
730
|
+
}
|
|
584
731
|
continue;
|
|
585
732
|
}
|
|
586
733
|
logger.info(`Received ${messages.length} message(s) from SQS`);
|
|
@@ -651,8 +798,17 @@ async function pollLoop() {
|
|
|
651
798
|
}
|
|
652
799
|
}
|
|
653
800
|
}
|
|
801
|
+
let sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
654
802
|
function sleep(ms) {
|
|
655
|
-
return
|
|
803
|
+
return sleepImpl(ms);
|
|
804
|
+
}
|
|
805
|
+
// Exported for testing only (tv#320 DevGuru round-1 nit): lets a test replace
|
|
806
|
+
// the real backoff delay (runInboxLoop's retry-cap path) with an immediate
|
|
807
|
+
// resolve, without a `globalThis.setTimeout` override that would also swallow
|
|
808
|
+
// any other real timer in the same code path. Same pattern as
|
|
809
|
+
// __setShuttingDownForTest above.
|
|
810
|
+
export function __setSleepForTest(impl) {
|
|
811
|
+
sleepImpl = impl;
|
|
656
812
|
}
|
|
657
813
|
// Graceful shutdown
|
|
658
814
|
let shuttingDown = false;
|
|
@@ -749,11 +905,38 @@ export async function startPoller() {
|
|
|
749
905
|
startEventQueue();
|
|
750
906
|
await ensureDirectories();
|
|
751
907
|
await ensureBaseBrain();
|
|
908
|
+
reconcileBgTasksOnBoot();
|
|
909
|
+
if (config.IDLE_RESTART_ENABLED) {
|
|
910
|
+
// Defaults off (tv#317 follow-up, DevGuru review): a silent disabled gate and an
|
|
911
|
+
// enabled gate that just hasn't hit its threshold yet look identical in production
|
|
912
|
+
// (same flat/growing memory graph) — logging only on enabled, matching the
|
|
913
|
+
// Worktree isolation line below, makes opt-in verifiable from boot logs instead.
|
|
914
|
+
logger.info(' Idle restart: ENABLED (IDLE_RESTART_ENABLED=true)');
|
|
915
|
+
}
|
|
752
916
|
if (isWorktreeIsolationEnabled()) {
|
|
753
917
|
logger.info(' Worktree isolation: ENABLED (POLLER_USE_WORKTREES=true)');
|
|
754
918
|
const sweep = await cleanupOrphanWorktreesForAllBrains(config.BRAINS_PATH);
|
|
755
919
|
logger.info(` Orphan worktree sweep: scanned ${sweep.scanned} brains, removed ${sweep.removed} stale worktrees`);
|
|
756
920
|
}
|
|
921
|
+
// Seed trackedBrains from the backend's authoritative brain list
|
|
922
|
+
// (teamvibe.ai#127 Option B) BEFORE the maintenance cron and the inbox
|
|
923
|
+
// watcher start — both iterate the in-memory trackedBrains map
|
|
924
|
+
// (maintenance-cron.ts's triggerAllBrains, inbox-watcher.ts's
|
|
925
|
+
// checkInboxForNewSessions), which is otherwise empty after every
|
|
926
|
+
// restart until a real message re-registers each brain one at a time.
|
|
927
|
+
// Best-effort: never throws, so a bootstrap failure here falls back to
|
|
928
|
+
// the pre-#127 behavior rather than blocking startup.
|
|
929
|
+
//
|
|
930
|
+
// This await only covers ONE short (~10s) fetch attempt (DevGuru round-3
|
|
931
|
+
// review item 2) — real message processing (pollLoop(), below) must not
|
|
932
|
+
// be blocked for up to ~62s by a backend outage, which is exactly the
|
|
933
|
+
// condition most likely to coincide with a poller restart (backend
|
|
934
|
+
// deploys). If that one attempt fails and is retryable, the retry runs
|
|
935
|
+
// detached in the background and finishes after startPoller() has
|
|
936
|
+
// already returned — see brains-bootstrap.ts's bootstrapTrackedBrains()
|
|
937
|
+
// doc comment for why that's safe (neither the maintenance cron nor the
|
|
938
|
+
// inbox watcher need trackedBrains populated synchronously here).
|
|
939
|
+
await bootstrapTrackedBrains();
|
|
757
940
|
startMaintenanceLoop(processMessage);
|
|
758
941
|
startAutoUpdateCron();
|
|
759
942
|
startMetricsCron();
|