@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.
Files changed (49) hide show
  1. package/README.md +13 -0
  2. package/dist/brain-directory.d.ts +14 -0
  3. package/dist/brain-directory.js +16 -0
  4. package/dist/brain-manager.d.ts +2 -2
  5. package/dist/brain-manager.js +32 -19
  6. package/dist/brain-path.d.ts +9 -0
  7. package/dist/brain-path.js +65 -0
  8. package/dist/claude-spawner.d.ts +3 -2
  9. package/dist/claude-spawner.js +195 -74
  10. package/dist/config.d.ts +4 -0
  11. package/dist/config.js +1 -0
  12. package/dist/event-queue.js +14 -1
  13. package/dist/inbox-manager.d.ts +11 -0
  14. package/dist/inbox-manager.js +101 -6
  15. package/dist/inbox-watcher.d.ts +9 -4
  16. package/dist/inbox-watcher.js +219 -49
  17. package/dist/maintenance-cron.d.ts +4 -2
  18. package/dist/maintenance-cron.js +56 -14
  19. package/dist/metrics/scheduler.d.ts +4 -1
  20. package/dist/metrics/scheduler.js +8 -6
  21. package/dist/poller.d.ts +2 -1
  22. package/dist/poller.js +50 -24
  23. package/dist/pool-logging.js +2 -2
  24. package/dist/pool-telemetry.d.ts +21 -5
  25. package/dist/pool-telemetry.js +28 -11
  26. package/dist/session-store.d.ts +1 -1
  27. package/dist/session-store.js +6 -2
  28. package/dist/startup-auth.js +4 -1
  29. package/dist/subscription-pool-client.js +7 -0
  30. package/dist/subscription-pool.d.ts +1 -0
  31. package/dist/subscription-pool.js +1 -1
  32. package/dist/subscription-runtime.d.ts +21 -3
  33. package/dist/subscription-runtime.js +17 -9
  34. package/dist/subscription-selection.d.ts +20 -3
  35. package/dist/subscription-selection.js +66 -4
  36. package/dist/subscription-usage-store.d.ts +2 -0
  37. package/dist/subscription-usage-store.js +41 -20
  38. package/dist/thread-routing.d.ts +23 -0
  39. package/dist/thread-routing.js +81 -0
  40. package/dist/types.d.ts +4 -10
  41. package/dist/usage-limit-messages.d.ts +1 -1
  42. package/dist/usage-limit-messages.js +1 -1
  43. package/dist/usage-probe-contract.d.ts +15 -0
  44. package/dist/usage-probe-contract.js +14 -0
  45. package/dist/usage-probe-job.js +20 -7
  46. package/dist/worktree-manager.d.ts +2 -1
  47. package/dist/worktree-manager.js +38 -10
  48. package/npm-shrinkwrap.json +2 -2
  49. package/package.json +1 -1
@@ -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
- export function readInboxContext(brainPath, threadId) {
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 first = readdirSync(join(root, subdir)).filter(name => name.endsWith('.txt')).sort()[0];
176
- if (first)
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
- for (const path of candidates) {
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
+ }
@@ -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, kbPath: string, queueMessage: TeamVibeQueueMessage, sessionLog: SessionLogger) => Promise<void>;
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 every tracked brain's .inbox/ for a thread that has queued or retained work but
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
@@ -1,24 +1,34 @@
1
- import { readInboxContext, readInboxRuntime } from './inbox-manager.js';
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
- const INBOX_SUBDIRS = ['new', 'processing', 'failed'];
12
- let brainWatchers = new Map();
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 every tracked brain's .inbox/ for a thread that has queued or retained work but
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 brains = new Map();
108
- for (const [, info] of deps.getTrackedBrains())
109
- brains.set(join(deps.brainsPath, info.brainId), info);
110
- // Durable inbox context discovers retained work even with empty bootstrap maps,
111
- // absent/corrupt pause markers, or a non-Slack thread ID.
112
- try {
113
- for (const entry of readdirSync(deps.brainsPath, { withFileTypes: true })) {
114
- if (entry.isDirectory()) {
115
- const path = join(deps.brainsPath, entry.name);
116
- if (existsSync(join(path, '.inbox')) && !brains.has(path))
117
- brains.set(path, undefined);
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
- catch { }
122
- for (const [brainPath, info] of brains) {
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
- return;
156
+ break;
125
157
  const inboxRoot = join(brainPath, '.inbox');
126
158
  for (const threadId of listThreadDirs(inboxRoot)) {
127
159
  if (deps.isAtCapacity())
128
- return;
129
- if (deps.isInboxLoopActive(threadId) || isPoolDeferred(threadId))
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
- const completion = readInboxRuntime(brainPath, threadId)?.completed;
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
- const message = durable ?? (info && parsed ? buildSyntheticMessage(info, threadId, parsed) : undefined);
140
- if (!message)
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
- const sessionLog = logger.createSession(`iw_${Math.random().toString(36).slice(2, 10)}`);
143
- deps.runInboxLoop(threadId, brainPath, message, sessionLog).catch(() => {
144
- logger.error('[InboxWatcher] Retained inbox run failed; work remains saved');
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(deps.brainsPath, info.brainId, '.inbox');
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(join(deps.brainsPath, info.brainId), threadId)?.completed)
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
- seen.add(info.brainId);
227
- if (brainWatchers.has(info.brainId))
395
+ const key = info.directory.path;
396
+ seen.add(key);
397
+ if (brainWatchers.has(key))
228
398
  continue;
229
- const inboxRoot = join(deps.brainsPath, info.brainId, '.inbox');
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(info.brainId);
406
+ brainWatchers.delete(key);
237
407
  });
238
- brainWatchers.set(info.brainId, watcher);
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 brainId already present in
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
  */