@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.
@@ -7,6 +7,7 @@ export interface TrackedBrainInfo {
7
7
  botId: string;
8
8
  botToken: string;
9
9
  channelId: string;
10
+ lastRealMessageAt?: number;
10
11
  }
11
12
  /**
12
13
  * Read-only view of brains seen so far (via registerBrain). Used by the inbox
@@ -14,6 +15,78 @@ export interface TrackedBrainInfo {
14
15
  * workspace/bot credentials to use when starting a session for an idle thread.
15
16
  */
16
17
  export declare function getTrackedBrains(): ReadonlyMap<string, TrackedBrainInfo>;
18
+ /**
19
+ * Test-only helper to clear tracked brains between tests (module-scoped
20
+ * state persists across tests in the same file otherwise).
21
+ * @internal
22
+ */
23
+ export declare function __resetTrackedBrainsForTest(): void;
24
+ /**
25
+ * Shape returned by GET /poller/brains (teamvibe.ai#127 Option B) — the
26
+ * fields registerBrain() needs to build a synthetic maintenance message,
27
+ * minus `lastRealMessageAt` (see seedTrackedBrains below for why that's
28
+ * deliberately not part of the bootstrap payload).
29
+ */
30
+ export interface BootstrapBrainInfo {
31
+ brainId: string;
32
+ gitRepoUrl: string;
33
+ branch: string;
34
+ workspaceId: string;
35
+ botId: string;
36
+ botToken: string;
37
+ channelId: string;
38
+ }
39
+ /**
40
+ * Seed trackedBrains at startup from the backend's authoritative brain list
41
+ * (teamvibe.ai#127) so a "quiet" brain — one that hasn't received a real
42
+ * message since this poller process started — still gets included in the
43
+ * maintenance/heartbeat cron, instead of being silently invisible until a
44
+ * human happens to message it.
45
+ *
46
+ * Deliberately does NOT set `lastRealMessageAt` (left undefined). That
47
+ * field specifically means "we've seen a real message since our own last
48
+ * restart" — shouldSkipMaintenance (maintenance-activity.ts, #131) treats
49
+ * `undefined` as fail-open (never skip) for exactly this reason. Bootstrap
50
+ * seeding isn't a real message; claiming one occurred here would risk the
51
+ * opposite bug — a genuinely-quiet brain wrongly evaluated as "nothing new,
52
+ * skip" on its very first post-restart cron tick.
53
+ *
54
+ * Skips (rather than overwrites) any brainId already present in
55
+ * trackedBrains. This guard is load-bearing, not just defensive (DevGuru
56
+ * review, round 4): since round 3's critical-path detachment
57
+ * (brains-bootstrap.ts's `void (async () => {...})()` retry), the retry
58
+ * branch can finish AFTER startPoller() has already returned and real user
59
+ * messages have started arriving. That means a real registerBrain() call
60
+ * for a brain (which sets `lastRealMessageAt`) can race ahead of — and
61
+ * complete before — the detached bootstrap retry's seedTrackedBrains()
62
+ * call for that same brain. Without this skip, the late-arriving bootstrap
63
+ * seed would silently overwrite the real registration back to a fresh
64
+ * entry with `lastRealMessageAt: undefined`, losing real activity data
65
+ * (and, per the fail-open contract above, artificially reopening the
66
+ * skip-maintenance window for a brain that's actually been active).
67
+ *
68
+ * DOES also register each seeded brain for hourly metrics (see
69
+ * `registerBrainForMetrics` below) — DevGuru review caught that this
70
+ * module's original "metrics has its own disk-walk path" reasoning was
71
+ * wrong: `metrics/scheduler.ts`'s `registered` Map has exactly one
72
+ * production caller (`registerBrain()` below), with no disk-walk discovery
73
+ * of its own. Disk reads only happen in `collector.ts`, and only for a
74
+ * brain already present in that Map. Skipping the metrics seed here would
75
+ * reproduce the exact same restart-amnesia bug for metrics that this whole
76
+ * change exists to fix for maintenance: a quiet brain would get maintenance
77
+ * triggers again, but zero metrics until someone messages it.
78
+ *
79
+ * Deliberately does NOT replicate `registerBrain()`'s *immediate*
80
+ * collect+send side effect (the `if (isNew) { void (async () => ... })()`
81
+ * block below) — only the Map insert. Firing an immediate collect+send for
82
+ * every brain this poller owns, all at once, on every restart would be a
83
+ * fleet-wide burst; the existing hourly cron with its per-brain jitter
84
+ * (`collectAndSendAll`, `jitterMsFor`) already picks up newly-registered
85
+ * brains on its own schedule without a startup spike.
86
+ *
87
+ * Returns the number of brains actually seeded (for startup logging).
88
+ */
89
+ export declare function seedTrackedBrains(brains: readonly BootstrapBrainInfo[]): number;
17
90
  /**
18
91
  * Register a brain from a real message so the maintenance cron knows about it.
19
92
  * Called from processMessage() after brain path is resolved.
@@ -6,7 +6,8 @@ import { emitError } from './event-queue.js';
6
6
  import { registerBrainForMetrics } from './metrics/scheduler.js';
7
7
  import { collectMetrics } from './metrics/collector.js';
8
8
  import { sendMetrics } from './metrics/sender.js';
9
- import { markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
9
+ import { markMaintenanceJobFired, markMaintenanceJobSuccess, markMaintenanceJobSkipped, } from './cron-telemetry.js';
10
+ import { shouldSkipMaintenance } from './maintenance-activity.js';
10
11
  import { join } from 'path';
11
12
  const trackedBrains = new Map();
12
13
  /**
@@ -17,7 +18,84 @@ const trackedBrains = new Map();
17
18
  export function getTrackedBrains() {
18
19
  return trackedBrains;
19
20
  }
21
+ /**
22
+ * Test-only helper to clear tracked brains between tests (module-scoped
23
+ * state persists across tests in the same file otherwise).
24
+ * @internal
25
+ */
26
+ export function __resetTrackedBrainsForTest() {
27
+ trackedBrains.clear();
28
+ }
20
29
  let maintenanceJob = null;
