@teamvibe/poller 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ import { existsSync, readdirSync, rmSync, watch as fsWatch } from 'fs';
2
+ import { join } from 'path';
3
+ import { config } from './config.js';
4
+ import { logger } from './logger.js';
5
+ import { isAtCapacity } from './claude-spawner.js';
6
+ import { getTrackedBrains } from './maintenance-cron.js';
7
+ import { runInboxLoop, isInboxLoopActive } from './poller.js';
8
+ const INBOX_SUBDIRS = ['new', 'processing', 'failed'];
9
+ let brainWatchers = new Map();
10
+ let fallbackInterval = null;
11
+ let debounceTimer = null;
12
+ function defaultDeps() {
13
+ return {
14
+ brainsPath: config.BRAINS_PATH,
15
+ getTrackedBrains,
16
+ isAtCapacity,
17
+ isInboxLoopActive,
18
+ runInboxLoop,
19
+ rmThreadDir: (path) => rmSync(path, { recursive: true }),
20
+ };
21
+ }
22
+ /**
23
+ * threadId is always `<botId>:<channel>:<threadTs>` for slack-originated
24
+ * threads (see modules-teamvibe/hooks/slack-events.ts). Synthetic threadIds
25
+ * used elsewhere (`maintenance_<brainId>_<ts>`, and
26
+ * `scheduled:<scheduleId>:<ts>` from
27
+ * modules-teamvibe/src/functions/scheduler.ts:86) never sit idle waiting on
28
+ * a background-task drop, so they're skipped rather than guessed at.
29
+ * `scheduled:*:*` has the same 3-colon-segment shape as a real Slack
30
+ * threadId, so it's rejected explicitly rather than by segment count alone
31
+ * — otherwise a leftover unprocessed file in a stale scheduled-session inbox
32
+ * dir would make this parse `scheduleId` as a Slack channel.
33
+ */
34
+ export function parseSlackThreadId(threadId) {
35
+ const parts = threadId.split(':');
36
+ if (parts.length !== 3)
37
+ return null;
38
+ const [botId, channel, threadTs] = parts;
39
+ if (botId === 'scheduled')
40
+ return null;
41
+ if (!channel || !threadTs)
42
+ return null;
43
+ return { channel, threadTs };
44
+ }
45
+ function buildSyntheticMessage(info, threadId, parsed) {
46
+ return {
47
+ id: `inbox_watch_${threadId}`,
48
+ source: 'slack',
49
+ thread_id: threadId,
50
+ sender: { id: 'system', name: 'Background Task' },
51
+ text: '',
52
+ attachments: [],
53
+ response_context: {
54
+ slack: {
55
+ channel: parsed.channel,
56
+ thread_ts: parsed.threadTs,
57
+ message_ts: parsed.threadTs,
58
+ },
59
+ },
60
+ type: 'message',
61
+ teamvibe: {
62
+ workspaceId: info.workspaceId,
63
+ botId: info.botId,
64
+ botToken: info.botToken,
65
+ channelId: info.channelId,
66
+ brain: {
67
+ brainId: info.brainId,
68
+ gitRepoUrl: info.gitRepoUrl,
69
+ branch: info.branch,
70
+ },
71
+ },
72
+ };
73
+ }
74
+ export function listThreadDirs(inboxRoot) {
75
+ try {
76
+ return readdirSync(inboxRoot, { withFileTypes: true })
77
+ .filter((entry) => entry.isDirectory())
78
+ .map((entry) => entry.name);
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ }
84
+ export function hasNewMessage(inboxRoot, threadId) {
85
+ const newDir = join(inboxRoot, threadId, 'new');
86
+ try {
87
+ return existsSync(newDir) && readdirSync(newDir).some((f) => f.endsWith('.txt'));
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * Scan every tracked brain's .inbox/ for a thread that has a new message but
95
+ * no active runInboxLoop, and start one. This is the only config-only path
96
+ * that can wake a fully idle session (teamvibe.ai#242) — a file dropped into
97
+ * .inbox/ by e.g. a background task otherwise sits inert until the next SQS
98
+ * message for that thread, which may never come.
99
+ */
100
+ export function checkInboxForNewSessions(deps = defaultDeps()) {
101
+ for (const [, info] of deps.getTrackedBrains()) {
102
+ if (deps.isAtCapacity()) {
103
+ logger.debug('[InboxWatcher] At capacity, pausing scan until next tick');
104
+ return;
105
+ }
106
+ const brainPath = join(deps.brainsPath, info.brainId);
107
+ const inboxRoot = join(brainPath, '.inbox');
108
+ if (!existsSync(inboxRoot))
109
+ continue;
110
+ for (const threadId of listThreadDirs(inboxRoot)) {
111
+ if (deps.isInboxLoopActive(threadId))
112
+ continue;
113
+ if (!hasNewMessage(inboxRoot, threadId))
114
+ continue;
115
+ const parsed = parseSlackThreadId(threadId);
116
+ if (!parsed) {
117
+ logger.debug(`[InboxWatcher] Skipping ${threadId}: not a slack-shaped threadId`);
118
+ continue;
119
+ }
120
+ logger.info(`[InboxWatcher] New message for idle thread ${threadId}, starting session`);
121
+ const sessionLog = logger.createSession(`iw_${Math.random().toString(36).slice(2, 10)}`);
122
+ const syntheticMessage = buildSyntheticMessage(info, threadId, parsed);
123
+ deps.runInboxLoop(threadId, brainPath, syntheticMessage, sessionLog).catch((error) => {
124
+ logger.error(`[InboxWatcher] runInboxLoop failed for ${threadId}: ${error instanceof Error ? error.message : error}`);
125
+ });
126
+ }
127
+ }
128
+ }
129
+ export function isThreadDirEmpty(inboxRoot, threadId) {
130
+ for (const sub of INBOX_SUBDIRS) {
131
+ const subDir = join(inboxRoot, threadId, sub);
132
+ if (!existsSync(subDir))
133
+ continue;
134
+ try {
135
+ if (readdirSync(subDir).length > 0)
136
+ return false;
137
+ }
138
+ catch {
139
+ return false; // unreadable — leave it alone rather than guess
140
+ }
141
+ }
142
+ return true;
143
+ }
144
+ /**
145
+ * Remove thread dirs whose new/processing/failed subdirs are all empty (or
146
+ * absent) and which have no active loop right now. Safe because
147
+ * writeMessage() always recreates the directory tree via a recursive
148
+ * mkdirSync before writing (inbox-manager.ts) — deleting an empty thread dir
149
+ * loses nothing, and a later message for the same thread just recreates it.
150
+ * Bounds the number of directories fs.watch has to track, which would
151
+ * otherwise only grow: thread dirs are never pruned anywhere else once
152
+ * created.
153
+ */
154
+ export function pruneEmptyInboxDirs(deps = defaultDeps()) {
155
+ for (const [, info] of deps.getTrackedBrains()) {
156
+ const inboxRoot = join(deps.brainsPath, info.brainId, '.inbox');
157
+ if (!existsSync(inboxRoot))
158
+ continue;
159
+ for (const threadId of listThreadDirs(inboxRoot)) {
160
+ if (deps.isInboxLoopActive(threadId))
161
+ continue;
162
+ if (!isThreadDirEmpty(inboxRoot, threadId))
163
+ continue;
164
+ const threadDir = join(inboxRoot, threadId);
165
+ try {
166
+ deps.rmThreadDir(threadDir);
167
+ logger.debug(`[InboxWatcher] Pruned empty inbox dir: ${threadId}`);
168
+ }
169
+ catch (error) {
170
+ // Most likely a message landed between the emptiness check and the
171
+ // remove (rmSync without `force` throws on a dir that's non-empty
172
+ // by then) — leave it for the next sweep rather than force-delete.
173
+ logger.debug(`[InboxWatcher] Skipped pruning ${threadId}: ${error instanceof Error ? error.message : error}`);
174
+ }
175
+ }
176
+ }
177
+ }
178
+ function scheduleCheck(deps) {
179
+ if (debounceTimer)
180
+ return;
181
+ debounceTimer = setTimeout(() => {
182
+ debounceTimer = null;
183
+ checkInboxForNewSessions(deps);
184
+ }, config.INBOX_WATCH_DEBOUNCE_MS);
185
+ }
186
+ /**
187
+ * Ensure every currently-tracked brain that already has a .inbox/ dir has a
188
+ * live fs.watch on it. Called on startup and on every fallback sweep, so a
189
+ * brain seen for the first time (or one whose .inbox/ didn't exist yet at
190
+ * watcher-startup — a brand-new brain's first-ever message always arrives
191
+ * via the normal SQS path, so this isn't on the critical path) gets covered
192
+ * within one fallback interval.
193
+ *
194
+ * Watching `<brain>/.inbox` specifically — not the whole brain checkout —
195
+ * keeps the watch count proportional to inbox activity: the brain repo
196
+ * itself (skills/, memory/, .git/) can have orders of magnitude more
197
+ * directories than its inbox ever will, and a single fs.watch holds one
198
+ * inotify watch descriptor per directory in the watched tree (verified:
199
+ * 801 watch descriptors for 200 nested thread dirs, all on one shared fd).
200
+ */
201
+ function syncBrainWatchers(deps) {
202
+ const seen = new Set();
203
+ for (const [, info] of deps.getTrackedBrains()) {
204
+ seen.add(info.brainId);
205
+ if (brainWatchers.has(info.brainId))
206
+ continue;
207
+ const inboxRoot = join(deps.brainsPath, info.brainId, '.inbox');
208
+ if (!existsSync(inboxRoot))
209
+ continue;
210
+ try {
211
+ const watcher = fsWatch(inboxRoot, { recursive: true }, () => scheduleCheck(deps));
212
+ watcher.on('error', (error) => {
213
+ logger.warn(`[InboxWatcher] Watch error for brain ${info.brainId}, dropping watcher: ${error.message}`);
214
+ brainWatchers.delete(info.brainId);
215
+ });
216
+ brainWatchers.set(info.brainId, watcher);
217
+ logger.debug(`[InboxWatcher] Watching ${inboxRoot}`);
218
+ }
219
+ catch (error) {
220
+ logger.debug(`[InboxWatcher] Could not watch ${inboxRoot} (${error instanceof Error ? error.message : error}), relying on fallback poll`);
221
+ }
222
+ }
223
+ for (const brainId of brainWatchers.keys()) {
224
+ if (!seen.has(brainId)) {
225
+ brainWatchers.get(brainId)?.close();
226
+ brainWatchers.delete(brainId);
227
+ }
228
+ }
229
+ }
230
+ /**
231
+ * Starts the fs.watch-driven wake path plus a sparse fallback sweep
232
+ * (default 5min) that (a) catches anything a missed/coalesced fs event
233
+ * dropped, (b) picks up brains whose .inbox/ didn't exist at watcher
234
+ * startup, and (c) prunes empty thread dirs. fs.watch gives near-instant
235
+ * wake; the fallback exists because relying on it exclusively would be a
236
+ * silent single point of failure with no observable "did it fire" signal.
237
+ */
238
+ export function startInboxWatcher(deps = defaultDeps()) {
239
+ if (fallbackInterval)
240
+ return;
241
+ syncBrainWatchers(deps);
242
+ const fallbackMs = config.INBOX_WATCH_FALLBACK_INTERVAL_MS;
243
+ logger.info(`[InboxWatcher] Started (fs.watch on ${brainWatchers.size} brain inbox dir(s), fallback sweep every ${fallbackMs}ms)`);
244
+ fallbackInterval = setInterval(() => {
245
+ syncBrainWatchers(deps);
246
+ checkInboxForNewSessions(deps);
247
+ pruneEmptyInboxDirs(deps);
248
+ }, fallbackMs);
249
+ }
250
+ export function stopInboxWatcher() {
251
+ if (fallbackInterval) {
252
+ clearInterval(fallbackInterval);
253
+ fallbackInterval = null;
254
+ }
255
+ if (debounceTimer) {
256
+ clearTimeout(debounceTimer);
257
+ debounceTimer = null;
258
+ }
259
+ for (const watcher of brainWatchers.values())
260
+ watcher.close();
261
+ brainWatchers.clear();
262
+ }
@@ -0,0 +1,18 @@
1
+ export interface ActivityCheckDeps {
2
+ statFn: (path: string) => Promise<{
3
+ mtimeMs: number;
4
+ }>;
5
+ }
6
+ /**
7
+ * Poller-side half of teamvibe.ai#131: skip the nightly maintenance trigger
8
+ * for a brain that has had zero real (non-maintenance-sourced) activity
9
+ * since its last maintenance run, so a quiet brain doesn't pay for a Claude
10
+ * spawn just to no-op inside the session's own time-debounce guard.
11
+ *
12
+ * `lastRealMessageAt` is set by registerBrain() (maintenance-cron.ts) on
13
+ * every non-maintenance message. It is undefined when the poller hasn't
14
+ * seen one since its own last restart (trackedBrains is in-memory, #127) —
15
+ * in that case we fail open (never skip) rather than risk starving a brain
16
+ * of maintenance because of a poller restart, not actual inactivity.
17
+ */
18
+ export declare function shouldSkipMaintenance(brainsPath: string, brainId: string, lastRealMessageAt: number | undefined, deps?: ActivityCheckDeps): Promise<boolean>;
@@ -0,0 +1,28 @@
1
+ import { stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ const defaultDeps = { statFn: stat };
4
+ /**
5
+ * Poller-side half of teamvibe.ai#131: skip the nightly maintenance trigger
6
+ * for a brain that has had zero real (non-maintenance-sourced) activity
7
+ * since its last maintenance run, so a quiet brain doesn't pay for a Claude
8
+ * spawn just to no-op inside the session's own time-debounce guard.
9
+ *
10
+ * `lastRealMessageAt` is set by registerBrain() (maintenance-cron.ts) on
11
+ * every non-maintenance message. It is undefined when the poller hasn't
12
+ * seen one since its own last restart (trackedBrains is in-memory, #127) —
13
+ * in that case we fail open (never skip) rather than risk starving a brain
14
+ * of maintenance because of a poller restart, not actual inactivity.
15
+ */
16
+ export async function shouldSkipMaintenance(brainsPath, brainId, lastRealMessageAt, deps = defaultDeps) {
17
+ if (lastRealMessageAt === undefined)
18
+ return false;
19
+ try {
20
+ const guardPath = join(brainsPath, brainId, 'memory/.last_consolidation');
21
+ const s = await deps.statFn(guardPath);
22
+ return lastRealMessageAt < s.mtimeMs;
23
+ }
24
+ catch {
25
+ // No guard file yet (brand-new brain, never consolidated) — never skip.
26
+ return false;
27
+ }
28
+ }
@@ -1,4 +1,26 @@
1
1
  import type { TeamVibeQueueMessage } from './types.js';
2
+ export interface TrackedBrainInfo {
3
+ brainId: string;
4
+ gitRepoUrl: string;
5
+ branch: string;
6
+ workspaceId: string;
7
+ botId: string;
8
+ botToken: string;
9
+ channelId: string;
10
+ lastRealMessageAt?: number;
11
+ }
12
+ /**
13
+ * Read-only view of brains seen so far (via registerBrain). Used by the inbox
14
+ * watcher (teamvibe.ai#242) to know which brain paths to scan and which
15
+ * workspace/bot credentials to use when starting a session for an idle thread.
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;
2
24
  /**
3
25
  * Register a brain from a real message so the maintenance cron knows about it.
4
26
  * Called from processMessage() after brain path is resolved.
@@ -6,9 +6,26 @@ 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();
13
+ /**
14
+ * Read-only view of brains seen so far (via registerBrain). Used by the inbox
15
+ * watcher (teamvibe.ai#242) to know which brain paths to scan and which
16
+ * workspace/bot credentials to use when starting a session for an idle thread.
17
+ */
18
+ export function getTrackedBrains() {
19
+ return trackedBrains;
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
+ }
12
29
  let maintenanceJob = null;
13
30
  /**
14
31
  * Register a brain from a real message so the maintenance cron knows about it.
@@ -30,6 +47,7 @@ export function registerBrain(msg) {
30
47
  botId: msg.teamvibe.botId,
31
48
  botToken: msg.teamvibe.botToken,
32
49
  channelId: msg.teamvibe.channelId,
50
+ lastRealMessageAt: Date.now(),
33
51
  });
34
52
  logger.debug(`[Maintenance] Registered brain: ${brain.brainId}`);
35
53
  // Metrics pipeline (teamvibe.ai#110): register for hourly collection. On the
@@ -60,6 +78,11 @@ async function triggerAllBrains(processMessageFn) {
60
78
  logger.debug(`[Maintenance] At capacity, skipping ${brainId}`);
61
79
  continue;
62
80
  }
81
+ if (await shouldSkipMaintenance(config.BRAINS_PATH, brainId, info.lastRealMessageAt)) {
82
+ markMaintenanceJobSkipped();
83
+ logger.debug(`[Maintenance] Skipping ${brainId} — no real activity since last maintenance (#131)`);
84
+ continue;
85
+ }
63
86
  logger.info(`[Maintenance] Triggering maintenance for brain ${brainId}`);
64
87
  const syntheticMessage = {
65
88
  id: `maintenance_${brainId}_${now}`,