@teamvibe/poller 0.5.3 → 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.
@@ -1,6 +1,7 @@
1
1
  import type { TeamVibeQueueMessage, SessionMetrics } from './types.js';
2
2
  import { type SessionLogger } from './logger.js';
3
3
  import type { TypingIndicator } from './slack-client.js';
4
+ import { type ResolvedModelConfig } from './config-client.js';
4
5
  import type { KilledByPoller } from './classify-failed-iteration.js';
5
6
  export interface ContextHintParams {
6
7
  isFirstMessage: boolean;
@@ -21,7 +22,38 @@ export interface SpawnResult {
21
22
  error?: string;
22
23
  metrics?: SessionMetrics;
23
24
  killedByPoller?: KilledByPoller;
25
+ modelSource?: ModelSource;
24
26
  }
27
+ /**
28
+ * `settings.json` and `env-default` are poller-local, not DDB-resolved
29
+ * scopes — kept as a superset of `services/model-config.ts`'s
30
+ * `ModelConfigScope` rather than importing it, since the poller package
31
+ * doesn't depend on `modules-teamvibe`.
32
+ */
33
+ export type ModelSource = 'settings.json' | 'poller' | 'workspace' | 'channel' | 'env-default' | undefined;
34
+ /**
35
+ * 3-tier model resolution — teamvibe.ai#235 PR2.
36
+ *
37
+ * Precedence (highest to lowest): brain `settings.json` > DDB-resolved
38
+ * scoped config (poller/workspace/channel, fetched via
39
+ * `fetchConfigForSpawn`) > `CLAUDE_MODEL` env default.
40
+ *
41
+ * `settings.json` staying on top is a **known, documented, accepted
42
+ * blind spot** (see DevGuru's tv#235 approach-review addendum): a
43
+ * poller-level emergency model cap set via DDB can be silently
44
+ * overridden by any brain's local settings.json. Not fixed here — PR3
45
+ * adds a read-only "last observed override source" surfaced via the
46
+ * `session.completed` telemetry this function's return value feeds
47
+ * (see the `modelSource` field on `SpawnResult` / the `poller.ts`
48
+ * `emitEvent` call site).
49
+ *
50
+ * Pure function — no I/O — so it's unit-testable without mocking
51
+ * `fetch` or `child_process.spawn`.
52
+ */
53
+ export declare function resolveModel(brainSettingsModel: string | undefined, ddbConfig: Pick<ResolvedModelConfig, 'model' | 'modelSource'>, envDefaultModel: string | undefined): {
54
+ model: string | undefined;
55
+ modelSource: ModelSource;
56
+ };
25
57
  export declare function buildPrompt(msg: TeamVibeQueueMessage, contextHint?: string): string;
26
58
  export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void, typingIndicator?: TypingIndicator): Promise<SpawnResult & {
27
59
  newSessionId?: string;
@@ -9,6 +9,7 @@ import { getPollerId } from './auth-provider.js';
9
9
  import { emitError } from './event-queue.js';
10
10
  import { createWorktree, removeWorktree, isWorktreeIsolationEnabled } from './worktree-manager.js';
11
11
  import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js';
12
+ import { fetchConfigForSpawn } from './config-client.js';
12
13
  /**
13
14
  * Maps tool names (or prefixes) to human-readable status messages.
14
15
  * More specific patterns are checked first.
@@ -167,6 +168,37 @@ function readBrainSettings(cwd) {
167
168
  return {};
168
169
  }
169
170
  }
171
+ /**
172
+ * 3-tier model resolution — teamvibe.ai#235 PR2.
173
+ *
174
+ * Precedence (highest to lowest): brain `settings.json` > DDB-resolved
175
+ * scoped config (poller/workspace/channel, fetched via
176
+ * `fetchConfigForSpawn`) > `CLAUDE_MODEL` env default.
177
+ *
178
+ * `settings.json` staying on top is a **known, documented, accepted
179
+ * blind spot** (see DevGuru's tv#235 approach-review addendum): a
180
+ * poller-level emergency model cap set via DDB can be silently
181
+ * overridden by any brain's local settings.json. Not fixed here — PR3
182
+ * adds a read-only "last observed override source" surfaced via the
183
+ * `session.completed` telemetry this function's return value feeds
184
+ * (see the `modelSource` field on `SpawnResult` / the `poller.ts`
185
+ * `emitEvent` call site).
186
+ *
187
+ * Pure function — no I/O — so it's unit-testable without mocking
188
+ * `fetch` or `child_process.spawn`.
189
+ */
190
+ export function resolveModel(brainSettingsModel, ddbConfig, envDefaultModel) {
191
+ if (brainSettingsModel) {
192
+ return { model: brainSettingsModel, modelSource: 'settings.json' };
193
+ }
194
+ if (ddbConfig.model) {
195
+ return { model: ddbConfig.model, modelSource: ddbConfig.modelSource };
196
+ }
197
+ if (envDefaultModel) {
198
+ return { model: envDefaultModel, modelSource: 'env-default' };
199
+ }
200
+ return { model: undefined, modelSource: undefined };
201
+ }
170
202
  // Tags procs at SIGTERM time so the close handler can report poller intent.
171
203
  // WeakMap auto-cleans when proc is GC'd. Module-scope is required because
172
204
  // killZombieForThread fires outside the runClaudeCode closure.
@@ -191,6 +223,12 @@ export function buildPrompt(msg, contextHint) {
191
223
  ? 'Direct Message'
192
224
  : `Channel ${slack.channel}`;
193
225
  prompt += `**Channel:** ${channelType}\n`;
226
+ // The agent's reply defaults to this thread. Stating it here is what stops
227
+ // the agent from reconstructing "the thread we were in" from memory and
228
+ // replying into a stale one — see teamvibeai/teamvibe.ai#184.
229
+ if (slack.thread_ts) {
230
+ prompt += `**Thread TS:** \`${slack.thread_ts}\` (your reply defaults here)\n`;
231
+ }
194
232
  }
195
233
  // Inject human-readable timestamp from Slack message ts
196
234
  if (msg.response_context.slack?.message_ts) {
@@ -333,6 +371,14 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
333
371
  channelId: msg.teamvibe.channelId,
334
372
  });
