@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.
- package/dist/claude-spawner.d.ts +32 -0
- package/dist/claude-spawner.js +57 -3
- package/dist/config-client.d.ts +44 -0
- package/dist/config-client.js +113 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +8 -0
- package/dist/inbox-watcher.d.ts +67 -0
- package/dist/inbox-watcher.js +262 -0
- package/dist/maintenance-cron.d.ts +15 -0
- package/dist/maintenance-cron.js +8 -0
- package/dist/maintenance-reporter.js +193 -88
- package/dist/poller.d.ts +10 -0
- package/dist/poller.js +18 -1
- package/package.json +1 -1
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -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;
|
package/dist/claude-spawner.js
CHANGED
|
@@ -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
|
|
362
|
-
|
|
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.
|
package/dist/maintenance-cron.js
CHANGED
|
@@ -9,6 +9,14 @@ import { sendMetrics } from './metrics/sender.js';
|
|
|
9
9
|
import { markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
const trackedBrains = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Read-only view of brains seen so far (via registerBrain). Used by the inbox
|
|
14
|
+
* watcher (teamvibe.ai#242) to know which brain paths to scan and which
|
|
15
|
+
* workspace/bot credentials to use when starting a session for an idle thread.
|
|
16
|
+
*/
|
|
17
|
+
export function getTrackedBrains() {
|
|
18
|
+
return trackedBrains;
|
|
19
|
+
}
|
|
12
20
|
let maintenanceJob = null;
|
|
13
21
|
/**
|
|
14
22
|
* Register a brain from a real message so the maintenance cron knows about it.
|
|
@@ -1,16 +1,72 @@
|
|
|
1
|
-
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
|
|
1
|
+
import { readdir, readFile, unlink, writeFile, mkdir, rename, } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { execFile } from 'child_process';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
import { config, POLLER_VERSION } from './config.js';
|
|
6
6
|
import { logger } from './logger.js';
|
|
7
7
|
import { emitError } from './event-queue.js';
|
|
8
|
-
import { getBaseBrainCommitSha, getBaseBrainAgeHours } from './base-brain-metadata.js';
|
|
8
|
+
import { getBaseBrainCommitSha, getBaseBrainAgeHours, } from './base-brain-metadata.js';
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const OPERATION_TO_EVENT_TYPE = {
|
|
11
11
|
consolidation: 'maintenance.consolidation',
|
|
12
12
|
reflection: 'maintenance.reflection',
|
|
13
13
|
};
|
|
14
|
+
// teamvibe.ai#222 — malformed reports used to error forever because nothing
|
|
15
|
+
// ever removed them from the brain repo. They're quarantined instead of
|
|
16
|
+
// deleted outright so the raw payload survives for forensic inspection
|
|
17
|
+
// (why did the generator emit invalid JSON?), then purged after a retention
|
|
18
|
+
// window so the quarantine folder doesn't grow unbounded.
|
|
19
|
+
const QUARANTINE_DIR_NAME = '.quarantine';
|
|
20
|
+
const QUARANTINE_RETENTION_DAYS = 30;
|
|
21
|
+
const QUARANTINE_NAME_RE = /^(\d{4}-\d{2}-\d{2})__(.+)$/;
|
|
22
|
+
function todayIso() {
|
|
23
|
+
return new Date().toISOString().slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
// Date is parsed from the quarantine filename prefix, never from fs mtime —
|
|
26
|
+
// brains are re-checked-out from git per session, so mtime reflects the last
|
|
27
|
+
// checkout, not when the file was actually quarantined.
|
|
28
|
+
function daysSince(isoDate) {
|
|
29
|
+
const from = new Date(`${isoDate}T00:00:00Z`).getTime();
|
|
30
|
+
const now = new Date(`${todayIso()}T00:00:00Z`).getTime();
|
|
31
|
+
return Math.floor((now - from) / (24 * 60 * 60 * 1000));
|
|
32
|
+
}
|
|
33
|
+
async function quarantineFile(brainPath, file) {
|
|
34
|
+
const quarantineDir = join(brainPath, 'reports', QUARANTINE_DIR_NAME);
|
|
35
|
+
await mkdir(quarantineDir, { recursive: true });
|
|
36
|
+
const quarantineName = `${todayIso()}__${file}`;
|
|
37
|
+
await rename(join(brainPath, 'reports', file), join(quarantineDir, quarantineName));
|
|
38
|
+
return quarantineName;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Sweep reports/.quarantine/ for entries past the retention window and
|
|
42
|
+
* remove them from disk. Runs unconditionally (independent of whether
|
|
43
|
+
* there are any new reports/*.json to process this session) so cleanup
|
|
44
|
+
* doesn't stall on brains with a low reporting cadence.
|
|
45
|
+
*/
|
|
46
|
+
async function sweepExpiredQuarantine(brainPath) {
|
|
47
|
+
const quarantineDir = join(brainPath, 'reports', QUARANTINE_DIR_NAME);
|
|
48
|
+
const entries = await readdir(quarantineDir).catch(() => []);
|
|
49
|
+
const expired = [];
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const match = entry.match(QUARANTINE_NAME_RE);
|
|
52
|
+
const quarantinedOn = match?.[1];
|
|
53
|
+
if (!quarantinedOn)
|
|
54
|
+
continue; // unexpected name — leave alone rather than guess an age
|
|
55
|
+
if (daysSince(quarantinedOn) >= QUARANTINE_RETENTION_DAYS) {
|
|
56
|
+
try {
|
|
57
|
+
await unlink(join(quarantineDir, entry));
|
|
58
|
+
expired.push(`reports/${QUARANTINE_DIR_NAME}/${entry}`);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Don't report this entry as expired if the unlink didn't actually
|
|
62
|
+
// happen — otherwise git rm --cached untracks it while it's still
|
|
63
|
+
// sitting on disk, and future sweeps can't see it's already "gone".
|
|
64
|
+
logger.warn(`[MaintenanceReporter] Failed to purge expired quarantine entry ${entry}: ${err instanceof Error ? err.message : err}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return expired;
|
|
69
|
+
}
|
|
14
70
|
/**
|
|
15
71
|
* Scan for JSON maintenance reports in the brain's reports/ directory
|
|
16
72
|
* and POST each to the /events endpoint. Fire-and-forget — never throws.
|
|
@@ -21,109 +77,158 @@ export async function postMaintenanceReports(brainPath, channelId) {
|
|
|
21
77
|
}
|
|
22
78
|
const reportsDir = join(brainPath, 'reports');
|
|
23
79
|
try {
|
|
80
|
+
const expiredQuarantineFiles = await sweepExpiredQuarantine(brainPath);
|
|
24
81
|
const files = await readdir(reportsDir).catch(() => []);
|
|
25
82
|
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
|
26
|
-
if (jsonFiles.length === 0)
|
|
27
|
-
return;
|
|
28
|
-
logger.info(`[MaintenanceReporter] Found ${jsonFiles.length} JSON report(s) in ${reportsDir}`);
|
|
29
|
-
// Fleet visibility fields — sampled once per run. See teamvibe.ai#129.
|
|
30
|
-
const [baseBrainCommitSha, baseBrainAgeHours] = await Promise.all([
|
|
31
|
-
getBaseBrainCommitSha(config.BASE_BRAIN_PATH),
|
|
32
|
-
getBaseBrainAgeHours(config.BASE_BRAIN_PATH),
|
|
33
|
-
]);
|
|
34
83
|
const postedFiles = [];
|
|
35
84
|
const removedFiles = [];
|
|
85
|
+
const quarantinedFrom = [];
|
|
86
|
+
const quarantinedTo = [];
|
|
36
87
|
let consolidationGuardTouched = false;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
if (jsonFiles.length > 0) {
|
|
89
|
+
logger.info(`[MaintenanceReporter] Found ${jsonFiles.length} JSON report(s) in ${reportsDir}`);
|
|
90
|
+
// Fleet visibility fields — sampled once per run. See teamvibe.ai#129.
|
|
91
|
+
const [baseBrainCommitSha, baseBrainAgeHours] = await Promise.all([
|
|
92
|
+
getBaseBrainCommitSha(config.BASE_BRAIN_PATH),
|
|
93
|
+
getBaseBrainAgeHours(config.BASE_BRAIN_PATH),
|
|
94
|
+
]);
|
|
95
|
+
for (const file of jsonFiles) {
|
|
96
|
+
const filePath = join(reportsDir, file);
|
|
97
|
+
try {
|
|
98
|
+
const content = await readFile(filePath, 'utf-8');
|
|
99
|
+
let report;
|
|
100
|
+
try {
|
|
101
|
+
report = JSON.parse(content);
|
|
102
|
+
}
|
|
103
|
+
catch (parseErr) {
|
|
104
|
+
// Unlike network/IO errors below, a JSON.parse failure is never
|
|
105
|
+
// going to recover on retry — the payload itself is broken.
|
|
106
|
+
// Quarantine it (not delete) so the content survives for later
|
|
107
|
+
// inspection into why the generator emitted invalid JSON.
|
|
108
|
+
const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
109
|
+
logger.warn(`[MaintenanceReporter] Quarantining malformed report ${file}: ${message}`);
|
|
110
|
+
emitError('maintenance', `Quarantining malformed report ${file}: ${message}`, { severity: 'warn', channelId });
|
|
111
|
+
const quarantineName = await quarantineFile(brainPath, file);
|
|
112
|
+
quarantinedFrom.push(`reports/${file}`);
|
|
113
|
+
quarantinedTo.push(`reports/${QUARANTINE_DIR_NAME}/${quarantineName}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!report.operationType) {
|
|
117
|
+
logger.warn(`[MaintenanceReporter] Removing invalid report ${file}: missing operationType`);
|
|
118
|
+
emitError('maintenance', `Removing invalid report ${file}: missing operationType`, { severity: 'warn' });
|
|
119
|
+
await unlink(filePath).catch(() => { });
|
|
120
|
+
removedFiles.push(`reports/${file}`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const eventType = OPERATION_TO_EVENT_TYPE[report.operationType];
|
|
124
|
+
if (!eventType) {
|
|
125
|
+
logger.warn(`[MaintenanceReporter] Removing report ${file}: unknown operationType "${report.operationType}"`);
|
|
126
|
+
emitError('maintenance', `Removing report ${file}: unknown operationType "${report.operationType}"`, { severity: 'warn' });
|
|
127
|
+
await unlink(filePath).catch(() => { });
|
|
128
|
+
removedFiles.push(`reports/${file}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const payload = {
|
|
132
|
+
eventType,
|
|
133
|
+
channelId,
|
|
134
|
+
pollerVersion: POLLER_VERSION,
|
|
135
|
+
baseBrainCommitSha,
|
|
136
|
+
baseBrainAgeHours,
|
|
137
|
+
metadata: report,
|
|
138
|
+
};
|
|
139
|
+
const response = await fetch(`${config.TEAMVIBE_API_URL}/events`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify(payload),
|
|
146
|
+
});
|
|
147
|
+
if (response.ok) {
|
|
148
|
+
logger.info(`[MaintenanceReporter] Posted ${file} as ${eventType}`);
|
|
149
|
+
await unlink(filePath).catch(() => { });
|
|
150
|
+
postedFiles.push(`reports/${file}`);
|
|
151
|
+
// Touch the consolidation guard file so the poller-local
|
|
152
|
+
// `daysSinceMaintenance` metric reflects this successful run.
|
|
153
|
+
// The metric reads `stat(memory/.last_consolidation).mtimeMs`, so any
|
|
154
|
+
// skip of the consolidation skill's "update timestamp" step causes
|
|
155
|
+
// silent drift. See poller-brain#109, poller-brain-eval#150.
|
|
156
|
+
if (report.operationType === 'consolidation') {
|
|
157
|
+
const guardPath = join(brainPath, 'memory', '.last_consolidation');
|
|
158
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
159
|
+
try {
|
|
160
|
+
await writeFile(guardPath, `${today}\n`);
|
|
161
|
+
consolidationGuardTouched = true;
|
|
162
|
+
logger.info(`[MaintenanceReporter] Touched ${guardPath}`);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
logger.warn(`[MaintenanceReporter] Failed to touch ${guardPath}: ${err instanceof Error ? err.message : err}`);
|
|
166
|
+
}
|
|
92
167
|
}
|
|
93
168
|
}
|
|
169
|
+
else {
|
|
170
|
+
const errorBody = await response.text().catch(() => 'unknown');
|
|
171
|
+
logger.warn(`[MaintenanceReporter] Failed to POST ${file}: ${response.status} ${errorBody}`);
|
|
172
|
+
emitError('maintenance', `Failed to POST report ${file}: ${response.status}`, { severity: 'warn', channelId });
|
|
173
|
+
}
|
|
94
174
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
emitError('maintenance', `Failed to POST report ${file}: ${response.status}`, { severity: 'warn', channelId });
|
|
175
|
+
catch (err) {
|
|
176
|
+
logger.warn(`[MaintenanceReporter] Error processing ${file}: ${err instanceof Error ? err.message : err}`);
|
|
177
|
+
emitError('maintenance', `Error processing report ${file}: ${err instanceof Error ? err.message : String(err)}`, { severity: 'warn', channelId });
|
|
99
178
|
}
|
|
100
179
|
}
|
|
101
|
-
catch (err) {
|
|
102
|
-
logger.warn(`[MaintenanceReporter] Error processing ${file}: ${err instanceof Error ? err.message : err}`);
|
|
103
|
-
emitError('maintenance', `Error processing report ${file}: ${err instanceof Error ? err.message : String(err)}`, { severity: 'warn', channelId });
|
|
104
|
-
}
|
|
105
180
|
}
|
|
106
|
-
// Remove posted/invalid JSON reports from git so they
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
181
|
+
// Remove posted/invalid/expired-quarantine JSON reports from git so they
|
|
182
|
+
// don't resurrect on next pull, stage newly quarantined reports at their
|
|
183
|
+
// new path, and stage the consolidation guard file if it was touched.
|
|
184
|
+
// Runs regardless of jsonFiles.length so the quarantine-expiry sweep
|
|
185
|
+
// still gets committed on sessions with no new reports to process.
|
|
186
|
+
const gitRemovals = [
|
|
187
|
+
...postedFiles,
|
|
188
|
+
...removedFiles,
|
|
189
|
+
...quarantinedFrom,
|
|
190
|
+
...expiredQuarantineFiles,
|
|
191
|
+
];
|
|
192
|
+
const gitAdditions = [...quarantinedTo];
|
|
193
|
+
if (gitRemovals.length > 0 ||
|
|
194
|
+
gitAdditions.length > 0 ||
|
|
195
|
+
consolidationGuardTouched) {
|
|
110
196
|
try {
|
|
111
|
-
if (
|
|
112
|
-
await execFileAsync('git', ['rm', '--cached', '--ignore-unmatch', ...
|
|
197
|
+
if (gitRemovals.length > 0) {
|
|
198
|
+
await execFileAsync('git', ['rm', '--cached', '--ignore-unmatch', ...gitRemovals], { cwd: brainPath });
|
|
199
|
+
}
|
|
200
|
+
if (gitAdditions.length > 0) {
|
|
201
|
+
// -f: quarantine payloads must survive regardless of any brain
|
|
202
|
+
// .gitignore rule matching dotdirs (e.g. a blanket `.*`) — a
|
|
203
|
+
// plain `git add` silently skips ignored paths, which would
|
|
204
|
+
// defeat the whole point of quarantining (forensic survival)
|
|
205
|
+
// the moment the next checkout drops the untracked file.
|
|
206
|
+
await execFileAsync('git', ['add', '-f', ...gitAdditions], {
|
|
207
|
+
cwd: brainPath,
|
|
208
|
+
});
|
|
113
209
|
}
|
|
114
210
|
if (consolidationGuardTouched) {
|
|
115
|
-
await execFileAsync('git', ['add', 'memory/.last_consolidation'], {
|
|
211
|
+
await execFileAsync('git', ['add', 'memory/.last_consolidation'], {
|
|
212
|
+
cwd: brainPath,
|
|
213
|
+
});
|
|
116
214
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
215
|
+
const messageParts = [];
|
|
216
|
+
if (postedFiles.length > 0 || removedFiles.length > 0)
|
|
217
|
+
messageParts.push('process maintenance reports');
|
|
218
|
+
if (quarantinedFrom.length > 0)
|
|
219
|
+
messageParts.push('quarantine malformed reports');
|
|
220
|
+
if (expiredQuarantineFiles.length > 0)
|
|
221
|
+
messageParts.push('purge expired quarantine');
|
|
222
|
+
if (consolidationGuardTouched)
|
|
223
|
+
messageParts.push('refresh consolidation guard');
|
|
224
|
+
const message = `chore: ${messageParts.join(' + ')}`;
|
|
225
|
+
await execFileAsync('git', ['commit', '-m', message], {
|
|
226
|
+
cwd: brainPath,
|
|
227
|
+
});
|
|
228
|
+
logger.info(`[MaintenanceReporter] Committed ${gitRemovals.length} removal(s), ${gitAdditions.length} quarantine addition(s)${consolidationGuardTouched ? ' + guard refresh' : ''}`);
|
|
124
229
|
}
|
|
125
230
|
catch (err) {
|
|
126
|
-
logger.debug(`[MaintenanceReporter] Could not git-rm reports (may not be a git repo): ${err instanceof Error ? err.message : err}`);
|
|
231
|
+
logger.debug(`[MaintenanceReporter] Could not git-rm/add reports (may not be a git repo): ${err instanceof Error ? err.message : err}`);
|
|
127
232
|
}
|
|
128
233
|
}
|
|
129
234
|
}
|
package/dist/poller.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { logger } from './logger.js';
|
|
2
|
+
import type { TeamVibeQueueMessage } from './types.js';
|
|
1
3
|
interface ReleaseLogger {
|
|
2
4
|
info: (msg: string) => void;
|
|
3
5
|
warn: (msg: string) => void;
|
|
@@ -13,5 +15,13 @@ export declare function releaseAllActiveLocks(locks: Map<string, string>, releas
|
|
|
13
15
|
* in morning-check telemetry. Same DI shape as runMaintenanceJob.
|
|
14
16
|
*/
|
|
15
17
|
export declare function runAutoUpdateJob(checkForUpdateFn?: () => Promise<boolean>): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Whether a runInboxLoop is currently active for this thread. Used by the
|
|
20
|
+
* inbox watcher (teamvibe.ai#242) to avoid starting a second loop for a
|
|
21
|
+
* thread that's already being served — its existing pickNextMessage() call
|
|
22
|
+
* will pick up any new file on its own.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isInboxLoopActive(threadId: string): boolean;
|
|
25
|
+
export declare function runInboxLoop(threadId: string, kbPath: string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession>): Promise<void>;
|
|
16
26
|
export declare function startPoller(): Promise<void>;
|
|
17
27
|
export {};
|
package/dist/poller.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './main
|
|
|
13
13
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
|
|
14
14
|
import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
|
|
15
15
|
import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
|
|
16
|
+
import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
|
|
16
17
|
import { Cron } from 'croner';
|
|
17
18
|
import { isControlMessage, handleControlMessage } from './control-handler.js';
|
|
18
19
|
import { checkForUpdate } from './auto-update.js';
|
|
@@ -122,7 +123,16 @@ async function attemptDnsFlush() {
|
|
|
122
123
|
}
|
|
123
124
|
// Track which threads have an active processing loop
|
|
124
125
|
const activeLoops = new Set();
|
|
125
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Whether a runInboxLoop is currently active for this thread. Used by the
|
|
128
|
+
* inbox watcher (teamvibe.ai#242) to avoid starting a second loop for a
|
|
129
|
+
* thread that's already being served — its existing pickNextMessage() call
|
|
130
|
+
* will pick up any new file on its own.
|
|
131
|
+
*/
|
|
132
|
+
export function isInboxLoopActive(threadId) {
|
|
133
|
+
return activeLoops.has(threadId);
|
|
134
|
+
}
|
|
135
|
+
export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
126
136
|
if (activeLoops.has(threadId)) {
|
|
127
137
|
sessionLog.info(`Loop already running for thread ${threadId}, skipping`);
|
|
128
138
|
return;
|
|
@@ -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
|
}
|