@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/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
- return text ? text : undefined;
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) → never
353
- // counts as an application failure, so markFailed/backoff is skipped
354
- // below regardless of reason. For timeout/idle/network/zombie this
355
- // really does mean "retry immediately" — the `while (!shuttingDown)`
356
- // condition above lets the loop come straight back around. For
357
- // 'shutdown' specifically, `shuttingDown` is already true by the time
358
- // this reads, so the loop condition above stops it from actually
359
- // retrying inline — instead the branch below requeues the message
360
- // from processing/ back to new/ (PR#308 round-5) so a future boot's
361
- // inbox watcher finds it. See isPollerInitiatedFailure() and the
362
- // loop-condition comment above.
363
- const isKilledSignal = isPollerInitiatedFailure({ exitCode: lastExitCode, killedByPoller: lastKilledByPoller });
364
- if (!isKilledSignal) {
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
- sessionLog.error(`[Loop] Message ${message.filename} failed ${failures} times, giving up`);
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
- await sleep(backoffMs);
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 if (lastKilledByPoller === 'shutdown') {
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, which
391
- // is empty after a fresh poller start until some SQS activity for
392
- // this brain re-registers it (see teamvibe.ai#310) — hence the
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 new Promise((resolve) => setTimeout(resolve, ms));
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {