@teamvibe/poller 0.8.3 → 0.8.4-rc.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/README.md +13 -0
- package/dist/brain-directory.d.ts +14 -0
- package/dist/brain-directory.js +16 -0
- package/dist/brain-manager.d.ts +2 -2
- package/dist/brain-manager.js +32 -19
- package/dist/brain-path.d.ts +9 -0
- package/dist/brain-path.js +65 -0
- package/dist/claude-spawner.d.ts +3 -2
- package/dist/claude-spawner.js +195 -74
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/event-queue.js +14 -1
- package/dist/inbox-manager.d.ts +11 -0
- package/dist/inbox-manager.js +101 -6
- package/dist/inbox-watcher.d.ts +9 -4
- package/dist/inbox-watcher.js +219 -49
- package/dist/maintenance-cron.d.ts +4 -2
- package/dist/maintenance-cron.js +56 -14
- package/dist/metrics/scheduler.d.ts +4 -1
- package/dist/metrics/scheduler.js +8 -6
- package/dist/poller.d.ts +2 -1
- package/dist/poller.js +50 -24
- package/dist/pool-logging.js +2 -2
- package/dist/pool-telemetry.d.ts +21 -5
- package/dist/pool-telemetry.js +28 -11
- package/dist/session-store.d.ts +1 -1
- package/dist/session-store.js +6 -2
- package/dist/startup-auth.js +4 -1
- package/dist/subscription-pool-client.js +7 -0
- package/dist/subscription-pool.d.ts +1 -0
- package/dist/subscription-pool.js +1 -1
- package/dist/subscription-runtime.d.ts +21 -3
- package/dist/subscription-runtime.js +17 -9
- package/dist/subscription-selection.d.ts +20 -3
- package/dist/subscription-selection.js +66 -4
- package/dist/subscription-usage-store.d.ts +2 -0
- package/dist/subscription-usage-store.js +41 -20
- package/dist/thread-routing.d.ts +23 -0
- package/dist/thread-routing.js +81 -0
- package/dist/types.d.ts +4 -10
- package/dist/usage-limit-messages.d.ts +1 -1
- package/dist/usage-limit-messages.js +1 -1
- package/dist/usage-probe-contract.d.ts +15 -0
- package/dist/usage-probe-contract.js +14 -0
- package/dist/usage-probe-job.js +20 -7
- package/dist/worktree-manager.d.ts +2 -1
- package/dist/worktree-manager.js +38 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/inbox-manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync, rmSync } from 'fs';
|
|
1
|
+
import { constants, copyFileSync, mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync, rmSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import { inboxDropId, readThreadRouting, recordProcessedDrop } from './thread-routing.js';
|
|
3
4
|
import { logger } from './logger.js';
|
|
4
5
|
const INBOX_DIR = '.inbox';
|
|
5
6
|
const NEW_DIR = 'new';
|
|
@@ -33,6 +34,7 @@ export function writeMessage(brainPath, threadId, content) {
|
|
|
33
34
|
*/
|
|
34
35
|
export function pickNextMessage(brainPath, threadId) {
|
|
35
36
|
ensureInboxDirs(brainPath, threadId);
|
|
37
|
+
discardHandledInboxDrops(brainPath, threadId);
|
|
36
38
|
const inbox = getInboxPath(brainPath, threadId);
|
|
37
39
|
const processingDir = join(inbox, PROCESSING_DIR);
|
|
38
40
|
const newDir = join(inbox, NEW_DIR);
|
|
@@ -68,6 +70,7 @@ export function markFailed(brainPath, threadId, filename) {
|
|
|
68
70
|
const dstPath = join(failedDir, filename);
|
|
69
71
|
try {
|
|
70
72
|
if (existsSync(srcPath)) {
|
|
73
|
+
recordProcessedDrop(threadId, filename, readFileSync(srcPath));
|
|
71
74
|
renameSync(srcPath, dstPath);
|
|
72
75
|
logger.info(`[Inbox] Moved exhausted message to failed/: ${filename}`);
|
|
73
76
|
}
|
|
@@ -85,6 +88,7 @@ export function markProcessed(brainPath, threadId, filename) {
|
|
|
85
88
|
const filepath = join(getInboxPath(brainPath, threadId), PROCESSING_DIR, filename);
|
|
86
89
|
try {
|
|
87
90
|
if (existsSync(filepath)) {
|
|
91
|
+
recordProcessedDrop(threadId, filename, readFileSync(filepath));
|
|
88
92
|
unlinkSync(filepath);
|
|
89
93
|
logger.info(`[Inbox] Deleted processed message: ${filename}`);
|
|
90
94
|
}
|
|
@@ -167,19 +171,33 @@ export function saveInboxContext(brainPath, msg) {
|
|
|
167
171
|
const { botToken: _token, ...teamvibe } = msg.teamvibe;
|
|
168
172
|
writeInboxState(brainPath, msg.thread_id, 'context.json', { ...msg, teamvibe });
|
|
169
173
|
}
|
|
170
|
-
|
|
174
|
+
function inboxContextCandidates(brainPath, threadId) {
|
|
171
175
|
const root = getInboxPath(brainPath, threadId);
|
|
172
176
|
const candidates = [];
|
|
173
177
|
for (const subdir of [PROCESSING_DIR, NEW_DIR]) {
|
|
174
178
|
try {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
candidates.push(join(root, subdir, first));
|
|
179
|
+
for (const name of readdirSync(join(root, subdir)).filter(name => name.endsWith('.txt')).sort())
|
|
180
|
+
candidates.push(join(root, subdir, name));
|
|
178
181
|
}
|
|
179
182
|
catch { }
|
|
180
183
|
}
|
|
181
184
|
candidates.push(join(root, 'context.json'));
|
|
182
|
-
|
|
185
|
+
return candidates;
|
|
186
|
+
}
|
|
187
|
+
/** Even partial envelopes carrying channel context must never use a singleton guess. */
|
|
188
|
+
export function readInboxChannelContext(brainPath, threadId) {
|
|
189
|
+
for (const path of inboxContextCandidates(brainPath, threadId)) {
|
|
190
|
+
try {
|
|
191
|
+
const msg = JSON.parse(readFileSync(path, 'utf8'));
|
|
192
|
+
if (msg.teamvibe && 'channelId' in msg.teamvibe)
|
|
193
|
+
return { workspaceId: msg.teamvibe.workspaceId, channelId: msg.teamvibe.channelId };
|
|
194
|
+
}
|
|
195
|
+
catch { /* Not a readable routing envelope; inspect remaining context. */ }
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
export function readInboxContext(brainPath, threadId) {
|
|
200
|
+
for (const path of inboxContextCandidates(brainPath, threadId)) {
|
|
183
201
|
try {
|
|
184
202
|
const msg = JSON.parse(readFileSync(path, 'utf8'));
|
|
185
203
|
if (msg.thread_id !== threadId || !['slack', 'email', 'cron', 'maintenance', 'webhook'].includes(msg.source) || typeof msg.teamvibe?.workspaceId !== 'string' || typeof msg.teamvibe?.channelId !== 'string' || !msg.response_context || typeof msg.text !== 'string' || !msg.sender || !Array.isArray(msg.attachments))
|
|
@@ -210,3 +228,80 @@ export function clearInboxRuntime(brainPath, threadId) {
|
|
|
210
228
|
}
|
|
211
229
|
catch { }
|
|
212
230
|
}
|
|
231
|
+
/** Remove transfer remnants and completed copies before discovery or replay. */
|
|
232
|
+
export function discardHandledInboxDrops(brainPath, threadId) {
|
|
233
|
+
const processed = new Set(readThreadRouting(threadId)?.processedDropIds ?? []);
|
|
234
|
+
for (const subdir of [NEW_DIR, PROCESSING_DIR]) {
|
|
235
|
+
const directory = join(getInboxPath(brainPath, threadId), subdir);
|
|
236
|
+
if (!existsSync(directory))
|
|
237
|
+
continue;
|
|
238
|
+
for (const filename of readdirSync(directory).filter(name => name.endsWith('.txt'))) {
|
|
239
|
+
const path = join(directory, filename);
|
|
240
|
+
const id = inboxDropId(threadId, filename, readFileSync(path));
|
|
241
|
+
const marker = `${path}.moved`;
|
|
242
|
+
if (processed.has(id) || existsSync(marker) && readFileSync(marker, 'utf8') === id)
|
|
243
|
+
unlinkSync(path);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** Move pending content only; a different brain must never inherit continuation state. */
|
|
248
|
+
export function restartInboxInDirectory(source, target, threadId) {
|
|
249
|
+
ensureInboxDirs(target, threadId);
|
|
250
|
+
discardHandledInboxDrops(source, threadId);
|
|
251
|
+
for (const subdir of [PROCESSING_DIR, NEW_DIR]) {
|
|
252
|
+
const from = join(getInboxPath(source, threadId), subdir);
|
|
253
|
+
if (!existsSync(from))
|
|
254
|
+
continue;
|
|
255
|
+
for (const filename of readdirSync(from).filter(name => name.endsWith('.txt'))) {
|
|
256
|
+
const original = join(from, filename);
|
|
257
|
+
const content = readFileSync(original);
|
|
258
|
+
const id = inboxDropId(threadId, filename, content);
|
|
259
|
+
const destination = join(getInboxPath(target, threadId), subdir, filename);
|
|
260
|
+
const existing = [PROCESSING_DIR, NEW_DIR].map(stage => join(getInboxPath(target, threadId), stage, filename)).find(path => existsSync(path));
|
|
261
|
+
if (existing) {
|
|
262
|
+
if (inboxDropId(threadId, filename, readFileSync(existing)) !== id)
|
|
263
|
+
throw new Error('Inbox transfer destination has different content');
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
try {
|
|
267
|
+
renameSync(original, destination);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error.code !== 'EXDEV')
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
copyFileSync(original, destination, constants.COPYFILE_EXCL);
|
|
275
|
+
}
|
|
276
|
+
// Cross-filesystem interruption: discovery can suppress the source even
|
|
277
|
+
// after the destination has been consumed. Identical existing copies
|
|
278
|
+
// also cover a crash between the copy and this marker write.
|
|
279
|
+
writeFileSync(`${original}.moved`, id, { mode: 0o600 });
|
|
280
|
+
unlinkSync(original);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Preserve unroutable work for operator review and remove it from replay queues. */
|
|
285
|
+
export function quarantineInbox(brainPath, threadId, includeFailed = false) {
|
|
286
|
+
const root = getInboxPath(brainPath, threadId);
|
|
287
|
+
const quarantine = join(root, 'unroutable');
|
|
288
|
+
for (const subdir of includeFailed ? [NEW_DIR, PROCESSING_DIR, FAILED_DIR] : [NEW_DIR, PROCESSING_DIR]) {
|
|
289
|
+
const source = join(root, subdir);
|
|
290
|
+
if (!existsSync(source))
|
|
291
|
+
continue;
|
|
292
|
+
for (const filename of readdirSync(source).filter(name => name.endsWith('.txt'))) {
|
|
293
|
+
const target = join(quarantine, subdir);
|
|
294
|
+
mkdirSync(target, { recursive: true });
|
|
295
|
+
// A later drop may reuse a name; never replace earlier quarantined work.
|
|
296
|
+
const destination = join(target, filename);
|
|
297
|
+
copyFileSync(join(source, filename), destination, constants.COPYFILE_EXCL);
|
|
298
|
+
unlinkSync(join(source, filename));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (const name of ['context.json', 'pool-session.json']) {
|
|
302
|
+
if (!existsSync(join(root, name)))
|
|
303
|
+
continue;
|
|
304
|
+
mkdirSync(quarantine, { recursive: true });
|
|
305
|
+
renameSync(join(root, name), join(quarantine, `${Date.now()}-${name}`));
|
|
306
|
+
}
|
|
307
|
+
}
|
package/dist/inbox-watcher.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { type BrainDirectory } from './brain-directory.js';
|
|
1
2
|
import { SessionLogger } from './logger.js';
|
|
2
3
|
import { type TrackedBrainInfo } from './maintenance-cron.js';
|
|
4
|
+
import { sendSlackMessage } from './slack-client.js';
|
|
3
5
|
import type { TeamVibeQueueMessage } from './types.js';
|
|
4
6
|
/**
|
|
5
7
|
* Everything checkInboxForNewSessions/pruneEmptyInboxDirs read from other
|
|
@@ -11,10 +13,13 @@ import type { TeamVibeQueueMessage } from './types.js';
|
|
|
11
13
|
*/
|
|
12
14
|
export interface InboxWatcherDeps {
|
|
13
15
|
brainsPath: string;
|
|
16
|
+
defaultBrainPath?: string;
|
|
14
17
|
getTrackedBrains: () => ReadonlyMap<string, TrackedBrainInfo>;
|
|
15
18
|
isAtCapacity: () => boolean;
|
|
16
19
|
isInboxLoopActive: (threadId: string) => boolean;
|
|
17
|
-
runInboxLoop: (threadId: string,
|
|
20
|
+
runInboxLoop: (threadId: string, directory: BrainDirectory | string, queueMessage: TeamVibeQueueMessage, sessionLog: SessionLogger) => Promise<void>;
|
|
21
|
+
sendSlackMessage?: typeof sendSlackMessage;
|
|
22
|
+
getSessionStatus?: (threadId: string) => Promise<string | undefined>;
|
|
18
23
|
rmThreadDir: (path: string) => void;
|
|
19
24
|
}
|
|
20
25
|
/**
|
|
@@ -36,16 +41,16 @@ export declare function parseSlackThreadId(threadId: string): {
|
|
|
36
41
|
export declare function listThreadDirs(inboxRoot: string): string[];
|
|
37
42
|
export declare function hasNewMessage(inboxRoot: string, threadId: string): boolean;
|
|
38
43
|
/**
|
|
39
|
-
* Scan
|
|
44
|
+
* Scan current and former brain .inbox/ directories for a thread that has queued or retained work but
|
|
40
45
|
* no active runInboxLoop, and start one. This is the only config-only path
|
|
41
46
|
* that can wake a fully idle session (teamvibe.ai#242) — a file dropped into
|
|
42
47
|
* .inbox/ by e.g. a background task otherwise sits inert until the next SQS
|
|
43
48
|
* message for that thread, which may never come.
|
|
44
49
|
*/
|
|
45
|
-
export declare function checkInboxForNewSessions(deps?: InboxWatcherDeps): void
|
|
50
|
+
export declare function checkInboxForNewSessions(deps?: InboxWatcherDeps): Promise<void>;
|
|
46
51
|
export declare function isThreadDirEmpty(inboxRoot: string, threadId: string): boolean;
|
|
47
52
|
/**
|
|
48
|
-
* Remove thread dirs whose new/processing/failed subdirs are all empty (or
|
|
53
|
+
* Remove thread dirs whose new/processing/failed/unroutable subdirs are all empty (or
|
|
49
54
|
* absent) and which have no active loop right now. Safe because
|
|
50
55
|
* writeMessage() always recreates the directory tree via a recursive
|
|
51
56
|
* mkdirSync before writing (inbox-manager.ts) — deleting an empty thread dir
|
package/dist/inbox-watcher.js
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { executionDirectory } from './brain-directory.js';
|
|
2
|
+
import { discardHandledInboxDrops, markProcessed, quarantineInbox, readInboxChannelContext, readInboxContext, readInboxRuntime, restartInboxInDirectory } from './inbox-manager.js';
|
|
3
|
+
import { listThreadRoutings, readThreadRouting, removeThreadRouting, threadRoutingKey } from './thread-routing.js';
|
|
2
4
|
import { isPoolDeferred, readPoolDefer } from './pool-defer.js';
|
|
3
5
|
import { existsSync, readdirSync, rmSync, watch as fsWatch } from 'fs';
|
|
4
6
|
import { join } from 'path';
|
|
5
7
|
import { config } from './config.js';
|
|
8
|
+
import { getBrainPath } from './brain-manager.js';
|
|
9
|
+
import { resolveBrainDir, legacyBrainPath } from './brain-path.js';
|
|
6
10
|
import { logger } from './logger.js';
|
|
7
11
|
import { isAtCapacity } from './claude-spawner.js';
|
|
8
12
|
import { getTrackedBrains } from './maintenance-cron.js';
|
|
9
13
|
import { runInboxLoop, isInboxLoopActive } from './poller.js';
|
|
10
14
|
import { isQuotaPaused } from './quota-pause.js';
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
import { emitError } from './event-queue.js';
|
|
16
|
+
import { sendSlackMessage } from './slack-client.js';
|
|
17
|
+
const INBOX_SUBDIRS = ['new', 'processing', 'failed', 'unroutable'];
|
|
18
|
+
const brainWatchers = new Map();
|
|
19
|
+
const recoveringThreads = new Set();
|
|
13
20
|
let fallbackInterval = null;
|
|
14
21
|
let debounceTimer = null;
|
|
15
22
|
function defaultDeps() {
|
|
16
23
|
return {
|
|
17
24
|
brainsPath: config.BRAINS_PATH,
|
|
25
|
+
defaultBrainPath: config.DEFAULT_BRAIN_PATH,
|
|
18
26
|
getTrackedBrains,
|
|
19
27
|
isAtCapacity,
|
|
20
28
|
isInboxLoopActive,
|
|
21
29
|
runInboxLoop,
|
|
30
|
+
sendSlackMessage,
|
|
31
|
+
getSessionStatus: async (threadId) => (await (await import('./session-store.js')).getSession(threadId))?.status,
|
|
22
32
|
rmThreadDir: (path) => rmSync(path, { recursive: true }),
|
|
23
33
|
};
|
|
24
34
|
}
|
|
@@ -45,6 +55,15 @@ export function parseSlackThreadId(threadId) {
|
|
|
45
55
|
return null;
|
|
46
56
|
return { channel, threadTs };
|
|
47
57
|
}
|
|
58
|
+
function platformRouting(info) {
|
|
59
|
+
return {
|
|
60
|
+
workspaceId: info.workspaceId,
|
|
61
|
+
botId: info.botId,
|
|
62
|
+
botToken: info.botToken,
|
|
63
|
+
channelId: info.channelId,
|
|
64
|
+
brain: { brainId: info.brainId, gitRepoUrl: info.gitRepoUrl, branch: info.branch },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
48
67
|
function buildSyntheticMessage(info, threadId, parsed) {
|
|
49
68
|
return {
|
|
50
69
|
id: `inbox_watch_${threadId}`,
|
|
@@ -61,17 +80,7 @@ function buildSyntheticMessage(info, threadId, parsed) {
|
|
|
61
80
|
},
|
|
62
81
|
},
|
|
63
82
|
type: 'message',
|
|
64
|
-
teamvibe:
|
|
65
|
-
workspaceId: info.workspaceId,
|
|
66
|
-
botId: info.botId,
|
|
67
|
-
botToken: info.botToken,
|
|
68
|
-
channelId: info.channelId,
|
|
69
|
-
brain: {
|
|
70
|
-
brainId: info.brainId,
|
|
71
|
-
gitRepoUrl: info.gitRepoUrl,
|
|
72
|
-
branch: info.branch,
|
|
73
|
-
},
|
|
74
|
-
},
|
|
83
|
+
teamvibe: platformRouting(info),
|
|
75
84
|
};
|
|
76
85
|
}
|
|
77
86
|
export function listThreadDirs(inboxRoot) {
|
|
@@ -97,38 +106,65 @@ export function hasNewMessage(inboxRoot, threadId) {
|
|
|
97
106
|
return hasMessage(inboxRoot, threadId, 'new');
|
|
98
107
|
}
|
|
99
108
|
/**
|
|
100
|
-
* Scan
|
|
109
|
+
* Scan current and former brain .inbox/ directories for a thread that has queued or retained work but
|
|
101
110
|
* no active runInboxLoop, and start one. This is the only config-only path
|
|
102
111
|
* that can wake a fully idle session (teamvibe.ai#242) — a file dropped into
|
|
103
112
|
* .inbox/ by e.g. a background task otherwise sits inert until the next SQS
|
|
104
113
|
* message for that thread, which may never come.
|
|
105
114
|
*/
|
|
106
|
-
export function checkInboxForNewSessions(deps = defaultDeps()) {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
export async function checkInboxForNewSessions(deps = defaultDeps()) {
|
|
116
|
+
const routes = [];
|
|
117
|
+
const brains = new Set();
|
|
118
|
+
// This is the full current platform channel map, not routes associated
|
|
119
|
+
// with a source directory (which may belong to a former assignment).
|
|
120
|
+
for (const info of deps.getTrackedBrains().values()) {
|
|
121
|
+
try {
|
|
122
|
+
const directory = executionDirectory(info.directory, deps.brainsPath);
|
|
123
|
+
routes.push({ info, directory });
|
|
124
|
+
brains.add(directory.path);
|
|
125
|
+
}
|
|
126
|
+
catch { /* Invalid platform identity was logged and reported; never scan a fallback. */ }
|
|
127
|
+
}
|
|
128
|
+
// Discover former locations from disk after restart, including legacy
|
|
129
|
+
// clones. Only inbox drops are read/removed there; no clone is adopted.
|
|
130
|
+
for (const workspace of listThreadDirs(deps.brainsPath)) {
|
|
131
|
+
let legacy;
|
|
132
|
+
try {
|
|
133
|
+
legacy = legacyBrainPath(workspace, deps.brainsPath);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (existsSync(join(legacy, '.inbox')))
|
|
139
|
+
brains.add(legacy);
|
|
140
|
+
for (const brain of listThreadDirs(legacy)) {
|
|
141
|
+
if (!existsSync(join(legacy, brain, '.inbox')))
|
|
142
|
+
continue;
|
|
143
|
+
try {
|
|
144
|
+
brains.add(resolveBrainDir(workspace, brain, deps.brainsPath));
|
|
118
145
|
}
|
|
146
|
+
catch { /* Invalid discovered identities are never used for execution. */ }
|
|
119
147
|
}
|
|
120
148
|
}
|
|
121
|
-
|
|
122
|
-
|
|
149
|
+
if (deps.defaultBrainPath)
|
|
150
|
+
brains.add(deps.defaultBrainPath);
|
|
151
|
+
const expired = await sweepThreadRouting(deps, brains);
|
|
152
|
+
const started = new Set();
|
|
153
|
+
const recoveries = [];
|
|
154
|
+
scan: for (const brainPath of brains) {
|
|
123
155
|
if (deps.isAtCapacity())
|
|
124
|
-
|
|
156
|
+
break;
|
|
125
157
|
const inboxRoot = join(brainPath, '.inbox');
|
|
126
158
|
for (const threadId of listThreadDirs(inboxRoot)) {
|
|
127
159
|
if (deps.isAtCapacity())
|
|
128
|
-
|
|
129
|
-
if (
|
|
160
|
+
break scan;
|
|
161
|
+
if (expired.has(threadRoutingKey(threadId)))
|
|
162
|
+
continue;
|
|
163
|
+
if (started.has(threadId) || recoveringThreads.has(threadId) || deps.isInboxLoopActive(threadId) || isPoolDeferred(threadId))
|
|
130
164
|
continue;
|
|
131
|
-
|
|
165
|
+
discardHandledInboxDrops(brainPath, threadId);
|
|
166
|
+
const runtime = readInboxRuntime(brainPath, threadId);
|
|
167
|
+
const completion = runtime?.completed;
|
|
132
168
|
const retryCompletion = completion && (Date.now() < completion.observedAt || Date.now() >= completion.retryAt);
|
|
133
169
|
if (!hasNewMessage(inboxRoot, threadId) && !hasMessage(inboxRoot, threadId, 'processing') && !retryCompletion)
|
|
134
170
|
continue;
|
|
@@ -136,15 +172,148 @@ export function checkInboxForNewSessions(deps = defaultDeps()) {
|
|
|
136
172
|
continue;
|
|
137
173
|
const durable = readInboxContext(brainPath, threadId);
|
|
138
174
|
const parsed = parseSlackThreadId(threadId);
|
|
139
|
-
|
|
140
|
-
|
|
175
|
+
// Context or poller-owned per-thread routing selects a channel in the
|
|
176
|
+
// full map. Never infer ownership from a directory's current channels.
|
|
177
|
+
const storedRoute = readInboxChannelContext(brainPath, threadId) ?? readThreadRouting(threadId);
|
|
178
|
+
const route = storedRoute
|
|
179
|
+
? routes.find(({ info }) => info.workspaceId === storedRoute.workspaceId && info.channelId === storedRoute.channelId)
|
|
180
|
+
: undefined;
|
|
181
|
+
if (!route) {
|
|
182
|
+
quarantineRetainedThread(deps, brainPath, threadId, durable);
|
|
141
183
|
continue;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
184
|
+
}
|
|
185
|
+
const info = route.info;
|
|
186
|
+
const directory = route.directory;
|
|
187
|
+
const current = parsed ? buildSyntheticMessage(info, threadId, parsed) : undefined;
|
|
188
|
+
// Stored context supplies content, never the selected directory identity.
|
|
189
|
+
const message = durable
|
|
190
|
+
? { ...durable, teamvibe: platformRouting(info) }
|
|
191
|
+
: current;
|
|
192
|
+
if (!message) {
|
|
193
|
+
quarantineRetainedThread(deps, brainPath, threadId, durable);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
started.add(threadId);
|
|
197
|
+
recoveringThreads.add(threadId);
|
|
198
|
+
recoveries.push((async () => {
|
|
199
|
+
try {
|
|
200
|
+
// A newly assigned brain may never have been used on this poller.
|
|
201
|
+
// Clone before relocation creates .inbox/, so clone reuse cannot
|
|
202
|
+
// mistake that inbox-only directory for an existing repository.
|
|
203
|
+
if (directory.workspaceId && !existsSync(directory.path)) {
|
|
204
|
+
await getBrainPath(message.teamvibe.brain, undefined, directory.workspaceId);
|
|
205
|
+
}
|
|
206
|
+
if (directory.path !== brainPath) {
|
|
207
|
+
// A completed prompt must never run again in the new brain. Its old
|
|
208
|
+
// bookkeeping stays at the old location; move only pending content.
|
|
209
|
+
if (completion)
|
|
210
|
+
markProcessed(brainPath, threadId, runtime.filename);
|
|
211
|
+
if (!hasNewMessage(inboxRoot, threadId) && !hasMessage(inboxRoot, threadId, 'processing'))
|
|
212
|
+
return;
|
|
213
|
+
try {
|
|
214
|
+
restartInboxInDirectory(brainPath, directory.path, threadId);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
logger.error('[InboxWatcher] Could not move retained work to the current brain; work remains saved');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
logger.warn(`[InboxWatcher] Restarting retained work for channel ${info.channelId} in its current brain ${info.brainId}; former continuation is not resumed`);
|
|
221
|
+
}
|
|
222
|
+
if (deps.isAtCapacity())
|
|
223
|
+
return;
|
|
224
|
+
const sessionLog = logger.createSession(`iw_${Math.random().toString(36).slice(2, 10)}`);
|
|
225
|
+
await deps.runInboxLoop(threadId, directory, message, sessionLog);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
recoveringThreads.delete(threadId);
|
|
229
|
+
}
|
|
230
|
+
})().catch(() => {
|
|
231
|
+
logger.error('[InboxWatcher] Retained inbox recovery failed; work remains saved');
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
await Promise.all(recoveries);
|
|
236
|
+
}
|
|
237
|
+
/** Expiry precedes capacity/quota gates: stale work is preserved, never executed. */
|
|
238
|
+
async function sweepThreadRouting(deps, brains) {
|
|
239
|
+
const discovered = new Map();
|
|
240
|
+
for (const brain of brains) {
|
|
241
|
+
for (const thread of listThreadDirs(join(brain, '.inbox')))
|
|
242
|
+
discovered.set(threadRoutingKey(thread), thread);
|
|
243
|
+
}
|
|
244
|
+
const expired = new Set();
|
|
245
|
+
for (const record of listThreadRoutings()) {
|
|
246
|
+
const thread = record.threadId ?? discovered.get(record.key);
|
|
247
|
+
const isExpired = Date.now() - record.modifiedAt >= config.INBOX_ROUTING_TTL_DAYS * 86_400_000;
|
|
248
|
+
if (isExpired)
|
|
249
|
+
expired.add(record.key);
|
|
250
|
+
if (thread && (deps.isInboxLoopActive(thread) || recoveringThreads.has(thread)))
|
|
251
|
+
continue;
|
|
252
|
+
if (isExpired) {
|
|
253
|
+
let quarantined = true;
|
|
254
|
+
if (thread) {
|
|
255
|
+
for (const brain of brains) {
|
|
256
|
+
if (!existsSync(join(brain, '.inbox', thread)))
|
|
257
|
+
continue;
|
|
258
|
+
if (!quarantineRetainedThread(deps, brain, thread, readInboxContext(brain, thread), true))
|
|
259
|
+
quarantined = false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (quarantined)
|
|
263
|
+
removeThreadRouting(record.key);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (!thread || !deps.getSessionStatus)
|
|
267
|
+
continue;
|
|
268
|
+
try {
|
|
269
|
+
const status = await deps.getSessionStatus(thread);
|
|
270
|
+
if (!status || !['completed', 'failed', 'aborted'].includes(status))
|
|
271
|
+
continue;
|
|
272
|
+
if (deps.isInboxLoopActive(thread) || recoveringThreads.has(thread))
|
|
273
|
+
continue;
|
|
274
|
+
// Inspect every discovered location, including interrupted transfer sources.
|
|
275
|
+
if ([...brains].some(brain => hasInboxDrops(join(brain, '.inbox', thread)) || readInboxRuntime(brain, thread)?.completed))
|
|
276
|
+
continue;
|
|
277
|
+
removeThreadRouting(record.key);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
logger.debug('[InboxWatcher] Routing cleanup deferred; session status unavailable');
|
|
146
281
|
}
|
|
147
282
|
}
|
|
283
|
+
return expired;
|
|
284
|
+
}
|
|
285
|
+
/** Fail closed on unreadable inboxes; metadata and .moved markers are not drops. */
|
|
286
|
+
function hasInboxDrops(path) {
|
|
287
|
+
try {
|
|
288
|
+
return readdirSync(path, { withFileTypes: true }).some(entry => entry.isDirectory()
|
|
289
|
+
? hasInboxDrops(join(path, entry.name)) : entry.name.endsWith('.txt'));
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
return error.code !== 'ENOENT';
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function quarantineRetainedThread(deps, brainPath, threadId, durable, expired = false) {
|
|
296
|
+
try {
|
|
297
|
+
quarantineInbox(brainPath, threadId, expired);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
logger.error('[InboxWatcher] Could not quarantine unroutable inbox; work remains saved');
|
|
301
|
+
emitError('session', 'Could not quarantine unroutable inbox', { context: { threadId, reason: 'unroutable_inbox' } });
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
const detail = expired ? 'Expired thread routing; retained inbox saved in unroutable/ for operator review' : 'Retained inbox has no unambiguous current channel; saved in unroutable/ for operator review';
|
|
305
|
+
logger.error(`[InboxWatcher] ${detail}: ${threadId}`);
|
|
306
|
+
emitError('session', detail, { channelId: durable?.teamvibe.channelId, context: { threadId, reason: expired ? 'expired_inbox_routing' : 'unroutable_inbox' } });
|
|
307
|
+
const parsed = parseSlackThreadId(threadId);
|
|
308
|
+
if (!parsed || !deps.sendSlackMessage)
|
|
309
|
+
return true;
|
|
310
|
+
// Slack ids are used only for notification, never to choose a platform channel.
|
|
311
|
+
const info = [...deps.getTrackedBrains().values()].find(info => info.botId === threadId.split(':')[0] && (!durable || info.workspaceId === durable.teamvibe.workspaceId));
|
|
312
|
+
if (!info)
|
|
313
|
+
return true;
|
|
314
|
+
void deps.sendSlackMessage(buildSyntheticMessage(info, threadId, parsed), 'A retained background result could not be routed to a current channel. It has been saved for operator review; please contact your poller operator.')
|
|
315
|
+
.catch(() => logger.error('[InboxWatcher] Could not send unroutable inbox notice'));
|
|
316
|
+
return true;
|
|
148
317
|
}
|
|
149
318
|
export function isThreadDirEmpty(inboxRoot, threadId) {
|
|
150
319
|
for (const sub of INBOX_SUBDIRS) {
|
|
@@ -162,7 +331,7 @@ export function isThreadDirEmpty(inboxRoot, threadId) {
|
|
|
162
331
|
return true;
|
|
163
332
|
}
|
|
164
333
|
/**
|
|
165
|
-
* Remove thread dirs whose new/processing/failed subdirs are all empty (or
|
|
334
|
+
* Remove thread dirs whose new/processing/failed/unroutable subdirs are all empty (or
|
|
166
335
|
* absent) and which have no active loop right now. Safe because
|
|
167
336
|
* writeMessage() always recreates the directory tree via a recursive
|
|
168
337
|
* mkdirSync before writing (inbox-manager.ts) — deleting an empty thread dir
|
|
@@ -173,13 +342,13 @@ export function isThreadDirEmpty(inboxRoot, threadId) {
|
|
|
173
342
|
*/
|
|
174
343
|
export function pruneEmptyInboxDirs(deps = defaultDeps()) {
|
|
175
344
|
for (const [, info] of deps.getTrackedBrains()) {
|
|
176
|
-
const inboxRoot = join(
|
|
345
|
+
const inboxRoot = join(info.directory.path, '.inbox');
|
|
177
346
|
if (!existsSync(inboxRoot))
|
|
178
347
|
continue;
|
|
179
348
|
for (const threadId of listThreadDirs(inboxRoot)) {
|
|
180
349
|
if (deps.isInboxLoopActive(threadId) || isPoolDeferred(threadId))
|
|
181
350
|
continue;
|
|
182
|
-
if (readInboxRuntime(
|
|
351
|
+
if (readInboxRuntime(info.directory.path, threadId)?.completed)
|
|
183
352
|
continue;
|
|
184
353
|
if (!isThreadDirEmpty(inboxRoot, threadId))
|
|
185
354
|
continue;
|
|
@@ -202,7 +371,7 @@ function scheduleCheck(deps) {
|
|
|
202
371
|
return;
|
|
203
372
|
debounceTimer = setTimeout(() => {
|
|
204
373
|
debounceTimer = null;
|
|
205
|
-
checkInboxForNewSessions(deps);
|
|
374
|
+
void checkInboxForNewSessions(deps).catch(() => logger.error('[InboxWatcher] Inbox scan failed; work remains saved'));
|
|
206
375
|
}, config.INBOX_WATCH_DEBOUNCE_MS);
|
|
207
376
|
}
|
|
208
377
|
/**
|
|
@@ -223,19 +392,20 @@ function scheduleCheck(deps) {
|
|
|
223
392
|
function syncBrainWatchers(deps) {
|
|
224
393
|
const seen = new Set();
|
|
225
394
|
for (const [, info] of deps.getTrackedBrains()) {
|
|
226
|
-
|
|
227
|
-
|
|
395
|
+
const key = info.directory.path;
|
|
396
|
+
seen.add(key);
|
|
397
|
+
if (brainWatchers.has(key))
|
|
228
398
|
continue;
|
|
229
|
-
const inboxRoot = join(
|
|
399
|
+
const inboxRoot = join(info.directory.path, '.inbox');
|
|
230
400
|
if (!existsSync(inboxRoot))
|
|
231
401
|
continue;
|
|
232
402
|
try {
|
|
233
403
|
const watcher = fsWatch(inboxRoot, { recursive: true }, () => scheduleCheck(deps));
|
|
234
404
|
watcher.on('error', (error) => {
|
|
235
405
|
logger.warn(`[InboxWatcher] Watch error for brain ${info.brainId}, dropping watcher: ${error.message}`);
|
|
236
|
-
brainWatchers.delete(
|
|
406
|
+
brainWatchers.delete(key);
|
|
237
407
|
});
|
|
238
|
-
brainWatchers.set(
|
|
408
|
+
brainWatchers.set(key, watcher);
|
|
239
409
|
logger.debug(`[InboxWatcher] Watching ${inboxRoot}`);
|
|
240
410
|
}
|
|
241
411
|
catch (error) {
|
|
@@ -265,7 +435,7 @@ export function startInboxWatcher(deps = defaultDeps()) {
|
|
|
265
435
|
logger.info(`[InboxWatcher] Started (fs.watch on ${brainWatchers.size} brain inbox dir(s), fallback sweep every ${fallbackMs}ms)`);
|
|
266
436
|
fallbackInterval = setInterval(() => {
|
|
267
437
|
syncBrainWatchers(deps);
|
|
268
|
-
checkInboxForNewSessions(deps);
|
|
438
|
+
void checkInboxForNewSessions(deps).catch(() => logger.error('[InboxWatcher] Inbox scan failed; work remains saved'));
|
|
269
439
|
pruneEmptyInboxDirs(deps);
|
|
270
440
|
}, fallbackMs);
|
|
271
441
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { type BrainDirectory } from './brain-directory.js';
|
|
1
2
|
import type { TeamVibeQueueMessage } from './types.js';
|
|
2
3
|
export interface TrackedBrainInfo {
|
|
4
|
+
directory: BrainDirectory;
|
|
3
5
|
brainId: string;
|
|
4
6
|
gitRepoUrl: string;
|
|
5
7
|
branch: string;
|
|
@@ -51,7 +53,7 @@ export interface BootstrapBrainInfo {
|
|
|
51
53
|
* opposite bug — a genuinely-quiet brain wrongly evaluated as "nothing new,
|
|
52
54
|
* skip" on its very first post-restart cron tick.
|
|
53
55
|
*
|
|
54
|
-
* Skips (rather than overwrites) any
|
|
56
|
+
* Skips (rather than overwrites) any workspace/channel already present in
|
|
55
57
|
* trackedBrains. This guard is load-bearing, not just defensive (DevGuru
|
|
56
58
|
* review, round 4): since round 3's critical-path detachment
|
|
57
59
|
* (brains-bootstrap.ts's `void (async () => {...})()` retry), the retry
|
|
@@ -91,7 +93,7 @@ export declare function seedTrackedBrains(brains: readonly BootstrapBrainInfo[])
|
|
|
91
93
|
* Register a brain from a real message so the maintenance cron knows about it.
|
|
92
94
|
* Called from processMessage() after brain path is resolved.
|
|
93
95
|
*/
|
|
94
|
-
export declare function registerBrain(msg: TeamVibeQueueMessage): void;
|
|
96
|
+
export declare function registerBrain(msg: TeamVibeQueueMessage, directory?: BrainDirectory): void;
|
|
95
97
|
/**
|
|
96
98
|
* Start the cron-based maintenance schedule (default: daily 03:30 in POLLER_TZ).
|
|
97
99
|
*/
|