30
+ /**
31
+ * Seed trackedBrains at startup from the backend's authoritative brain list
32
+ * (teamvibe.ai#127) so a "quiet" brain — one that hasn't received a real
33
+ * message since this poller process started — still gets included in the
34
+ * maintenance/heartbeat cron, instead of being silently invisible until a
35
+ * human happens to message it.
36
+ *
37
+ * Deliberately does NOT set `lastRealMessageAt` (left undefined). That
38
+ * field specifically means "we've seen a real message since our own last
39
+ * restart" — shouldSkipMaintenance (maintenance-activity.ts, #131) treats
40
+ * `undefined` as fail-open (never skip) for exactly this reason. Bootstrap
41
+ * seeding isn't a real message; claiming one occurred here would risk the
42
+ * opposite bug — a genuinely-quiet brain wrongly evaluated as "nothing new,
43
+ * skip" on its very first post-restart cron tick.
44
+ *
45
+ * Skips (rather than overwrites) any brainId already present in
46
+ * trackedBrains. This guard is load-bearing, not just defensive (DevGuru
47
+ * review, round 4): since round 3's critical-path detachment
48
+ * (brains-bootstrap.ts's `void (async () => {...})()` retry), the retry
49
+ * branch can finish AFTER startPoller() has already returned and real user
50
+ * messages have started arriving. That means a real registerBrain() call
51
+ * for a brain (which sets `lastRealMessageAt`) can race ahead of — and
52
+ * complete before — the detached bootstrap retry's seedTrackedBrains()
53
+ * call for that same brain. Without this skip, the late-arriving bootstrap
54
+ * seed would silently overwrite the real registration back to a fresh
55
+ * entry with `lastRealMessageAt: undefined`, losing real activity data
56
+ * (and, per the fail-open contract above, artificially reopening the
57
+ * skip-maintenance window for a brain that's actually been active).
58
+ *
59
+ * DOES also register each seeded brain for hourly metrics (see
60
+ * `registerBrainForMetrics` below) — DevGuru review caught that this
61
+ * module's original "metrics has its own disk-walk path" reasoning was
62
+ * wrong: `metrics/scheduler.ts`'s `registered` Map has exactly one
63
+ * production caller (`registerBrain()` below), with no disk-walk discovery
64
+ * of its own. Disk reads only happen in `collector.ts`, and only for a
65
+ * brain already present in that Map. Skipping the metrics seed here would
66
+ * reproduce the exact same restart-amnesia bug for metrics that this whole
67
+ * change exists to fix for maintenance: a quiet brain would get maintenance
68
+ * triggers again, but zero metrics until someone messages it.
69
+ *
70
+ * Deliberately does NOT replicate `registerBrain()`'s *immediate*
71
+ * collect+send side effect (the `if (isNew) { void (async () => ... })()`
72
+ * block below) — only the Map insert. Firing an immediate collect+send for
73
+ * every brain this poller owns, all at once, on every restart would be a
74
+ * fleet-wide burst; the existing hourly cron with its per-brain jitter
75
+ * (`collectAndSendAll`, `jitterMsFor`) already picks up newly-registered
76
+ * brains on its own schedule without a startup spike.
77
+ *
78
+ * Returns the number of brains actually seeded (for startup logging).
79
+ */
80
+ export function seedTrackedBrains(brains) {
81
+ let seeded = 0;
82
+ for (const b of brains) {
83
+ if (trackedBrains.has(b.brainId))
84
+ continue;
85
+ trackedBrains.set(b.brainId, {
86
+ brainId: b.brainId,
87
+ gitRepoUrl: b.gitRepoUrl,
88
+ branch: b.branch,
89
+ workspaceId: b.workspaceId,
90
+ botId: b.botId,
91
+ botToken: b.botToken,
92
+ channelId: b.channelId,
93
+ });
94
+ registerBrainForMetrics(b.brainId);
95
+ seeded++;
96
+ }
97
+ return seeded;
98
+ }
21
99
  /**
22
100
  * Register a brain from a real message so the maintenance cron knows about it.
23
101
  * Called from processMessage() after brain path is resolved.
@@ -38,6 +116,7 @@ export function registerBrain(msg) {
38
116
  botId: msg.teamvibe.botId,
39
117
  botToken: msg.teamvibe.botToken,
40
118
  channelId: msg.teamvibe.channelId,
119
+ lastRealMessageAt: Date.now(),
41
120
  });
42
121
  logger.debug(`[Maintenance] Registered brain: ${brain.brainId}`);
43
122
  // Metrics pipeline (teamvibe.ai#110): register for hourly collection. On the
@@ -68,6 +147,11 @@ async function triggerAllBrains(processMessageFn) {
68
147
  logger.debug(`[Maintenance] At capacity, skipping ${brainId}`);
69
148
  continue;
70
149
  }
150
+ if (await shouldSkipMaintenance(config.BRAINS_PATH, brainId, info.lastRealMessageAt)) {
151
+ markMaintenanceJobSkipped();
152
+ logger.debug(`[Maintenance] Skipping ${brainId} — no real activity since last maintenance (#131)`);
153
+ continue;
154
+ }
71
155
  logger.info(`[Maintenance] Triggering maintenance for brain ${brainId}`);
72
156
  const syntheticMessage = {
73
157
  id: `maintenance_${brainId}_${now}`,
@@ -21,6 +21,8 @@ export interface MetricsPayload {
21
21
  daysSinceMaintenance?: number;
22
22
  lastMaintenanceJobMs?: number;
23
23
  lastMaintenanceJobSuccessMs?: number;
24
+ maintenanceSkippedCountLastRun?: number;
25
+ lastMaintenanceJobSkippedMs?: number;
24
26
  lastAutoUpdateJobMs?: number;
25
27
  lastAutoUpdateJobSuccessMs?: number;
26
28
  timestamp?: string;
@@ -228,6 +228,13 @@ export async function collectMetrics(brainPath, brainId) {
228
228
  if (cron.lastMaintenanceJobSuccessAt !== null) {
229
229
  payload.lastMaintenanceJobSuccessMs = now - cron.lastMaintenanceJobSuccessAt;
230
230
  }
231
+ // #131 — activity-based skip counter. Always sent (0 is a real "no skips
232
+ // this run" value, not an omission) so a stuck-high count is visible
233
+ // fleet-wide without per-brain digging.
234
+ payload.maintenanceSkippedCountLastRun = cron.maintenanceSkippedCountLastRun;
235
+ if (cron.lastMaintenanceJobSkippedAt !== null) {
236
+ payload.lastMaintenanceJobSkippedMs = now - cron.lastMaintenanceJobSkippedAt;
237
+ }
231
238
  if (cron.lastAutoUpdateJobFiredAt !== null) {
232
239
  payload.lastAutoUpdateJobMs = now - cron.lastAutoUpdateJobFiredAt;
233
240
  }
package/dist/poller.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { logger } from './logger.js';
2
+ import { type SpawnResult } from './claude-spawner.js';
3
+ import { type KilledByPoller } from './classify-failed-iteration.js';
2
4
  import type { TeamVibeQueueMessage } from './types.js';
3
5
  interface ReleaseLogger {
4
6
  info: (msg: string) => void;
@@ -22,6 +24,16 @@ export declare function runAutoUpdateJob(checkForUpdateFn?: () => Promise<boolea
22
24
  * will pick up any new file on its own.
23
25
  */
24
26
  export declare function isInboxLoopActive(threadId: string): boolean;
27
+ export declare function isIntentionalKillExitCode(exitCode: number | null): boolean;
28
+ export declare function isPollerInitiatedFailure(result: {
29
+ exitCode: number | null;
30
+ killedByPoller?: KilledByPoller;
31
+ }): boolean;
32
+ export declare function describeFailureReason(killedByPoller: KilledByPoller | undefined, exitCode: number | null): string;
33
+ export declare const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = "NO_FURTHER_RESPONSE_NEEDED";
34
+ export declare function endOfTurnFallbackText(hasSlackContext: boolean, result: Pick<SpawnResult, 'success' | 'sawSendMessageCall' | 'lastPlainTextContent'>): string | undefined;
25
35
  export declare function runInboxLoop(threadId: string, kbPath: string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession>): Promise<void>;
36
+ export declare function __setSleepForTest(impl: (ms: number) => Promise<void>): void;
37
+ export declare function __setShuttingDownForTest(value: boolean): void;
26
38
  export declare function startPoller(): Promise<void>;
27
39
  export {};