@teamvibe/poller 0.6.0 → 0.6.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/claude-spawner.js +3 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +8 -0
- package/dist/inbox-watcher.d.ts +67 -0
- package/dist/inbox-watcher.js +262 -0
- package/dist/maintenance-cron.d.ts +15 -0
- package/dist/maintenance-cron.js +8 -0
- package/dist/maintenance-reporter.js +193 -88
- package/dist/poller.d.ts +10 -0
- package/dist/poller.js +13 -1
- package/package.json +1 -1
package/dist/claude-spawner.js
CHANGED
|
@@ -631,6 +631,9 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
631
631
|
context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
|
|
632
632
|
channelId: msg.teamvibe.channelId,
|
|
633
633
|
});
|
|
634
|
+
// Surface a visible status so the user isn't left with a stale/generic
|
|
635
|
+
// indicator during long silent stretches (e.g. context compaction).
|
|
636
|
+
typingIndicator?.updateStatus('Still working on a long response...');
|
|
634
637
|
idleWarned = true;
|
|
635
638
|
}
|
|
636
639
|
}, 30000); // Check every 30 seconds
|
package/dist/config.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
33
33
|
MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
|
|
34
34
|
AUTO_UPDATE_POLICY: z.ZodDefault<z.ZodEnum<["patch", "minor", "off"]>>;
|
|
35
35
|
AUTO_UPDATE_SCHEDULE: z.ZodDefault<z.ZodString>;
|
|
36
|
+
INBOX_WATCH_FALLBACK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
|
|
37
|
+
INBOX_WATCH_DEBOUNCE_MS: z.ZodDefault<z.ZodNumber>;
|
|
36
38
|
}, "strip", z.ZodTypeAny, {
|
|
37
39
|
AWS_REGION: string;
|
|
38
40
|
TEAMVIBE_API_URL: string;
|
|
@@ -62,6 +64,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
62
64
|
MAINTENANCE_ENABLED: boolean;
|
|
63
65
|
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
64
66
|
AUTO_UPDATE_SCHEDULE: string;
|
|
67
|
+
INBOX_WATCH_FALLBACK_INTERVAL_MS: number;
|
|
68
|
+
INBOX_WATCH_DEBOUNCE_MS: number;
|
|
65
69
|
SQS_QUEUE_URL?: string | undefined;
|
|
66
70
|
SESSIONS_TABLE?: string | undefined;
|
|
67
71
|
TEAMVIBE_POLLER_TOKEN?: string | undefined;
|
|
@@ -99,6 +103,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
99
103
|
MAINTENANCE_ENABLED?: string | undefined;
|
|
100
104
|
AUTO_UPDATE_POLICY?: "patch" | "minor" | "off" | undefined;
|
|
101
105
|
AUTO_UPDATE_SCHEDULE?: string | undefined;
|
|
106
|
+
INBOX_WATCH_FALLBACK_INTERVAL_MS?: number | undefined;
|
|
107
|
+
INBOX_WATCH_DEBOUNCE_MS?: number | undefined;
|
|
102
108
|
}>;
|
|
103
109
|
export type Config = z.infer<typeof configSchema>;
|
|
104
110
|
export declare function loadConfig(): Config;
|
|
@@ -131,6 +137,8 @@ export declare const config: {
|
|
|
131
137
|
MAINTENANCE_ENABLED: boolean;
|
|
132
138
|
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
133
139
|
AUTO_UPDATE_SCHEDULE: string;
|
|
140
|
+
INBOX_WATCH_FALLBACK_INTERVAL_MS: number;
|
|
141
|
+
INBOX_WATCH_DEBOUNCE_MS: number;
|
|
134
142
|
SQS_QUEUE_URL?: string | undefined;
|
|
135
143
|
SESSIONS_TABLE?: string | undefined;
|
|
136
144
|
TEAMVIBE_POLLER_TOKEN?: string | undefined;
|
package/dist/config.js
CHANGED
|
@@ -55,6 +55,14 @@ const configSchema = z.object({
|
|
|
55
55
|
// Auto-update settings
|
|
56
56
|
AUTO_UPDATE_POLICY: z.enum(['patch', 'minor', 'off']).default('patch'),
|
|
57
57
|
AUTO_UPDATE_SCHEDULE: z.string().default('03:00'), // HH:MM in UTC — daily check time
|
|
58
|
+
// Inbox watcher (teamvibe.ai#242): fs.watch()'s .inbox/ per tracked brain so a
|
|
59
|
+
// file dropped while no session is running (e.g. a background-task result)
|
|
60
|
+
// still starts one, instead of waiting inert for the next SQS message.
|
|
61
|
+
// The fallback sweep re-scans on an interval regardless of fs events — it
|
|
62
|
+
// catches missed/coalesced events, picks up brains whose .inbox/ didn't
|
|
63
|
+
// exist at watcher startup, and prunes empty thread dirs.
|
|
64
|
+
INBOX_WATCH_FALLBACK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 minutes
|
|
65
|
+
INBOX_WATCH_DEBOUNCE_MS: z.coerce.number().default(300), // coalesce bursty fs events into one scan
|
|
58
66
|
});
|
|
59
67
|
export function loadConfig() {
|
|
60
68
|
const result = configSchema.safeParse(process.env);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { SessionLogger } from './logger.js';
|
|
2
|
+
import { type TrackedBrainInfo } from './maintenance-cron.js';
|
|
3
|
+
import type { TeamVibeQueueMessage } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Everything checkInboxForNewSessions/pruneEmptyInboxDirs read from other
|
|
6
|
+
* modules, gathered behind one interface so tests can pass plain fakes
|
|
7
|
+
* instead of mocking poller.js / maintenance-cron.js / claude-spawner.js
|
|
8
|
+
* (those pull in AWS clients and process-spawning at import time, and Bun's
|
|
9
|
+
* mock.module() is process-global — it would leak into every other test
|
|
10
|
+
* file in the run).
|
|
11
|
+
*/
|
|
12
|
+
export interface InboxWatcherDeps {
|
|
13
|
+
brainsPath: string;
|
|
14
|
+
getTrackedBrains: () => ReadonlyMap<string, TrackedBrainInfo>;
|
|
15
|
+
isAtCapacity: () => boolean;
|
|
16
|
+
isInboxLoopActive: (threadId: string) => boolean;
|
|
17
|
+
runInboxLoop: (threadId: string, kbPath: string, queueMessage: TeamVibeQueueMessage, sessionLog: SessionLogger) => Promise<void>;
|
|
18
|
+
rmThreadDir: (path: string) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* threadId is always `<botId>:<channel>:<threadTs>` for slack-originated
|
|
22
|
+
* threads (see modules-teamvibe/hooks/slack-events.ts). Synthetic threadIds
|
|
23
|
+
* used elsewhere (`maintenance_<brainId>_<ts>`, and
|
|
24
|
+
* `scheduled:<scheduleId>:<ts>` from
|
|
25
|
+
* modules-teamvibe/src/functions/scheduler.ts:86) never sit idle waiting on
|
|
26
|
+
* a background-task drop, so they're skipped rather than guessed at.
|
|
27
|
+
* `scheduled:*:*` has the same 3-colon-segment shape as a real Slack
|
|
28
|
+
* threadId, so it's rejected explicitly rather than by segment count alone
|
|
29
|
+
* — otherwise a leftover unprocessed file in a stale scheduled-session inbox
|
|
30
|
+
* dir would make this parse `scheduleId` as a Slack channel.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseSlackThreadId(threadId: string): {
|
|
33
|
+
channel: string;
|
|
34
|
+
threadTs: string;
|
|
35
|
+
} | null;
|
|
36
|
+
export declare function listThreadDirs(inboxRoot: string): string[];
|
|
37
|
+
export declare function hasNewMessage(inboxRoot: string, threadId: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Scan every tracked brain's .inbox/ for a thread that has a new message but
|
|
40
|
+
* no active runInboxLoop, and start one. This is the only config-only path
|
|
41
|
+
* that can wake a fully idle session (teamvibe.ai#242) — a file dropped into
|
|
42
|
+
* .inbox/ by e.g. a background task otherwise sits inert until the next SQS
|
|
43
|
+
* message for that thread, which may never come.
|
|
44
|
+
*/
|
|
45
|
+
export declare function checkInboxForNewSessions(deps?: InboxWatcherDeps): void;
|
|
46
|
+
export declare function isThreadDirEmpty(inboxRoot: string, threadId: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Remove thread dirs whose new/processing/failed subdirs are all empty (or
|
|
49
|
+
* absent) and which have no active loop right now. Safe because
|
|
50
|
+
* writeMessage() always recreates the directory tree via a recursive
|
|
51
|
+
* mkdirSync before writing (inbox-manager.ts) — deleting an empty thread dir
|
|
52
|
+
* loses nothing, and a later message for the same thread just recreates it.
|
|
53
|
+
* Bounds the number of directories fs.watch has to track, which would
|
|
54
|
+
* otherwise only grow: thread dirs are never pruned anywhere else once
|
|
55
|
+
* created.
|
|
56
|
+
*/
|
|
57
|
+
export declare function pruneEmptyInboxDirs(deps?: InboxWatcherDeps): void;
|
|
58
|
+
/**
|
|
59
|
+
* Starts the fs.watch-driven wake path plus a sparse fallback sweep
|
|
60
|
+
* (default 5min) that (a) catches anything a missed/coalesced fs event
|
|
61
|
+
* dropped, (b) picks up brains whose .inbox/ didn't exist at watcher
|
|
62
|
+
* startup, and (c) prunes empty thread dirs. fs.watch gives near-instant
|
|
63
|
+
* wake; the fallback exists because relying on it exclusively would be a
|
|
64
|
+
* silent single point of failure with no observable "did it fire" signal.
|
|
65
|
+
*/
|
|
66
|
+
export declare function startInboxWatcher(deps?: InboxWatcherDeps): void;
|
|
67
|
+
export declare function stopInboxWatcher(): void;
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,19 @@
|
|
|
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
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read-only view of brains seen so far (via registerBrain). Used by the inbox
|
|
13
|
+
* watcher (teamvibe.ai#242) to know which brain paths to scan and which
|
|
14
|
+
* workspace/bot credentials to use when starting a session for an idle thread.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getTrackedBrains(): ReadonlyMap<string, TrackedBrainInfo>;
|
|
2
17
|
/**
|
|
3
18
|
* Register a brain from a real message so the maintenance cron knows about it.
|
|
4
19
|
* Called from processMessage() after brain path is resolved.
|
package/dist/maintenance-cron.js
CHANGED
|
@@ -9,6 +9,14 @@ import { sendMetrics } from './metrics/sender.js';
|
|
|
9
9
|
import { markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
const trackedBrains = new Map();
|
|
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 function getTrackedBrains() {
|
|
18
|
+
return trackedBrains;
|
|
19
|
+
}
|
|
12
20
|
let maintenanceJob = null;
|
|
13
21
|
/**
|
|
14
22
|
* Register a brain from a real message so the maintenance cron knows about it.
|
|
@@ -1,16 +1,72 @@
|
|
|
1
|
-
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
|
|
1
|
+
import { readdir, readFile, unlink, writeFile, mkdir, rename, } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { execFile } from 'child_process';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
import { config, POLLER_VERSION } from './config.js';
|
|
6
6
|
import { logger } from './logger.js';
|
|
7
7
|
import { emitError } from './event-queue.js';
|
|
8
|
-
import { getBaseBrainCommitSha, getBaseBrainAgeHours } from './base-brain-metadata.js';
|
|
8
|
+
import { getBaseBrainCommitSha, getBaseBrainAgeHours, } from './base-brain-metadata.js';
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const OPERATION_TO_EVENT_TYPE = {
|
|
11
11
|
consolidation: 'maintenance.consolidation',
|
|
12
12
|
reflection: 'maintenance.reflection',
|
|
13
13
|
};
|
|
14
|
+
// teamvibe.ai#222 — malformed reports used to error forever because nothing
|
|
15
|
+
// ever removed them from the brain repo. They're quarantined instead of
|
|
16
|
+
// deleted outright so the raw payload survives for forensic inspection
|
|
17
|
+
// (why did the generator emit invalid JSON?), then purged after a retention
|
|
18
|
+
// window so the quarantine folder doesn't grow unbounded.
|
|
19
|
+
const QUARANTINE_DIR_NAME = '.quarantine';
|
|
20
|
+
const QUARANTINE_RETENTION_DAYS = 30;
|
|
21
|
+
const QUARANTINE_NAME_RE = /^(\d{4}-\d{2}-\d{2})__(.+)$/;
|
|
22
|
+
function todayIso() {
|
|
23
|
+
return new Date().toISOString().slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
// Date is parsed from the quarantine filename prefix, never from fs mtime —
|
|
26
|
+
// brains are re-checked-out from git per session, so mtime reflects the last
|
|
27
|
+
// checkout, not when the file was actually quarantined.
|
|
28
|
+
function daysSince(isoDate) {
|
|
29
|
+
const from = new Date(`${isoDate}T00:00:00Z`).getTime();
|
|
30
|
+
const now = new Date(`${todayIso()}T00:00:00Z`).getTime();
|
|
31
|
+
return Math.floor((now - from) / (24 * 60 * 60 * 1000));
|
|
32
|
+
}
|
|
33
|
+
async function quarantineFile(brainPath, file) {
|
|
34
|
+
const quarantineDir = join(brainPath, 'reports', QUARANTINE_DIR_NAME);
|
|
35
|
+
await mkdir(quarantineDir, { recursive: true });
|
|
36
|
+
const quarantineName = `${todayIso()}__${file}`;
|
|
37
|
+
await rename(join(brainPath, 'reports', file), join(quarantineDir, quarantineName));
|
|
38
|
+
return quarantineName;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Sweep reports/.quarantine/ for entries past the retention window and
|
|
42
|
+
* remove them from disk. Runs unconditionally (independent of whether
|
|
43
|
+
* there are any new reports/*.json to process this session) so cleanup
|
|
44
|
+
* doesn't stall on brains with a low reporting cadence.
|
|
45
|
+
*/
|
|
46
|
+
async function sweepExpiredQuarantine(brainPath) {
|
|
47
|
+
const quarantineDir = join(brainPath, 'reports', QUARANTINE_DIR_NAME);
|
|
48
|
+
const entries = await readdir(quarantineDir).catch(() => []);
|
|
49
|
+
const expired = [];
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const match = entry.match(QUARANTINE_NAME_RE);
|
|
52
|
+
const quarantinedOn = match?.[1];
|
|
53
|
+
if (!quarantinedOn)
|
|
54
|
+
continue; // unexpected name — leave alone rather than guess an age
|
|
55
|
+
if (daysSince(quarantinedOn) >= QUARANTINE_RETENTION_DAYS) {
|
|
56
|
+
try {
|
|
57
|
+
await unlink(join(quarantineDir, entry));
|
|
58
|
+
expired.push(`reports/${QUARANTINE_DIR_NAME}/${entry}`);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Don't report this entry as expired if the unlink didn't actually
|
|
62
|
+
// happen — otherwise git rm --cached untracks it while it's still
|
|
63
|
+
// sitting on disk, and future sweeps can't see it's already "gone".
|
|
64
|
+
logger.warn(`[MaintenanceReporter] Failed to purge expired quarantine entry ${entry}: ${err instanceof Error ? err.message : err}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return expired;
|
|
69
|
+
}
|
|
14
70
|
/**
|
|
15
71
|
* Scan for JSON maintenance reports in the brain's reports/ directory
|
|
16
72
|
* and POST each to the /events endpoint. Fire-and-forget — never throws.
|
|
@@ -21,109 +77,158 @@ export async function postMaintenanceReports(brainPath, channelId) {
|
|
|
21
77
|
}
|
|
22
78
|
const reportsDir = join(brainPath, 'reports');
|
|
23
79
|
try {
|
|
80
|
+
const expiredQuarantineFiles = await sweepExpiredQuarantine(brainPath);
|
|
24
81
|
const files = await readdir(reportsDir).catch(() => []);
|
|
25
82
|
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
|
26
|
-
if (jsonFiles.length === 0)
|
|
27
|
-
return;
|
|
28
|
-
logger.info(`[MaintenanceReporter] Found ${jsonFiles.length} JSON report(s) in ${reportsDir}`);
|
|
29
|
-
// Fleet visibility fields — sampled once per run. See teamvibe.ai#129.
|
|
30
|
-
const [baseBrainCommitSha, baseBrainAgeHours] = await Promise.all([
|
|
31
|
-
getBaseBrainCommitSha(config.BASE_BRAIN_PATH),
|
|
32
|
-
getBaseBrainAgeHours(config.BASE_BRAIN_PATH),
|
|
33
|
-
]);
|
|
34
83
|
const postedFiles = [];
|
|
35
84
|
const removedFiles = [];
|
|
85
|
+
const quarantinedFrom = [];
|
|
86
|
+
const quarantinedTo = [];
|
|
36
87
|
let consolidationGuardTouched = false;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
if (jsonFiles.length > 0) {
|
|
89
|
+
logger.info(`[MaintenanceReporter] Found ${jsonFiles.length} JSON report(s) in ${reportsDir}`);
|
|
90
|
+
// Fleet visibility fields — sampled once per run. See teamvibe.ai#129.
|
|
91
|
+
const [baseBrainCommitSha, baseBrainAgeHours] = await Promise.all([
|
|
92
|
+
getBaseBrainCommitSha(config.BASE_BRAIN_PATH),
|
|
93
|
+
getBaseBrainAgeHours(config.BASE_BRAIN_PATH),
|
|
94
|
+
]);
|
|
95
|
+
for (const file of jsonFiles) {
|
|
96
|
+
const filePath = join(reportsDir, file);
|
|
97
|
+
try {
|
|
98
|
+
const content = await readFile(filePath, 'utf-8');
|
|
99
|
+
let report;
|
|
100
|
+
try {
|
|
101
|
+
report = JSON.parse(content);
|
|
102
|
+
}
|
|
103
|
+
catch (parseErr) {
|
|
104
|
+
// Unlike network/IO errors below, a JSON.parse failure is never
|
|
105
|
+
// going to recover on retry — the payload itself is broken.
|
|
106
|
+
// Quarantine it (not delete) so the content survives for later
|
|
107
|
+
// inspection into why the generator emitted invalid JSON.
|
|
108
|
+
const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
109
|
+
logger.warn(`[MaintenanceReporter] Quarantining malformed report ${file}: ${message}`);
|
|
110
|
+
emitError('maintenance', `Quarantining malformed report ${file}: ${message}`, { severity: 'warn', channelId });
|
|
111
|
+
const quarantineName = await quarantineFile(brainPath, file);
|
|
112
|
+
quarantinedFrom.push(`reports/${file}`);
|
|
113
|
+
quarantinedTo.push(`reports/${QUARANTINE_DIR_NAME}/${quarantineName}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!report.operationType) {
|
|
117
|
+
logger.warn(`[MaintenanceReporter] Removing invalid report ${file}: missing operationType`);
|
|
118
|
+
emitError('maintenance', `Removing invalid report ${file}: missing operationType`, { severity: 'warn' });
|
|
119
|
+
await unlink(filePath).catch(() => { });
|
|
120
|
+
removedFiles.push(`reports/${file}`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const eventType = OPERATION_TO_EVENT_TYPE[report.operationType];
|
|
124
|
+
if (!eventType) {
|
|
125
|
+
logger.warn(`[MaintenanceReporter] Removing report ${file}: unknown operationType "${report.operationType}"`);
|
|
126
|
+
emitError('maintenance', `Removing report ${file}: unknown operationType "${report.operationType}"`, { severity: 'warn' });
|
|
127
|
+
await unlink(filePath).catch(() => { });
|
|
128
|
+
removedFiles.push(`reports/${file}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const payload = {
|
|
132
|
+
eventType,
|
|
133
|
+
channelId,
|
|
134
|
+
pollerVersion: POLLER_VERSION,
|
|
135
|
+
baseBrainCommitSha,
|
|
136
|
+
baseBrainAgeHours,
|
|
137
|
+
metadata: report,
|
|
138
|
+
};
|
|
139
|
+
const response = await fetch(`${config.TEAMVIBE_API_URL}/events`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify(payload),
|
|
146
|
+
});
|
|
147
|
+
if (response.ok) {
|
|
148
|
+
logger.info(`[MaintenanceReporter] Posted ${file} as ${eventType}`);
|
|
149
|
+
await unlink(filePath).catch(() => { });
|
|
150
|
+
postedFiles.push(`reports/${file}`);
|
|
151
|
+
// Touch the consolidation guard file so the poller-local
|
|
152
|
+
// `daysSinceMaintenance` metric reflects this successful run.
|
|
153
|
+
// The metric reads `stat(memory/.last_consolidation).mtimeMs`, so any
|
|
154
|
+
// skip of the consolidation skill's "update timestamp" step causes
|
|
155
|
+
// silent drift. See poller-brain#109, poller-brain-eval#150.
|
|
156
|
+
if (report.operationType === 'consolidation') {
|
|
157
|
+
const guardPath = join(brainPath, 'memory', '.last_consolidation');
|
|
158
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
159
|
+
try {
|
|
160
|
+
await writeFile(guardPath, `${today}\n`);
|
|
161
|
+
consolidationGuardTouched = true;
|
|
162
|
+
logger.info(`[MaintenanceReporter] Touched ${guardPath}`);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
logger.warn(`[MaintenanceReporter] Failed to touch ${guardPath}: ${err instanceof Error ? err.message : err}`);
|
|
166
|
+
}
|
|
92
167
|
}
|
|
93
168
|
}
|
|
169
|
+
else {
|
|
170
|
+
const errorBody = await response.text().catch(() => 'unknown');
|
|
171
|
+
logger.warn(`[MaintenanceReporter] Failed to POST ${file}: ${response.status} ${errorBody}`);
|
|
172
|
+
emitError('maintenance', `Failed to POST report ${file}: ${response.status}`, { severity: 'warn', channelId });
|
|
173
|
+
}
|
|
94
174
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
emitError('maintenance', `Failed to POST report ${file}: ${response.status}`, { severity: 'warn', channelId });
|
|
175
|
+
catch (err) {
|
|
176
|
+
logger.warn(`[MaintenanceReporter] Error processing ${file}: ${err instanceof Error ? err.message : err}`);
|
|
177
|
+
emitError('maintenance', `Error processing report ${file}: ${err instanceof Error ? err.message : String(err)}`, { severity: 'warn', channelId });
|
|
99
178
|
}
|
|
100
179
|
}
|
|
101
|
-
catch (err) {
|
|
102
|
-
logger.warn(`[MaintenanceReporter] Error processing ${file}: ${err instanceof Error ? err.message : err}`);
|
|
103
|
-
emitError('maintenance', `Error processing report ${file}: ${err instanceof Error ? err.message : String(err)}`, { severity: 'warn', channelId });
|
|
104
|
-
}
|
|
105
180
|
}
|
|
106
|
-
// Remove posted/invalid JSON reports from git so they
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
181
|
+
// Remove posted/invalid/expired-quarantine JSON reports from git so they
|
|
182
|
+
// don't resurrect on next pull, stage newly quarantined reports at their
|
|
183
|
+
// new path, and stage the consolidation guard file if it was touched.
|
|
184
|
+
// Runs regardless of jsonFiles.length so the quarantine-expiry sweep
|
|
185
|
+
// still gets committed on sessions with no new reports to process.
|
|
186
|
+
const gitRemovals = [
|
|
187
|
+
...postedFiles,
|
|
188
|
+
...removedFiles,
|
|
189
|
+
...quarantinedFrom,
|
|
190
|
+
...expiredQuarantineFiles,
|
|
191
|
+
];
|
|
192
|
+
const gitAdditions = [...quarantinedTo];
|
|
193
|
+
if (gitRemovals.length > 0 ||
|
|
194
|
+
gitAdditions.length > 0 ||
|
|
195
|
+
consolidationGuardTouched) {
|
|
110
196
|
try {
|
|
111
|
-
if (
|
|
112
|
-
await execFileAsync('git', ['rm', '--cached', '--ignore-unmatch', ...
|
|
197
|
+
if (gitRemovals.length > 0) {
|
|
198
|
+
await execFileAsync('git', ['rm', '--cached', '--ignore-unmatch', ...gitRemovals], { cwd: brainPath });
|
|
199
|
+
}
|
|
200
|
+
if (gitAdditions.length > 0) {
|
|
201
|
+
// -f: quarantine payloads must survive regardless of any brain
|
|
202
|
+
// .gitignore rule matching dotdirs (e.g. a blanket `.*`) — a
|
|
203
|
+
// plain `git add` silently skips ignored paths, which would
|
|
204
|
+
// defeat the whole point of quarantining (forensic survival)
|
|
205
|
+
// the moment the next checkout drops the untracked file.
|
|
206
|
+
await execFileAsync('git', ['add', '-f', ...gitAdditions], {
|
|
207
|
+
cwd: brainPath,
|
|
208
|
+
});
|
|
113
209
|
}
|
|
114
210
|
if (consolidationGuardTouched) {
|
|
115
|
-
await execFileAsync('git', ['add', 'memory/.last_consolidation'], {
|
|
211
|
+
await execFileAsync('git', ['add', 'memory/.last_consolidation'], {
|
|
212
|
+
cwd: brainPath,
|
|
213
|
+
});
|
|
116
214
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
215
|
+
const messageParts = [];
|
|
216
|
+
if (postedFiles.length > 0 || removedFiles.length > 0)
|
|
217
|
+
messageParts.push('process maintenance reports');
|
|
218
|
+
if (quarantinedFrom.length > 0)
|
|
219
|
+
messageParts.push('quarantine malformed reports');
|
|
220
|
+
if (expiredQuarantineFiles.length > 0)
|
|
221
|
+
messageParts.push('purge expired quarantine');
|
|
222
|
+
if (consolidationGuardTouched)
|
|
223
|
+
messageParts.push('refresh consolidation guard');
|
|
224
|
+
const message = `chore: ${messageParts.join(' + ')}`;
|
|
225
|
+
await execFileAsync('git', ['commit', '-m', message], {
|
|
226
|
+
cwd: brainPath,
|
|
227
|
+
});
|
|
228
|
+
logger.info(`[MaintenanceReporter] Committed ${gitRemovals.length} removal(s), ${gitAdditions.length} quarantine addition(s)${consolidationGuardTouched ? ' + guard refresh' : ''}`);
|
|
124
229
|
}
|
|
125
230
|
catch (err) {
|
|
126
|
-
logger.debug(`[MaintenanceReporter] Could not git-rm reports (may not be a git repo): ${err instanceof Error ? err.message : err}`);
|
|
231
|
+
logger.debug(`[MaintenanceReporter] Could not git-rm/add reports (may not be a git repo): ${err instanceof Error ? err.message : err}`);
|
|
127
232
|
}
|
|
128
233
|
}
|
|
129
234
|
}
|
package/dist/poller.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { logger } from './logger.js';
|
|
2
|
+
import type { TeamVibeQueueMessage } from './types.js';
|
|
1
3
|
interface ReleaseLogger {
|
|
2
4
|
info: (msg: string) => void;
|
|
3
5
|
warn: (msg: string) => void;
|
|
@@ -13,5 +15,13 @@ export declare function releaseAllActiveLocks(locks: Map<string, string>, releas
|
|
|
13
15
|
* in morning-check telemetry. Same DI shape as runMaintenanceJob.
|
|
14
16
|
*/
|
|
15
17
|
export declare function runAutoUpdateJob(checkForUpdateFn?: () => Promise<boolean>): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Whether a runInboxLoop is currently active for this thread. Used by the
|
|
20
|
+
* inbox watcher (teamvibe.ai#242) to avoid starting a second loop for a
|
|
21
|
+
* thread that's already being served — its existing pickNextMessage() call
|
|
22
|
+
* will pick up any new file on its own.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isInboxLoopActive(threadId: string): boolean;
|
|
25
|
+
export declare function runInboxLoop(threadId: string, kbPath: string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession>): Promise<void>;
|
|
16
26
|
export declare function startPoller(): Promise<void>;
|
|
17
27
|
export {};
|
package/dist/poller.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './main
|
|
|
13
13
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
|
|
14
14
|
import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
|
|
15
15
|
import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
|
|
16
|
+
import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
|
|
16
17
|
import { Cron } from 'croner';
|
|
17
18
|
import { isControlMessage, handleControlMessage } from './control-handler.js';
|
|
18
19
|
import { checkForUpdate } from './auto-update.js';
|
|
@@ -122,7 +123,16 @@ async function attemptDnsFlush() {
|
|
|
122
123
|
}
|
|
123
124
|
// Track which threads have an active processing loop
|
|
124
125
|
const activeLoops = new Set();
|
|
125
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Whether a runInboxLoop is currently active for this thread. Used by the
|
|
128
|
+
* inbox watcher (teamvibe.ai#242) to avoid starting a second loop for a
|
|
129
|
+
* thread that's already being served — its existing pickNextMessage() call
|
|
130
|
+
* will pick up any new file on its own.
|
|
131
|
+
*/
|
|
132
|
+
export function isInboxLoopActive(threadId) {
|
|
133
|
+
return activeLoops.has(threadId);
|
|
134
|
+
}
|
|
135
|
+
export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
126
136
|
if (activeLoops.has(threadId)) {
|
|
127
137
|
sessionLog.info(`Loop already running for thread ${threadId}, skipping`);
|
|
128
138
|
return;
|
|
@@ -514,6 +524,7 @@ async function shutdown(signal, exitCode = 0) {
|
|
|
514
524
|
stopMaintenanceLoop();
|
|
515
525
|
stopAutoUpdateCron();
|
|
516
526
|
stopMetricsCron();
|
|
527
|
+
stopInboxWatcher();
|
|
517
528
|
stopEventQueue();
|
|
518
529
|
// Kill all active Claude child processes
|
|
519
530
|
await killAllActiveProcesses();
|
|
@@ -592,5 +603,6 @@ export async function startPoller() {
|
|
|
592
603
|
startMaintenanceLoop(processMessage);
|
|
593
604
|
startAutoUpdateCron();
|
|
594
605
|
startMetricsCron();
|
|
606
|
+
startInboxWatcher();
|
|
595
607
|
await pollLoop();
|
|
596
608
|
}
|