335
373
  const secretsReminder = buildSecretsReminder(spawnSecrets.reminders);
374
+ // Fetch scoped model config for this spawn — same hoisting rationale as
375
+ // secrets above. Fail-soft: returns {} on any error, which resolveModel
376
+ // treats as "nothing at this tier" and falls through to the env default.
377
+ const ddbModelConfig = await fetchConfigForSpawn({
378
+ pollerId: getPollerId() || '',
379
+ workspaceId: msg.teamvibe.workspaceId,
380
+ channelId: msg.teamvibe.channelId,
381
+ });
336
382
  return new Promise((resolve) => {
337
383
  const prompt = buildPrompt(msg, contextHint);
338
384
  // MCP server config passed via --mcp-config flag (no temp files needed)
@@ -358,11 +404,15 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
358
404
  '--disallowedTools',
359
405
  'NotebookEdit',
360
406
  ];
361
- // Model override: brain settings.json takes priority, then CLAUDE_MODEL env var
362
- const model = (typeof brainSettings['model'] === 'string' && brainSettings['model']) || config.CLAUDE_MODEL;
407
+ // Model override — 3-tier precedence (teamvibe.ai#235 PR2): brain
408
+ // settings.json > DDB-resolved scoped config (poller/workspace/channel)
409
+ // > CLAUDE_MODEL env default. See resolveModel's docstring for why
410
+ // settings.json stays on top (known, accepted blind spot).
411
+ const brainSettingsModel = typeof brainSettings['model'] === 'string' ? brainSettings['model'] : undefined;
412
+ const { model, modelSource } = resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
363
413
  if (model) {
364
414
  args.push('--model', model);
365
- sessionLog.info(`Using model: ${model}`);
415
+ sessionLog.info(`Using model: ${model} (source: ${modelSource ?? 'unknown'})`);
366
416
  }
367
417
  // Inbox protocol: instruct Claude to check for mid-task messages (thread-scoped)
368
418
  const inboxNewDir = `.inbox/${msg.thread_id}/new`;
@@ -581,6 +631,9 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
581
631
  context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
582
632
  channelId: msg.teamvibe.channelId,
583
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...');
584
637
  idleWarned = true;
585
638
  }
586
639
  }, 30000); // Check every 30 seconds
@@ -595,6 +648,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
595
648
  output: stdout,
596
649
  exitCode: code,
597
650
  metrics,
651
+ modelSource,
598
652
  ...(killReason ? { killedByPoller: killReason } : {}),
599
653
  };
600
654
  if (killReason === 'timeout') {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Poller-side non-secret scoped config client — teamvibe.ai#235 (PR2).
3
+ *
4
+ * Fetches the resolved scoped config from the Poller API
5
+ * (GET /config/spawn?pollerId=&workspaceId=&channelId=) before spawn.
6
+ * Mirrors `secrets-client.ts`'s defensive-failure pattern: on any failure
7
+ * mode (missing config, network error, non-2xx, malformed JSON) this
8
+ * returns an empty result rather than throwing — spawn must never fail
9
+ * because this fetch failed, it just falls through to the next
10
+ * precedence tier (settings.json / CLAUDE_MODEL env default) in
11
+ * `claude-spawner.ts`.
12
+ *
13
+ * Unlike secrets, this config is NOT sensitive — no `Redacted`/opaque
14
+ * envelope wrapper needed, the resolved value is returned as a plain
15
+ * object.
16
+ *
17
+ * Wire shape is a generic `env`/`sources` envelope (see
18
+ * `poller-config.ts`'s `buildConfigResponse` on the server side) so
19
+ * future non-secret settings land as additional `env` keys without a new
20
+ * endpoint. This client unwraps that envelope into the typed
21
+ * `ResolvedModelConfig` shape `claude-spawner.ts` already consumes —
22
+ * today only the `MODEL`/`REASONING_EFFORT` keys exist, so the unwrap is
23
+ * a fixed 1:1 mapping, not a generic loop; widen it if/when a third key
24
+ * appears.
25
+ */
26
+ export type ModelConfigScope = 'poller' | 'workspace' | 'channel';
27
+ export interface ResolvedModelConfig {
28
+ model?: string;
29
+ reasoningEffort?: string;
30
+ modelSource?: ModelConfigScope;
31
+ reasoningEffortSource?: ModelConfigScope;
32
+ }
33
+ export interface FetchModelConfigInput {
34
+ pollerId: string;
35
+ workspaceId: string;
36
+ channelId: string;
37
+ }
38
+ /**
39
+ * Fetch the resolved config for a spawn. Returns `{}` on any failure
40
+ * mode — spawn continues, falling through to the env-default model tier.
41
+ * Errors are logged at warn level so monitoring can alarm; mirrors
42
+ * `fetchSecretsForSpawn`'s fail-soft contract exactly.
43
+ */
44
+ export declare function fetchConfigForSpawn(input: FetchModelConfigInput): Promise<ResolvedModelConfig>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Poller-side non-secret scoped config client — teamvibe.ai#235 (PR2).
3
+ *
4
+ * Fetches the resolved scoped config from the Poller API
5
+ * (GET /config/spawn?pollerId=&workspaceId=&channelId=) before spawn.
6
+ * Mirrors `secrets-client.ts`'s defensive-failure pattern: on any failure
7
+ * mode (missing config, network error, non-2xx, malformed JSON) this
8
+ * returns an empty result rather than throwing — spawn must never fail
9
+ * because this fetch failed, it just falls through to the next
10
+ * precedence tier (settings.json / CLAUDE_MODEL env default) in
11
+ * `claude-spawner.ts`.
12
+ *
13
+ * Unlike secrets, this config is NOT sensitive — no `Redacted`/opaque
14
+ * envelope wrapper needed, the resolved value is returned as a plain
15
+ * object.
16
+ *
17
+ * Wire shape is a generic `env`/`sources` envelope (see
18
+ * `poller-config.ts`'s `buildConfigResponse` on the server side) so
19
+ * future non-secret settings land as additional `env` keys without a new
20
+ * endpoint. This client unwraps that envelope into the typed
21
+ * `ResolvedModelConfig` shape `claude-spawner.ts` already consumes —
22
+ * today only the `MODEL`/`REASONING_EFFORT` keys exist, so the unwrap is
23
+ * a fixed 1:1 mapping, not a generic loop; widen it if/when a third key
24
+ * appears.
25
+ */
26
+ import { logger } from './logger.js';
27
+ import { config } from './config.js';
28
+ const SCOPES = new Set(['poller', 'workspace', 'channel']);
29
+ function isModelConfigScope(v) {
30
+ return typeof v === 'string' && SCOPES.has(v);
31
+ }
32
+ /**
33
+ * Fetch the resolved config for a spawn. Returns `{}` on any failure
34
+ * mode — spawn continues, falling through to the env-default model tier.
35
+ * Errors are logged at warn level so monitoring can alarm; mirrors
36
+ * `fetchSecretsForSpawn`'s fail-soft contract exactly.
37
+ */
38
+ export async function fetchConfigForSpawn(input) {
39
+ if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
40
+ logger.warn('config-client: TEAMVIBE_API_URL or TEAMVIBE_POLLER_TOKEN not set; skipping fetch');
41
+ return {};
42
+ }
43
+ // Upfront short-circuit: an empty pollerId would fail server-side
44
+ // validation and round-trip an HTTP error. Skip the call entirely so
45
+ // the warn log makes the cause obvious from poller stdout.
46
+ if (!input.pollerId) {
47
+ logger.warn('config-client: pollerId empty; skipping fetch (no scoped config for spawn)');
48
+ return {};
49
+ }
50
+ let url;
51
+ try {
52
+ url = new URL('/config/spawn', config.TEAMVIBE_API_URL);
53
+ url.searchParams.set('pollerId', input.pollerId);
54
+ url.searchParams.set('workspaceId', input.workspaceId);
55
+ url.searchParams.set('channelId', input.channelId);
56
+ }
57
+ catch (err) {
58
+ logger.warn(`config-client.config_error: invalid TEAMVIBE_API_URL=${String(config.TEAMVIBE_API_URL)} ` +
59
+ `error=${err?.message ?? String(err)}`);
60
+ return {};
61
+ }
62
+ let response;
63
+ try {
64
+ response = await fetch(url.toString(), {
65
+ method: 'GET',
66
+ headers: {
67
+ Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
68
+ },
69
+ });
70
+ }
71
+ catch (err) {
72
+ logger.warn(`config-client.fetch_failed: network error pollerId=${input.pollerId} ` +
73
+ `workspaceId=${input.workspaceId} channelId=${input.channelId} ` +
74
+ `error=${err?.message ?? String(err)}`);
75
+ return {};
76
+ }
77
+ if (!response.ok) {
78
+ let snippet = '';
79
+ try {
80
+ snippet = (await response.text()).slice(0, 200);
81
+ }
82
+ catch {
83
+ snippet = '<failed-to-read-body>';
84
+ }
85
+ logger.warn(`config-client.fetch_failed: status=${response.status} ` +
86
+ `pollerId=${input.pollerId} workspaceId=${input.workspaceId} ` +
87
+ `channelId=${input.channelId} bodySnippet=${snippet}`);
88
+ return {};
89
+ }
90
+ let parsed;
91
+ try {
92
+ parsed = (await response.json());
93
+ }
94
+ catch (err) {
95
+ logger.warn(`config-client.parse_failed: pollerId=${input.pollerId} ` +
96
+ `error=${err?.message ?? String(err)}`);
97
+ return {};
98
+ }
99
+ const env = parsed.env ?? {};
100
+ const sources = parsed.sources ?? {};
101
+ const result = {};
102
+ if (typeof env.MODEL === 'string' && env.MODEL)
103
+ result.model = env.MODEL;
104
+ if (typeof env.REASONING_EFFORT === 'string' && env.REASONING_EFFORT) {
105
+ result.reasoningEffort = env.REASONING_EFFORT;
106
+ }
107
+ if (isModelConfigScope(sources.MODEL))
108
+ result.modelSource = sources.MODEL;
109
+ if (isModelConfigScope(sources.REASONING_EFFORT))
110
+ result.reasoningEffortSource = sources.REASONING_EFFORT;
111
+ logger.info(`config-client: resolved model=${result.model ?? '(none)'} source=${result.modelSource ?? '(none)'}`);
112
+ return result;
113
+ }
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.
@@ -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
- for (const file of jsonFiles) {
38
- const filePath = join(reportsDir, file);
39
- try {
40
- const content = await readFile(filePath, 'utf-8');
41
- const report = JSON.parse(content);
42
- if (!report.operationType) {
43
- logger.warn(`[MaintenanceReporter] Removing invalid report ${file}: missing operationType`);
44
- emitError('maintenance', `Removing invalid report ${file}: missing operationType`, { severity: 'warn' });
45
- await unlink(filePath).catch(() => { });
46
- removedFiles.push(`reports/${file}`);
47
- continue;
48
- }
49
- const eventType = OPERATION_TO_EVENT_TYPE[report.operationType];
50
- if (!eventType) {
51
- logger.warn(`[MaintenanceReporter] Removing report ${file}: unknown operationType "${report.operationType}"`);
52
- emitError('maintenance', `Removing report ${file}: unknown operationType "${report.operationType}"`, { severity: 'warn' });
53
- await unlink(filePath).catch(() => { });
54
- removedFiles.push(`reports/${file}`);
55
- continue;
56
- }
57
- const payload = {
58
- eventType,
59
- channelId,
60
- pollerVersion: POLLER_VERSION,
61
- baseBrainCommitSha,
62
- baseBrainAgeHours,
63
- metadata: report,
64
- };
65
- const response = await fetch(`${config.TEAMVIBE_API_URL}/events`, {
66
- method: 'POST',
67
- headers: {
68
- 'Content-Type': 'application/json',
69
- Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
70
- },
71
- body: JSON.stringify(payload),
72
- });
73
- if (response.ok) {
74
- logger.info(`[MaintenanceReporter] Posted ${file} as ${eventType}`);
75
- await unlink(filePath).catch(() => { });
76
- postedFiles.push(`reports/${file}`);
77
- // Touch the consolidation guard file so the poller-local
78
- // `daysSinceMaintenance` metric reflects this successful run.
79
- // The metric reads `stat(memory/.last_consolidation).mtimeMs`, so any
80
- // skip of the consolidation skill's "update timestamp" step causes
81
- // silent drift. See poller-brain#109, poller-brain-eval#150.
82
- if (report.operationType === 'consolidation') {
83
- const guardPath = join(brainPath, 'memory', '.last_consolidation');
84
- const today = new Date().toISOString().slice(0, 10);
85
- try {
86
- await writeFile(guardPath, `${today}\n`);
87
- consolidationGuardTouched = true;
88
- logger.info(`[MaintenanceReporter] Touched ${guardPath}`);
89
- }
90
- catch (err) {
91
- logger.warn(`[MaintenanceReporter] Failed to touch ${guardPath}: ${err instanceof Error ? err.message : err}`);
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
- else {
96
- const errorBody = await response.text().catch(() => 'unknown');
97
- logger.warn(`[MaintenanceReporter] Failed to POST ${file}: ${response.status} ${errorBody}`);
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 don't resurrect on next pull,
107
- // and stage the consolidation guard file if it was touched this run.
108
- const allRemovedFromGit = [...postedFiles, ...removedFiles];
109
- if (allRemovedFromGit.length > 0 || consolidationGuardTouched) {
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 (allRemovedFromGit.length > 0) {
112
- await execFileAsync('git', ['rm', '--cached', '--ignore-unmatch', ...allRemovedFromGit], { cwd: brainPath });
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'], { cwd: brainPath });
211
+ await execFileAsync('git', ['add', 'memory/.last_consolidation'], {
212
+ cwd: brainPath,
213
+ });
116
214
  }
117
- const message = consolidationGuardTouched && allRemovedFromGit.length > 0
118
- ? 'chore: process maintenance reports and refresh consolidation guard'
119
- : consolidationGuardTouched
120
- ? 'chore: refresh consolidation guard'
121
- : 'chore: remove processed maintenance reports';
122
- await execFileAsync('git', ['commit', '-m', message], { cwd: brainPath });
123
- logger.info(`[MaintenanceReporter] Committed ${allRemovedFromGit.length} report removal(s)${consolidationGuardTouched ? ' + guard refresh' : ''}`);
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
- async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
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;
@@ -210,6 +220,11 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
210
220
  duration_api_ms: result.metrics.duration_api_ms,
211
221
  num_turns: result.metrics.num_turns,
212
222
  model: result.metrics.model,
223
+ // Which config layer actually chose the `--model` flag
224
+ // (settings.json / poller / workspace / channel / env-default)
225
+ // — teamvibe.ai#235 PR2. Distinct from `model` above, which is
226
+ // Claude CLI's own report of the model it used.
227
+ modelSource: result.modelSource,
213
228
  });
214
229
  }
215
230
  if (queueMessage.teamvibe.brain?.brainId) {
@@ -509,6 +524,7 @@ async function shutdown(signal, exitCode = 0) {
509
524
  stopMaintenanceLoop();
510
525
  stopAutoUpdateCron();
511
526
  stopMetricsCron();
527
+ stopInboxWatcher();
512
528
  stopEventQueue();
513
529
  // Kill all active Claude child processes
514
530
  await killAllActiveProcesses();
@@ -587,5 +603,6 @@ export async function startPoller() {
587
603
  startMaintenanceLoop(processMessage);
588
604
  startAutoUpdateCron();
589
605
  startMetricsCron();
606
+ startInboxWatcher();
590
607
  await pollLoop();
591
608
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.5.3",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {