@teamvibe/poller 0.4.4 → 0.5.1

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.
@@ -4,6 +4,11 @@ interface BrainConfig {
4
4
  branch: string;
5
5
  }
6
6
  export declare function getBrainPath(brain?: BrainConfig, channelId?: string, workspaceId?: string): Promise<string>;
7
+ /**
8
+ * Update a brain repo (respects cooldown). Exported for testing — production
9
+ * call sites should go through `getBrainPath` / `ensureBaseBrain`.
10
+ */
11
+ export declare function updateBrain(brainDir: string, brainId: string, branch: string): Promise<void>;
7
12
  /**
8
13
  * Ensure base brain repo is cloned and up to date.
9
14
  * Uses same cooldown pattern as channel brains.
@@ -90,15 +90,53 @@ export async function getBrainPath(brain, channelId, workspaceId) {
90
90
  return brainDir;
91
91
  }
92
92
  /**
93
- * Update a brain repo (respects cooldown)
93
+ * Update a brain repo (respects cooldown). Exported for testing — production
94
+ * call sites should go through `getBrainPath` / `ensureBaseBrain`.
94
95
  */
95
- async function updateBrain(brainDir, brainId, branch) {
96
+ export async function updateBrain(brainDir, brainId, branch) {
96
97
  const now = Date.now();
97
98
  const lastUpdate = lastUpdateTimes.get(brainId) || 0;
98
99
  if (now - lastUpdate < config.BRAIN_UPDATE_COOLDOWN_MS) {
99
100
  logger.debug(`Brain ${brainId} update skipped - cooldown active (${Math.round((config.BRAIN_UPDATE_COOLDOWN_MS - (now - lastUpdate)) / 1000)}s remaining)`);
100
101
  return;
101
102
  }
103
+ // Guard: skip `reset --hard` when the working tree has uncommitted writes or
104
+ // unpushed commits, otherwise an in-flight session's mid-message memory
105
+ // writes (e.g. memory/TODAY.md, MEM_REGISTRY.md) get silently wiped on the
106
+ // next message arrival after the cooldown elapses. Fail-safe direction —
107
+ // pushBrainChanges at session-end reconciles drift on the happy path; the
108
+ // structural fix is worktree isolation (see #205). Mirrors no-upstream
109
+ // handling in pushBrainChanges below.
110
+ try {
111
+ const { stdout: dirty } = await gitExec('git status --porcelain', { cwd: brainDir });
112
+ let hasLocalCommits = false;
113
+ try {
114
+ const { stdout: unpushed } = await gitExec('git log @{u}..HEAD --oneline', { cwd: brainDir });
115
+ hasLocalCommits = !!unpushed.trim();
116
+ }
117
+ catch {
118
+ // No upstream tracking (detached HEAD, mid-rebase, or `git init`-bootstrapped
119
+ // brain dir; a normal `git clone` always sets up tracking). Broken upstream
120
+ // tracking → skip aggressively; manual investigation preferred over a silent
121
+ // --hard reset.
122
+ const { stdout: commits } = await gitExec('git log --oneline -1', { cwd: brainDir });
123
+ hasLocalCommits = !!commits.trim();
124
+ }
125
+ if (dirty.trim() || hasLocalCommits) {
126
+ // TODO(#205-followup): emit operational alert if a brain skips reset for > N hours —
127
+ // long-term skip means pushBrainChanges is persistently failing and the brain is
128
+ // silently drifting away from base-brain / skills updates.
129
+ logger.info(`Brain ${brainId} update skipped — local changes present (dirty=${!!dirty.trim()}, hasLocalCommits=${hasLocalCommits})`);
130
+ return;
131
+ }
132
+ }
133
+ catch (error) {
134
+ // Pre-check itself failed (e.g. brainDir not a git repo). Fail-safe to skip;
135
+ // the next iteration will retry.
136
+ const errorMessage = error instanceof Error ? error.message : String(error);
137
+ logger.warn(`Brain ${brainId} pre-check failed, skipping update: ${errorMessage}`);
138
+ return;
139
+ }
102
140
  try {
103
141
  logger.info(`Updating brain ${brainId} at ${brainDir}...`);
104
142
  await gitExec(`git fetch origin ${branch} && git reset --hard origin/${branch} && git submodule update --init --recursive`, {
@@ -0,0 +1,16 @@
1
+ export type KilledByPoller = 'timeout' | 'idle' | 'zombie' | 'network';
2
+ export interface FailedIterationInput {
3
+ error?: string;
4
+ exitCode: number | null;
5
+ killedByPoller?: KilledByPoller;
6
+ }
7
+ export type FailedIterationDecision = {
8
+ kind: 'neutral';
9
+ text: string;
10
+ reaction: string;
11
+ } | {
12
+ kind: 'error';
13
+ text: string;
14
+ reaction: string;
15
+ };
16
+ export declare function classifyFailedIteration(result: FailedIterationInput): FailedIterationDecision;
@@ -0,0 +1,53 @@
1
+ // Decides how to surface a failed claude-spawner iteration to the user.
2
+ //
3
+ // Background (teamvibe.ai#193): poller-induced kills (timeout, idle watchdog,
4
+ // network watchdog, zombie cleanup) used to leak as generic
5
+ // "Error processing your request: Process exited with code 143" via
6
+ // sendSlackError. The explicit `killedByPoller` intent marker (set by the
7
+ // spawner before SIGTERM) lets us emit a neutral 1-sentence notice instead —
8
+ // "Session paused — send another message to resume." — which tells the user
9
+ // *what happened* and *what to do*, without false-flagging an internal kill
10
+ // as an application failure.
11
+ //
12
+ // Pattern: explicit intent marker > exit-code pattern matching (143/137).
13
+ // Exit codes are fragile — the Claude binary can legitimately return 143
14
+ // for unrelated reasons; the marker decides on poller intent, not process
15
+ // output.
16
+ export function classifyFailedIteration(result) {
17
+ switch (result.killedByPoller) {
18
+ case 'idle':
19
+ return {
20
+ kind: 'neutral',
21
+ text: 'Session paused (idle timeout) — send another message to resume.',
22
+ reaction: 'zzz',
23
+ };
24
+ case 'zombie':
25
+ return {
26
+ kind: 'neutral',
27
+ text: "Process didn't shut down cleanly — send another message to restart.",
28
+ reaction: 'recycle',
29
+ };
30
+ case 'network':
31
+ return {
32
+ kind: 'neutral',
33
+ text: 'Network connectivity lost — send another message to retry.',
34
+ reaction: 'satellite_antenna',
35
+ };
36
+ case 'timeout':
37
+ case undefined: {
38
+ // 'timeout' keeps the legacy ":warning: Session timed out after N
39
+ // minutes..." surface unchanged (the error string is set by the
40
+ // spawner). undefined = genuine failure → keep current behavior.
41
+ const text = result.error || `Process exited with code ${result.exitCode}`;
42
+ const isTimeout = result.killedByPoller === 'timeout' || text.includes('timed out');
43
+ return { kind: 'error', text, reaction: isTimeout ? 'hourglass' : 'x' };
44
+ }
45
+ default: {
46
+ // Exhaustiveness guard: adding a new member to KilledByPoller without
47
+ // handling it here will fail to compile (never assignment). Runtime
48
+ // throw is the fallback for tsconfigs without noImplicitReturns.
49
+ const _exhaustive = result.killedByPoller;
50
+ throw new Error(`Unhandled killedByPoller: ${String(_exhaustive)}`);
51
+ }
52
+ }
53
+ }
@@ -1,14 +1,28 @@
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 { KilledByPoller } from './classify-failed-iteration.js';
5
+ export interface ContextHintParams {
6
+ isFirstMessage: boolean;
7
+ lastMessageTs?: string;
8
+ slack?: {
9
+ channel: string;
10
+ thread_ts: string;
11
+ message_ts: string;
12
+ };
13
+ countNewSinceTs: (channel: string, threadTs: string, sinceTs: string) => Promise<number>;
14
+ countPriorBeforeTs: (channel: string, threadTs: string, beforeTs: string) => Promise<number>;
15
+ }
16
+ export declare function buildContextHint(p: ContextHintParams): Promise<string | undefined>;
4
17
  export interface SpawnResult {
5
18
  success: boolean;
6
19
  output: string;
7
20
  exitCode: number | null;
8
21
  error?: string;
9
22
  metrics?: SessionMetrics;
23
+ killedByPoller?: KilledByPoller;
10
24
  }
11
- export declare function buildPrompt(msg: TeamVibeQueueMessage, resumeHint?: string): string;
25
+ export declare function buildPrompt(msg: TeamVibeQueueMessage, contextHint?: string): string;
12
26
  export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void, typingIndicator?: TypingIndicator): Promise<SpawnResult & {
13
27
  newSessionId?: string;
14
28
  }>;
@@ -8,6 +8,7 @@ import { getBaseBrainPath } from './brain-manager.js';
8
8
  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
+ import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js';
11
12
  /**
12
13
  * Maps tool names (or prefixes) to human-readable status messages.
13
14
  * More specific patterns are checked first.
@@ -86,6 +87,68 @@ async function countNewThreadMessages(botToken, channel, threadTs, sinceTs) {
86
87
  return 0;
87
88
  }
88
89
  }
90
+ // Count messages strictly before the message being handled. Used to hint that
91
+ // prior thread context exists when the bot is invoked for the first time on a
92
+ // thread that already has history. Bot-side messages are included — the bot's
93
+ // own past replies are still context the model should be aware of (a fresh
94
+ // session has lost what it said before).
95
+ // TODO: detect has_more to surface a "100+" signal — conversations.replies
96
+ // saturates at limit:100 without telling the caller, so very long threads
97
+ // will be reported as exactly "100 previous messages".
98
+ async function countPriorThreadMessages(botToken, channel, threadTs, currentMessageTs) {
99
+ try {
100
+ const slack = getSlackClient(botToken);
101
+ const result = await slack.conversations.replies({
102
+ channel,
103
+ ts: threadTs,
104
+ limit: 100,
105
+ });
106
+ if (!result.messages)
107
+ return 0;
108
+ const priorMessages = result.messages.filter((msg) => {
109
+ if (!msg.ts)
110
+ return false;
111
+ return parseFloat(msg.ts) < parseFloat(currentMessageTs);
112
+ });
113
+ return priorMessages.length;
114
+ }
115
+ catch (error) {
116
+ logger.warn(`Failed to count prior thread messages: ${error}`);
117
+ emitError('slack', `Failed to count prior thread messages: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
118
+ return 0;
119
+ }
120
+ }
121
+ // Select and render the context hint that goes into the prompt. Pure aside
122
+ // from the injected counters — extracted from runClaudeCode so the branching
123
+ // (resume vs. thread-context vs. no-hint) can be unit tested without spinning
124
+ // up a Slack WebClient.
125
+ //
126
+ // Branches:
127
+ // - resumed session (!isFirstMessage && lastMessageTs && slack thread)
128
+ // → "Resumed session: N new messages …" (or "No new messages" when N=0)
129
+ // - first-spawn on a thread reply (isFirstMessage && thread_ts !== message_ts)
130
+ // → "Thread context: N previous messages …" when N>0, else no hint
131
+ // - everything else (top-level message, resume-without-anchor)
132
+ // → no hint
133
+ export async function buildContextHint(p) {
134
+ if (!p.isFirstMessage && p.lastMessageTs && p.slack?.channel && p.slack?.thread_ts) {
135
+ const newMessageCount = await p.countNewSinceTs(p.slack.channel, p.slack.thread_ts, p.lastMessageTs);
136
+ return newMessageCount > 0
137
+ ? `**Resumed session:** ${newMessageCount} new message(s) in the thread since your last response. Consider using \`read_thread\` to see what was discussed before responding.`
138
+ : `**Resumed session:** No new messages since your last response.`;
139
+ }
140
+ if (p.isFirstMessage &&
141
+ p.slack?.channel &&
142
+ p.slack?.thread_ts &&
143
+ p.slack?.message_ts &&
144
+ p.slack.thread_ts !== p.slack.message_ts) {
145
+ const priorCount = await p.countPriorBeforeTs(p.slack.channel, p.slack.thread_ts, p.slack.message_ts);
146
+ if (priorCount > 0) {
147
+ return `**Thread context:** ${priorCount} previous message(s) in this thread before yours.\nCall \`read_thread\` first to load conversation context.`;
148
+ }
149
+ }
150
+ return undefined;
151
+ }
89
152
  /**
90
153
  * Read brain settings.json from the brain working directory.
91
154
  * Returns parsed settings or empty object if not found.
@@ -104,11 +167,11 @@ function readBrainSettings(cwd) {
104
167
  return {};
105
168
  }
106
169
  }
107
- export function buildPrompt(msg, resumeHint) {
108
- // Heartbeat messages get a minimal prompt — CLAUDE.md/MAINTENANCE.md handle the rest
109
- if (msg.source === 'heartbeat') {
110
- return 'heartbeat';
111
- }
170
+ // Tags procs at SIGTERM time so the close handler can report poller intent.
171
+ // WeakMap auto-cleans when proc is GC'd. Module-scope is required because
172
+ // killZombieForThread fires outside the runClaudeCode closure.
173
+ const killReasonByProc = new WeakMap();
174
+ export function buildPrompt(msg, contextHint) {
112
175
  const isEmail = msg.source === 'email';
113
176
  const isWebhook = msg.source === 'webhook';
114
177
  let prompt = isEmail ? '## Incoming Email\n\n' : isWebhook ? '## Incoming Webhook\n\n' : '## Incoming Slack Message\n\n';
@@ -164,8 +227,8 @@ export function buildPrompt(msg, resumeHint) {
164
227
  if (msg.teamvibe.emailHookPrompt) {
165
228
  prompt += '\n\n**Channel Instructions:**\n' + msg.teamvibe.emailHookPrompt + '\n';
166
229
  }
167
- if (resumeHint) {
168
- prompt += `\n\n${resumeHint}`;
230
+ if (contextHint) {
231
+ prompt += `\n\n${contextHint}`;
169
232
  }
170
233
  if (isEmail) {
171
234
  prompt += `\n\n---\n\nProcess this email and respond appropriately using available tools.`;
@@ -234,31 +297,44 @@ function formatStreamEvent(event) {
234
297
  async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
235
298
  const slackContext = msg.response_context.slack;
236
299
  const isCronMessage = msg.source === 'cron';
237
- const isHeartbeat = msg.source === 'heartbeat';
238
300
  const isMaintenance = msg.source === 'maintenance';
239
301
  const isEmailMessage = msg.source === 'email';
240
302
  const isWebhookMessage = msg.source === 'webhook';
241
303
  if (!slackContext &&
242
304
  !isCronMessage &&
243
- !isHeartbeat &&
244
305
  !isMaintenance &&
245
306
  !isEmailMessage &&
246
307
  !isWebhookMessage) {
247
308
  throw new Error('No Slack context in message');
248
309
  }
249
- // Build resume hint
250
- let resumeHint;
251
- if (!isFirstMessage && lastMessageTs && slackContext?.channel && slackContext?.thread_ts) {
252
- const newMessageCount = await countNewThreadMessages(msg.teamvibe.botToken, slackContext.channel, slackContext.thread_ts, lastMessageTs);
253
- if (newMessageCount > 0) {
254
- resumeHint = `**Resumed session:** ${newMessageCount} new message(s) in the thread since your last response. Consider using \`read_thread\` to see what was discussed before responding.`;
255
- }
256
- else {
257
- resumeHint = `**Resumed session:** No new messages since your last response.`;
258
- }
259
- }
310
+ // Build context hint — either resume hint (when continuing a session) or
311
+ // thread context hint (when first-spawning on a thread that already has
312
+ // prior messages). The two are mutually exclusive: a resume implies the bot
313
+ // already saw the thread once, so resume's "X new since last response" is
314
+ // the more informative signal. Logic extracted into buildContextHint for
315
+ // unit testability — the counters are injected so tests don't need to mock
316
+ // the Slack WebClient module.
317
+ const contextHint = await buildContextHint({
318
+ isFirstMessage,
319
+ lastMessageTs,
320
+ slack: slackContext,
321
+ countNewSinceTs: (ch, tts, since) => countNewThreadMessages(msg.teamvibe.botToken, ch, tts, since),
322
+ countPriorBeforeTs: (ch, tts, before) => countPriorThreadMessages(msg.teamvibe.botToken, ch, tts, before),
323
+ });
324
+ // Fetch secrets for this spawn before entering the Promise constructor —
325
+ // the executor callback is not async, so awaits must be hoisted out.
326
+ // Returns an opaque envelope; we unwrap exactly once at the spawn env
327
+ // construction site below. Reminder metadata (names + scope + expiration
328
+ // only — never values) feeds the system prompt so the session knows which
329
+ // ENV variables exist.
330
+ const spawnSecrets = await fetchSecretsForSpawn({
331
+ pollerId: getPollerId() || '',
332
+ workspaceId: msg.teamvibe.workspaceId,
333
+ channelId: msg.teamvibe.channelId,
334
+ });
335
+ const secretsReminder = buildSecretsReminder(spawnSecrets.reminders);
260
336
  return new Promise((resolve) => {
261
- const prompt = buildPrompt(msg, resumeHint);
337
+ const prompt = buildPrompt(msg, contextHint);
262
338
  // MCP server config passed via --mcp-config flag (no temp files needed)
263
339
  const mcpServerPath = join(getBaseBrainPath(), 'mcp', 'slack.mjs');
264
340
  const teamvibeApiServerPath = join(getBaseBrainPath(), 'mcp', 'teamvibe-api.mjs');
@@ -302,6 +378,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
302
378
  `If empty: continue your work.`,
303
379
  `You MUST check the inbox: (1) before starting work, (2) after EVERY tool call, (3) after finishing all work.`,
304
380
  `Never skip an inbox check. This is how the user communicates with you during long tasks.`,
381
+ ...(secretsReminder ? ['', secretsReminder] : []),
305
382
  ].join('\n'));
306
383
  // Handle session continuity
307
384
  if (sessionId) {
@@ -323,12 +400,27 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
323
400
  const proc = spawn(config.CLAUDE_CLI_PATH, args, {
324
401
  cwd,
325
402
  env: {
403
+ // Secrets platform (#105) injection — SOLE call site of
404
+ // `SpawnSecretsEnvelope.unwrap()` in the entire poller codebase.
405
+ // Placed FIRST so subsequent spreads can overwrite. Belt-and-
406
+ // suspenders: even if the API's reserved-prefix validator fails
407
+ // (`AWS_`, `TEAMVIBE_`, `SLACK_`, `CLAUDE_CODE_`, `ANTHROPIC_`)
408
+ // a name collision is silently won by the platform variable
409
+ // explicit field below.
410
+ //
411
+ // Phase 2 of teamvibe.ai#215 (poller v0.5.1): the explicit
412
+ // `SLACK_BOT_TOKEN: msg.teamvibe.botToken` injection that used
413
+ // to live here was removed. `SLACK_BOT_TOKEN` now sources from
414
+ // the `/secrets/spawn` platform overlay (the spread above) —
415
+ // backend v0.2.2+ resolves it via the `Channel → SlackBot`
416
+ // relation. Requires backend on Phase 1; pollers on 0.5.0 still
417
+ // read the explicit field, so split rollout is safe.
418
+ ...spawnSecrets.env.unwrap(),
326
419
  ...process.env,
327
420
  CLAUDECODE: undefined,
328
421
  PATH: envPath,
329
422
  CI: 'true',
330
423
  CLAUDE_CONFIG_DIR: getBaseBrainPath(),
331
- SLACK_BOT_TOKEN: msg.teamvibe.botToken,
332
424
  ...(config.PERSISTENT_STORAGE_PATH && {
333
425
  PERSISTENT_STORAGE_PATH: config.PERSISTENT_STORAGE_PATH,
334
426
  }),
@@ -439,15 +531,14 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
439
531
  networkErrorStart = null;
440
532
  }
441
533
  });
442
- let timedOut = false;
443
534
  const timeoutMinutes = Math.round(config.CLAUDE_TIMEOUT_MS / 60000);
444
535
  const timeout = setTimeout(() => {
445
- timedOut = true;
446
536
  logger.error(`Claude Code process timed out after ${timeoutMinutes} minutes`);
447
537
  emitError('session', `Claude Code process timed out after ${timeoutMinutes} minutes`, {
448
538
  context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
449
539
  channelId: msg.teamvibe.channelId,
450
540
  });
541
+ killReasonByProc.set(proc, 'timeout');
451
542
  proc.kill('SIGTERM');
452
543
  setTimeout(() => proc.kill('SIGKILL'), 5000);
453
544
  }, config.CLAUDE_TIMEOUT_MS);
@@ -465,6 +556,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
465
556
  context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
466
557
  channelId: msg.teamvibe.channelId,
467
558
  });
559
+ killReasonByProc.set(proc, 'network');
468
560
  proc.kill('SIGTERM');
469
561
  setTimeout(() => proc.kill('SIGKILL'), 5000);
470
562
  return;
@@ -477,6 +569,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
477
569
  context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
478
570
  channelId: msg.teamvibe.channelId,
479
571
  });
572
+ killReasonByProc.set(proc, 'idle');
480
573
  proc.kill('SIGTERM');
481
574
  setTimeout(() => proc.kill('SIGKILL'), 5000);
482
575
  }
@@ -496,15 +589,26 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
496
589
  clearInterval(idleWatchdog);
497
590
  activeProcesses.delete(msg.id);
498
591
  activeProcessesByThread.delete(msg.thread_id);
592
+ const killReason = killReasonByProc.get(proc);
499
593
  const result = {
500
594
  success: code === 0,
501
595
  output: stdout,
502
596
  exitCode: code,
503
597
  metrics,
598
+ ...(killReason ? { killedByPoller: killReason } : {}),
504
599
  };
505
- if (timedOut) {
600
+ if (killReason === 'timeout') {
506
601
  result.error = `Session timed out after ${timeoutMinutes} minutes. The task was still running when the time limit was reached. Consider breaking complex tasks into smaller steps.`;
507
602
  }
603
+ else if (killReason === 'idle') {
604
+ result.error = `Killed by idle watchdog (no output for >${Math.round(config.IDLE_WATCHDOG_KILL_MS / 60000)} minutes)`;
605
+ }
606
+ else if (killReason === 'network') {
607
+ result.error = 'Killed by network watchdog (sustained network errors)';
608
+ }
609
+ else if (killReason === 'zombie') {
610
+ result.error = 'Killed as zombie process (stale session lock)';
611
+ }
508
612
  else if (code !== 0) {
509
613
  result.error = stderr || `Process exited with code ${code}`;
510
614
  }
@@ -532,7 +636,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
532
636
  * Sources whose sessions should run in an isolated git worktree (teamvibe.ai#120).
533
637
  * Foreground Slack messages always run in the main working dir.
534
638
  */
535
- const ISOLATED_SOURCES = new Set(['cron', 'heartbeat', 'maintenance']);
639
+ const ISOLATED_SOURCES = new Set(['cron', 'maintenance']);
536
640
  function shouldIsolateInWorktree(msg) {
537
641
  return isWorktreeIsolationEnabled() && ISOLATED_SOURCES.has(msg.source);
538
642
  }
@@ -603,6 +707,7 @@ export function killZombieForThread(threadId) {
603
707
  }
604
708
  logger.warn(`Killing zombie process for thread ${threadId} (pid: ${proc.pid})`);
605
709
  emitError('session', `Killing zombie process for thread ${threadId}`, { severity: 'warn' });
710
+ killReasonByProc.set(proc, 'zombie');
606
711
  proc.kill('SIGTERM');
607
712
  setTimeout(() => {
608
713
  if (proc.exitCode === null) {
package/dist/config.d.ts CHANGED
@@ -29,7 +29,6 @@ declare const configSchema: z.ZodObject<{
29
29
  BRAIN_AUTO_UPDATE: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
30
30
  BRAIN_UPDATE_COOLDOWN_MS: z.ZodDefault<z.ZodNumber>;
31
31
  POLLER_TZ: z.ZodOptional<z.ZodString>;
32
- HEARTBEAT_CRON: z.ZodDefault<z.ZodString>;
33
32
  MAINTENANCE_CRON: z.ZodDefault<z.ZodString>;
34
33
  MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
35
34
  AUTO_UPDATE_POLICY: z.ZodDefault<z.ZodEnum<["patch", "minor", "off"]>>;
@@ -59,7 +58,6 @@ declare const configSchema: z.ZodObject<{
59
58
  BASE_BRAIN_PATH: string;
60
59
  BRAIN_AUTO_UPDATE: boolean;
61
60
  BRAIN_UPDATE_COOLDOWN_MS: number;
62
- HEARTBEAT_CRON: string;
63
61
  MAINTENANCE_CRON: string;
64
62
  MAINTENANCE_ENABLED: boolean;
65
63
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
@@ -97,7 +95,6 @@ declare const configSchema: z.ZodObject<{
97
95
  BRAIN_AUTO_UPDATE?: string | undefined;
98
96
  BRAIN_UPDATE_COOLDOWN_MS?: number | undefined;
99
97
  POLLER_TZ?: string | undefined;
100
- HEARTBEAT_CRON?: string | undefined;
101
98
  MAINTENANCE_CRON?: string | undefined;
102
99
  MAINTENANCE_ENABLED?: string | undefined;
103
100
  AUTO_UPDATE_POLICY?: "patch" | "minor" | "off" | undefined;
@@ -130,7 +127,6 @@ export declare const config: {
130
127
  BASE_BRAIN_PATH: string;
131
128
  BRAIN_AUTO_UPDATE: boolean;
132
129
  BRAIN_UPDATE_COOLDOWN_MS: number;
133
- HEARTBEAT_CRON: string;
134
130
  MAINTENANCE_CRON: string;
135
131
  MAINTENANCE_ENABLED: boolean;
136
132
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
package/dist/config.js CHANGED
@@ -43,14 +43,13 @@ const configSchema = z.object({
43
43
  .default('true')
44
44
  .transform((v) => v.toLowerCase() === 'true'),
45
45
  BRAIN_UPDATE_COOLDOWN_MS: z.coerce.number().default(300000), // 5 minutes
46
- // Brain heartbeat & maintenance (cron-based)
47
- // POLLER_TZ: optional IANA timezone for HEARTBEAT_CRON & MAINTENANCE_CRON.
46
+ // Brain maintenance (cron-based)
47
+ // POLLER_TZ: optional IANA timezone for MAINTENANCE_CRON.
48
48
  // When unset, croner uses the host system timezone (e.g. `Europe/Prague` on
49
49
  // a CZ Mac mini, `UTC` on a default Railway container). Set explicitly
50
50
  // (e.g. `POLLER_TZ=Europe/Prague` on Railway) when the host TZ doesn't match
51
51
  // the intended cron interpretation. DST is handled by croner automatically.
52
52
  POLLER_TZ: z.string().optional(),
53
- HEARTBEAT_CRON: z.string().default('0 */6 * * *'), // Every 6 hours
54
53
  MAINTENANCE_CRON: z.string().default('30 3 * * *'), // Daily at 03:30 in POLLER_TZ (or host TZ)
55
54
  MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
56
55
  // Auto-update settings
@@ -1,24 +1,20 @@
1
1
  /**
2
2
  * Module-scoped state for poller-level cron-job telemetry.
3
3
  *
4
- * Per teamvibeai/teamvibe.ai#119, the metrics collector reports six measures:
5
- * last{Heartbeat,Maintenance,AutoUpdate}Job{,Success}Ms
4
+ * Per teamvibeai/teamvibe.ai#119, the metrics collector reports four measures:
5
+ * last{Maintenance,AutoUpdate}Job{,Success}Ms
6
6
  * each computed as `Date.now() - storedTs` at collection time, with the
7
7
  * measure omitted entirely when the cron has never fired (storedTs null).
8
8
  *
9
- * Pulled out of heartbeat-manager.ts / poller.ts into a dedicated module to
10
- * avoid a circular import — the metrics collector reads from these getters,
11
- * and heartbeat-manager.ts already imports from the collector.
9
+ * Lives in its own module to avoid a circular import — the metrics collector
10
+ * reads from these getters, and maintenance-cron.ts already imports from the
11
+ * collector.
12
12
  */
13
- export declare function markHeartbeatJobFired(at?: number): void;
14
- export declare function markHeartbeatJobSuccess(at?: number): void;
15
13
  export declare function markMaintenanceJobFired(at?: number): void;
16
14
  export declare function markMaintenanceJobSuccess(at?: number): void;
17
15
  export declare function markAutoUpdateJobFired(at?: number): void;
18
16
  export declare function markAutoUpdateJobSuccess(at?: number): void;
19
17
  export interface CronJobTimestamps {
20
- lastHeartbeatJobFiredAt: number | null;
21
- lastHeartbeatJobSuccessAt: number | null;
22
18
  lastMaintenanceJobFiredAt: number | null;
23
19
  lastMaintenanceJobSuccessAt: number | null;
24
20
  lastAutoUpdateJobFiredAt: number | null;
@@ -1,27 +1,19 @@
1
1
  /**
2
2
  * Module-scoped state for poller-level cron-job telemetry.
3
3
  *
4
- * Per teamvibeai/teamvibe.ai#119, the metrics collector reports six measures:
5
- * last{Heartbeat,Maintenance,AutoUpdate}Job{,Success}Ms
4
+ * Per teamvibeai/teamvibe.ai#119, the metrics collector reports four measures:
5
+ * last{Maintenance,AutoUpdate}Job{,Success}Ms
6
6
  * each computed as `Date.now() - storedTs` at collection time, with the
7
7
  * measure omitted entirely when the cron has never fired (storedTs null).
8
8
  *
9
- * Pulled out of heartbeat-manager.ts / poller.ts into a dedicated module to
10
- * avoid a circular import — the metrics collector reads from these getters,
11
- * and heartbeat-manager.ts already imports from the collector.
9
+ * Lives in its own module to avoid a circular import — the metrics collector
10
+ * reads from these getters, and maintenance-cron.ts already imports from the
11
+ * collector.
12
12
  */
13
- let lastHeartbeatJobFiredAt = null;
14
- let lastHeartbeatJobSuccessAt = null;
15
13
  let lastMaintenanceJobFiredAt = null;
16
14
  let lastMaintenanceJobSuccessAt = null;
17
15
  let lastAutoUpdateJobFiredAt = null;
18
16
  let lastAutoUpdateJobSuccessAt = null;
19
- export function markHeartbeatJobFired(at = Date.now()) {
20
- lastHeartbeatJobFiredAt = at;
21
- }
22
- export function markHeartbeatJobSuccess(at = Date.now()) {
23
- lastHeartbeatJobSuccessAt = at;
24
- }
25
17
  export function markMaintenanceJobFired(at = Date.now()) {
26
18
  lastMaintenanceJobFiredAt = at;
27
19
  }
@@ -36,8 +28,6 @@ export function markAutoUpdateJobSuccess(at = Date.now()) {
36
28
  }
37
29
  export function getCronJobTimestamps() {
38
30
  return {
39
- lastHeartbeatJobFiredAt,
40
- lastHeartbeatJobSuccessAt,
41
31
  lastMaintenanceJobFiredAt,
42
32
  lastMaintenanceJobSuccessAt,
43
33
  lastAutoUpdateJobFiredAt,
@@ -49,8 +39,6 @@ export function getCronJobTimestamps() {
49
39
  * @internal
50
40
  */
51
41
  export function __resetCronTelemetryForTest() {
52
- lastHeartbeatJobFiredAt = null;
53
- lastHeartbeatJobSuccessAt = null;
54
42
  lastMaintenanceJobFiredAt = null;
55
43
  lastMaintenanceJobSuccessAt = null;
56
44
  lastAutoUpdateJobFiredAt = null;
@@ -1,39 +1,28 @@
1
1
  import type { TeamVibeQueueMessage } from './types.js';
2
2
  /**
3
- * Register a brain from a real message so we know about it for heartbeats.
3
+ * Register a brain from a real message so the maintenance cron knows about it.
4
4
  * Called from processMessage() after brain path is resolved.
5
5
  */
6
6
  export declare function registerBrain(msg: TeamVibeQueueMessage): void;
7
7
  /**
8
- * Start the cron-based heartbeat and maintenance schedules.
9
- *
10
- * - Heartbeat: lightweight, frequent (default: every 5 min)
11
- * - Maintenance: heavy, daily (default: 03:30 UTC)
8
+ * Start the cron-based maintenance schedule (default: daily 03:30 in POLLER_TZ).
12
9
  */
13
- export declare function startHeartbeatLoop(processMessageFn: (msg: {
10
+ export declare function startMaintenanceLoop(processMessageFn: (msg: {
14
11
  queueMessage: TeamVibeQueueMessage;
15
12
  receiptHandle: string;
16
13
  messageId: string;
17
14
  }) => Promise<void>): void;
18
15
  /**
19
- * Run the heartbeat cron handler with telemetry instrumentation (#119).
16
+ * Run the maintenance cron handler with telemetry instrumentation (#119).
20
17
  * Records last-fire timestamp on entry and last-success timestamp on completion.
21
18
  * Exported for use by the cron registration above and tests that fire it manually.
22
19
  */
23
- export declare function runHeartbeatJob(processMessageFn: (msg: {
24
- queueMessage: TeamVibeQueueMessage;
25
- receiptHandle: string;
26
- messageId: string;
27
- }) => Promise<void>): Promise<void>;
28
- /**
29
- * Run the maintenance cron handler with telemetry instrumentation (#119).
30
- */
31
20
  export declare function runMaintenanceJob(processMessageFn: (msg: {
32
21
  queueMessage: TeamVibeQueueMessage;
33
22
  receiptHandle: string;
34
23
  messageId: string;
35
24
  }) => Promise<void>): Promise<void>;
36
25
  /**
37
- * Stop the heartbeat and maintenance crons (for shutdown).
26
+ * Stop the maintenance cron (for shutdown).
38
27
  */
39
- export declare function stopHeartbeatLoop(): void;
28
+ export declare function stopMaintenanceLoop(): void;
@@ -6,21 +6,20 @@ import { emitError } from './event-queue.js';
6
6
  import { registerBrainForMetrics } from './metrics/scheduler.js';
7
7
  import { collectMetrics } from './metrics/collector.js';
8
8
  import { sendMetrics } from './metrics/sender.js';
9
- import { markHeartbeatJobFired, markHeartbeatJobSuccess, markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
9
+ import { markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
10
10
  import { join } from 'path';
11
11
  const trackedBrains = new Map();
12
- let heartbeatJob = null;
13
12
  let maintenanceJob = null;
14
13
  /**
15
- * Register a brain from a real message so we know about it for heartbeats.
14
+ * Register a brain from a real message so the maintenance cron knows about it.
16
15
  * Called from processMessage() after brain path is resolved.
17
16
  */
18
17
  export function registerBrain(msg) {
19
18
  const brain = msg.teamvibe.brain;
20
19
  if (!brain?.brainId)
21
20
  return;
22
- // Don't register from heartbeat/maintenance messages (avoid self-referential loop)
23
- if (msg.source === 'heartbeat' || msg.source === 'maintenance')
21
+ // Don't register from maintenance messages (avoid self-referential loop)
22
+ if (msg.source === 'maintenance')
24
23
  return;
25
24
  const isNew = !trackedBrains.has(brain.brainId);
26
25
  trackedBrains.set(brain.brainId, {
@@ -32,7 +31,7 @@ export function registerBrain(msg) {
32
31
  botToken: msg.teamvibe.botToken,
33
32
  channelId: msg.teamvibe.channelId,
34
33
  });
35
- logger.debug(`[Heartbeat] Registered brain: ${brain.brainId}`);
34
+ logger.debug(`[Maintenance] Registered brain: ${brain.brainId}`);
36
35
  // Metrics pipeline (teamvibe.ai#110): register for hourly collection. On the
37
36
  // first sighting of this brain, fire one immediate collect+send so we don't
38
37
  // wait up to an hour for the first sample.
@@ -52,25 +51,25 @@ export function registerBrain(msg) {
52
51
  }
53
52
  }
54
53
  /**
55
- * Send a synthetic message to all tracked brains.
54
+ * Send a synthetic maintenance message to all tracked brains.
56
55
  */
57
- async function triggerAllBrains(source, processMessageFn) {
56
+ async function triggerAllBrains(processMessageFn) {
58
57
  const now = Date.now();
59
58
  for (const [brainId, info] of trackedBrains.entries()) {
60
59
  if (isAtCapacity()) {
61
- logger.debug(`[${source}] At capacity, skipping ${brainId}`);
60
+ logger.debug(`[Maintenance] At capacity, skipping ${brainId}`);
62
61
  continue;
63
62
  }
64
- logger.info(`[${source}] Triggering ${source} for brain ${brainId}`);
63
+ logger.info(`[Maintenance] Triggering maintenance for brain ${brainId}`);
65
64
  const syntheticMessage = {
66
- id: `${source}_${brainId}_${now}`,
67
- source,
68
- thread_id: `${source}_${brainId}_${now}`,
65
+ id: `maintenance_${brainId}_${now}`,
66
+ source: 'maintenance',
67
+ thread_id: `maintenance_${brainId}_${now}`,
69
68
  sender: {
70
69
  id: 'system',
71
70
  name: 'System',
72
71
  },
73
- text: source,
72
+ text: 'maintenance',
74
73
  attachments: [],
75
74
  response_context: {},
76
75
  type: 'message',
@@ -94,8 +93,8 @@ async function triggerAllBrains(source, processMessageFn) {
94
93
  });
95
94
  }
96
95
  catch (error) {
97
- logger.error(`[${source}] Failed for ${brainId}: ${error instanceof Error ? error.message : error}`);
98
- emitError('session', `${source} failed for ${brainId}: ${error instanceof Error ? error.message : String(error)}`, {
96
+ logger.error(`[Maintenance] Failed for ${brainId}: ${error instanceof Error ? error.message : error}`);
97
+ emitError('session', `maintenance failed for ${brainId}: ${error instanceof Error ? error.message : String(error)}`, {
99
98
  channelId: info.channelId,
100
99
  context: { brainId },
101
100
  });
@@ -103,14 +102,11 @@ async function triggerAllBrains(source, processMessageFn) {
103
102
  }
104
103
  }
105
104
  /**
106
- * Start the cron-based heartbeat and maintenance schedules.
107
- *
108
- * - Heartbeat: lightweight, frequent (default: every 5 min)
109
- * - Maintenance: heavy, daily (default: 03:30 UTC)
105
+ * Start the cron-based maintenance schedule (default: daily 03:30 in POLLER_TZ).
110
106
  */
111
- export function startHeartbeatLoop(processMessageFn) {
107
+ export function startMaintenanceLoop(processMessageFn) {
112
108
  if (!config.MAINTENANCE_ENABLED) {
113
- logger.info('[Heartbeat] Maintenance disabled via config');
109
+ logger.info('[Maintenance] Maintenance disabled via config');
114
110
  return;
115
111
  }
116
112
  // Croner uses the host system TZ when `timezone` option is omitted.
@@ -120,58 +116,34 @@ export function startHeartbeatLoop(processMessageFn) {
120
116
  ? { timezone: config.POLLER_TZ, protect: true }
121
117
  : { protect: true };
122
118
  const tzLabel = config.POLLER_TZ ?? `host (${Intl.DateTimeFormat().resolvedOptions().timeZone})`;
123
- // Heartbeat cron — frequent, lightweight
124
- logger.info(`[Heartbeat] Starting heartbeat cron: "${config.HEARTBEAT_CRON}" (tz: ${tzLabel})`);
125
- heartbeatJob = new Cron(config.HEARTBEAT_CRON, cronOpts, async () => {
126
- await runHeartbeatJob(processMessageFn);
127
- });
128
- // Maintenance cron — daily, heavy (consolidation, memory cleanup)
129
119
  logger.info(`[Maintenance] Starting maintenance cron: "${config.MAINTENANCE_CRON}" (tz: ${tzLabel})`);
130
120
  maintenanceJob = new Cron(config.MAINTENANCE_CRON, cronOpts, async () => {
131
121
  await runMaintenanceJob(processMessageFn);
132
122
  });
133
123
  }
134
124
  /**
135
- * Run the heartbeat cron handler with telemetry instrumentation (#119).
125
+ * Run the maintenance cron handler with telemetry instrumentation (#119).
136
126
  * Records last-fire timestamp on entry and last-success timestamp on completion.
137
127
  * Exported for use by the cron registration above and tests that fire it manually.
138
128
  */
139
- export async function runHeartbeatJob(processMessageFn) {
140
- markHeartbeatJobFired();
141
- try {
142
- await triggerAllBrains('heartbeat', processMessageFn);
143
- markHeartbeatJobSuccess();
144
- }
145
- catch (e) {
146
- logger.error(`[Heartbeat] heartbeatJob handler threw: ${e instanceof Error ? e.message : e}`);
147
- throw e; // preserve existing behavior; croner protect handles overlap
148
- }
149
- }
150
- /**
151
- * Run the maintenance cron handler with telemetry instrumentation (#119).
152
- */
153
129
  export async function runMaintenanceJob(processMessageFn) {
154
130
  markMaintenanceJobFired();
155
131
  try {
156
- await triggerAllBrains('maintenance', processMessageFn);
132
+ await triggerAllBrains(processMessageFn);
157
133
  markMaintenanceJobSuccess();
158
134
  }
159
135
  catch (e) {
160
136
  logger.error(`[Maintenance] maintenanceJob handler threw: ${e instanceof Error ? e.message : e}`);
161
- throw e;
137
+ throw e; // preserve existing behavior; croner protect handles overlap
162
138
  }
163
139
  }
164
140
  /**
165
- * Stop the heartbeat and maintenance crons (for shutdown).
141
+ * Stop the maintenance cron (for shutdown).
166
142
  */
167
- export function stopHeartbeatLoop() {
168
- if (heartbeatJob) {
169
- heartbeatJob.stop();
170
- heartbeatJob = null;
171
- }
143
+ export function stopMaintenanceLoop() {
172
144
  if (maintenanceJob) {
173
145
  maintenanceJob.stop();
174
146
  maintenanceJob = null;
175
147
  }
176
- logger.info('[Heartbeat] Stopped heartbeat & maintenance crons');
148
+ logger.info('[Maintenance] Stopped maintenance cron');
177
149
  }
@@ -11,8 +11,6 @@ export interface MetricsPayload {
11
11
  todayMdLines?: number;
12
12
  summaryMdBytes?: number;
13
13
  summaryMdLines?: number;
14
- heartbeatMdBytes?: number;
15
- heartbeatMdLines?: number;
16
14
  brainDirBytes?: number;
17
15
  vmDiskFreeBytes?: number;
18
16
  vmDiskUsedBytes?: number;
@@ -21,8 +19,6 @@ export interface MetricsPayload {
21
19
  oldestInboxMessageAgeSeconds?: number;
22
20
  uncommittedChangeCount?: number;
23
21
  daysSinceMaintenance?: number;
24
- lastHeartbeatJobMs?: number;
25
- lastHeartbeatJobSuccessMs?: number;
26
22
  lastMaintenanceJobMs?: number;
27
23
  lastMaintenanceJobSuccessMs?: number;
28
24
  lastAutoUpdateJobMs?: number;
@@ -7,6 +7,10 @@ import { getCronJobTimestamps } from '../cron-telemetry.js';
7
7
  const statfsAsync = promisify(statfs);
8
8
  const execFileAsync = promisify(execFile);
9
9
  const MS_PER_DAY = 86_400_000;
10
+ // Directory names skipped at any depth during brainDirBytes walk.
11
+ // Holds `du --exclude=.git --exclude=node_modules` parity for the brain dir signal.
12
+ // See teamvibeai/teamvibe.ai#114.
13
+ const EXCLUDED_DIR_NAMES = new Set(['.git', 'node_modules']);
10
14
  /**
11
15
  * Recursively sum file sizes under a directory.
12
16
  * Skips entries we cannot stat (permission errors, broken symlinks).
@@ -21,6 +25,8 @@ async function dirSizeBytes(path) {
21
25
  return 0;
22
26
  }
23
27
  for (const entry of entries) {
28
+ if (entry.isDirectory() && EXCLUDED_DIR_NAMES.has(entry.name))
29
+ continue;
24
30
  const full = join(path, entry.name);
25
31
  try {
26
32
  if (entry.isDirectory()) {
@@ -135,19 +141,6 @@ export async function collectMetrics(brainPath, brainId) {
135
141
  catch {
136
142
  // omit
137
143
  }
138
- // HEARTBEAT.md size + line count — see poller-brain#101. The *number of
139
- // items* in the file is what drives the Step 8b sweep crash risk, so we
140
- // report size + line count rather than a binary presence flag. Both omitted
141
- // on any error (file missing or unreadable).
142
- try {
143
- const heartbeatPath = join(brainPath, 'HEARTBEAT.md');
144
- const s = await stat(heartbeatPath);
145
- payload.heartbeatMdBytes = s.size;
146
- payload.heartbeatMdLines = await countLines(heartbeatPath);
147
- }
148
- catch {
149
- // omit
150
- }
151
144
  // brain dir total size
152
145
  try {
153
146
  payload.brainDirBytes = await dirSizeBytes(brainPath);
@@ -224,17 +217,11 @@ export async function collectMetrics(brainPath, brainId) {
224
217
  catch {
225
218
  // Non-git brain, missing git binary, timeout — omit.
226
219
  }
227
- // Poller-level cron-job telemetry (#119). Same six values for every brain on
220
+ // Poller-level cron-job telemetry (#119). Same four values for every brain on
228
221
  // the poller — see issue body for storage cost rationale. Each measure is
229
222
  // `now - storedTs`; if the cron has never fired (storedTs null), omit it.
230
223
  const now = Date.now();
231
224
  const cron = getCronJobTimestamps();
232
- if (cron.lastHeartbeatJobFiredAt !== null) {
233
- payload.lastHeartbeatJobMs = now - cron.lastHeartbeatJobFiredAt;
234
- }
235
- if (cron.lastHeartbeatJobSuccessAt !== null) {
236
- payload.lastHeartbeatJobSuccessMs = now - cron.lastHeartbeatJobSuccessAt;
237
- }
238
225
  if (cron.lastMaintenanceJobFiredAt !== null) {
239
226
  payload.lastMaintenanceJobMs = now - cron.lastMaintenanceJobFiredAt;
240
227
  }
package/dist/poller.d.ts CHANGED
@@ -10,7 +10,7 @@ export declare function releaseAllActiveLocks(locks: Map<string, string>, releas
10
10
  * `checkForUpdateFn` is injected (default = real checkForUpdate) so tests
11
11
  * can lock in the "swallow but still mark fired" contract that
12
12
  * distinguishes scenario (A) cron-doesn't-fire from (B/C) cron-fires-but-fails
13
- * in morning-check telemetry. Same DI shape as runHeartbeatJob/runMaintenanceJob.
13
+ * in morning-check telemetry. Same DI shape as runMaintenanceJob.
14
14
  */
15
15
  export declare function runAutoUpdateJob(checkForUpdateFn?: () => Promise<boolean>): Promise<void>;
16
16
  export declare function startPoller(): Promise<void>;
package/dist/poller.js CHANGED
@@ -2,13 +2,14 @@ import { config } from './config.js';
2
2
  import { logger } from './logger.js';
3
3
  import { pollMessages, deleteMessage, } from './sqs-poller.js';
4
4
  import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
5
- import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
5
+ import { sendSlackError, sendSlackMessage, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
6
+ import { classifyFailedIteration } from './classify-failed-iteration.js';
6
7
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
7
8
  import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
8
9
  import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
9
10
  import { postMaintenanceReports } from './maintenance-reporter.js';
10
11
  import { initAuth, stopRefresh } from './auth-provider.js';
11
- import { registerBrain, startHeartbeatLoop, stopHeartbeatLoop } from './heartbeat-manager.js';
12
+ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './maintenance-cron.js';
12
13
  import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent } from './event-queue.js';
13
14
  import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
14
15
  import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
@@ -39,7 +40,7 @@ export async function releaseAllActiveLocks(locks, release, log = logger) {
39
40
  })));
40
41
  locks.clear();
41
42
  }
42
- // Poller auto-update cron (separate from brain heartbeats)
43
+ // Poller auto-update cron (separate from the maintenance cron)
43
44
  let autoUpdateJob = null;
44
45
  /**
45
46
  * Run the auto-update cron handler with telemetry instrumentation (#119).
@@ -48,7 +49,7 @@ let autoUpdateJob = null;
48
49
  * `checkForUpdateFn` is injected (default = real checkForUpdate) so tests
49
50
  * can lock in the "swallow but still mark fired" contract that
50
51
  * distinguishes scenario (A) cron-doesn't-fire from (B/C) cron-fires-but-fails
51
- * in morning-check telemetry. Same DI shape as runHeartbeatJob/runMaintenanceJob.
52
+ * in morning-check telemetry. Same DI shape as runMaintenanceJob.
52
53
  */
53
54
  export async function runAutoUpdateJob(checkForUpdateFn = checkForUpdate) {
54
55
  markAutoUpdateJobFired();
@@ -220,10 +221,14 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
220
221
  lastExitCode = result.exitCode;
221
222
  sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
222
223
  if (hasSlackContext) {
223
- const errorMessage = result.error || `Process exited with code ${result.exitCode}`;
224
- const isTimeout = errorMessage.includes('timed out');
225
- await sendSlackError(queueMessage, errorMessage);
226
- await addReaction(queueMessage, isTimeout ? 'hourglass' : 'x');
224
+ const decision = classifyFailedIteration(result);
225
+ if (decision.kind === 'neutral') {
226
+ await sendSlackMessage(queueMessage, decision.text);
227
+ }
228
+ else {
229
+ await sendSlackError(queueMessage, decision.text);
230
+ }
231
+ await addReaction(queueMessage, decision.reaction);
227
232
  }
228
233
  }
229
234
  }
@@ -275,7 +280,7 @@ async function processMessage(received) {
275
280
  const threadId = queueMessage.thread_id;
276
281
  const sessionLog = logger.createSession(logSessionId);
277
282
  // Fetch user info if we only have ID
278
- if (queueMessage.source !== 'cron' && queueMessage.source !== 'heartbeat' && queueMessage.source !== 'maintenance' &&
283
+ if (queueMessage.source !== 'cron' && queueMessage.source !== 'maintenance' &&
279
284
  queueMessage.sender.name === 'Unknown User' &&
280
285
  queueMessage.sender.id !== 'unknown') {
281
286
  const userInfo = await getUserInfo(queueMessage.teamvibe.botToken, queueMessage.sender.id);
@@ -311,7 +316,7 @@ async function processMessage(received) {
311
316
  }
312
317
  return;
313
318
  }
314
- // Register brain for heartbeat tracking
319
+ // Register brain so the maintenance cron knows about it.
315
320
  registerBrain(queueMessage);
316
321
  // Write raw message to inbox (spawnClaudeCode handles prompt formatting)
317
322
  writeMessage(kbPath, threadId, JSON.stringify({
@@ -501,7 +506,7 @@ async function shutdown(signal, exitCode = 0) {
501
506
  shuttingDown = true;
502
507
  logger.info(`${signal} received, shutting down gracefully...`);
503
508
  stopRefresh();
504
- stopHeartbeatLoop();
509
+ stopMaintenanceLoop();
505
510
  stopAutoUpdateCron();
506
511
  stopMetricsCron();
507
512
  stopEventQueue();
@@ -549,7 +554,7 @@ export async function startPoller() {
549
554
  const sweep = await cleanupOrphanWorktreesForAllBrains(config.BRAINS_PATH);
550
555
  logger.info(` Orphan worktree sweep: scanned ${sweep.scanned} brains, removed ${sweep.removed} stale worktrees`);
551
556
  }
552
- startHeartbeatLoop(processMessage);
557
+ startMaintenanceLoop(processMessage);
553
558
  startAutoUpdateCron();
554
559
  startMetricsCron();
555
560
  await pollLoop();
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Poller-side secrets client — teamvibe.ai#105 (PR4).
3
+ *
4
+ * Fetches per-spawn secret envelopes from the Poller API
5
+ * (GET /secrets/spawn?pollerId=&workspaceId=&channelId=) and shapes the
6
+ * response into a child-process env map plus a metadata array for system
7
+ * reminder rendering.
8
+ *
9
+ * Defense-in-depth principles enforced here:
10
+ *
11
+ * 1. **Localized unwrap.** The decrypted env map is returned wrapped in
12
+ * an opaque container with a single `unwrap()` method. The caller
13
+ * (`claude-spawner.ts`) calls `unwrap()` exactly once at the child
14
+ * process spawn site. No other call site touches the values.
15
+ *
16
+ * 2. **Reminder rendering never sees values.** `reminders[]` carries
17
+ * only `{name, scope, expiresAt, overrodeScopes}`. A separate render
18
+ * function `buildSecretsReminder` produces the system-prompt text
19
+ * from this metadata; it has no access to the env map.
20
+ *
21
+ * 3. **Caller-side log redaction.** This module's logging functions
22
+ * explicitly never log response bodies. Error paths log only the
23
+ * HTTP status + a sanitised error message. We do NOT rely solely on
24
+ * the API's `x-tv-no-log: response-body` header — the poller is its
25
+ * own trust boundary.
26
+ *
27
+ * If the API call fails, `fetchSecretsForSpawn` returns an empty envelope
28
+ * + reminders so that spawn proceeds without secrets rather than failing
29
+ * the user's session. Errors are logged at warn level; production
30
+ * monitoring should alarm on `secrets-client.fetch_failed` metric counter.
31
+ */
32
+ export type SecretScope = 'poller' | 'workspace' | 'channel';
33
+ export interface SecretReminderEntry {
34
+ name: string;
35
+ scope: SecretScope;
36
+ expiresAt?: string;
37
+ overrodeScopes?: SecretScope[];
38
+ }
39
+ /**
40
+ * Opaque envelope around the decrypted env map. The values inside MUST NOT
41
+ * be read except via `unwrap()`, and `unwrap()` is intended to be called
42
+ * exactly once at the child-process spawn site.
43
+ *
44
+ * Default JSON / toString render `[REDACTED]`. If you ever find yourself
45
+ * adding a new caller of `unwrap()`, treat it as a security-relevant
46
+ * change and ping DevGuru.
47
+ */
48
+ export declare class SpawnSecretsEnvelope {
49
+ #private;
50
+ constructor(env: Record<string, string>);
51
+ /** Sole legitimate value access. Single call site = `claude-spawner.ts`. */
52
+ unwrap(): Record<string, string>;
53
+ /** Number of secrets carried — safe for logging. */
54
+ size(): number;
55
+ toString(): string;
56
+ toJSON(): string;
57
+ }
58
+ export interface SpawnSecretsResult {
59
+ env: SpawnSecretsEnvelope;
60
+ reminders: SecretReminderEntry[];
61
+ }
62
+ export interface FetchSecretsInput {
63
+ pollerId: string;
64
+ workspaceId: string;
65
+ channelId: string;
66
+ }
67
+ /**
68
+ * Fetch secrets for a spawn. Returns an empty envelope (and empty
69
+ * reminders array) on any failure mode — spawn continues without
70
+ * secrets. The error is logged here so monitoring can alarm; do NOT
71
+ * pass error details into the spawn pipeline where they might surface
72
+ * to the user.
73
+ */
74
+ export declare function fetchSecretsForSpawn(input: FetchSecretsInput): Promise<SpawnSecretsResult>;
75
+ /**
76
+ * Render the system-reminder text from reminder metadata. This function
77
+ * intentionally has NO access to the env map — its inputs are name +
78
+ * scope + expiration only. A future code-reader who needs to add a value
79
+ * to the reminder body must change the function signature, which is a
80
+ * loud signal to think twice.
81
+ */
82
+ export declare function buildSecretsReminder(reminders: SecretReminderEntry[]): string;
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Poller-side secrets client — teamvibe.ai#105 (PR4).
3
+ *
4
+ * Fetches per-spawn secret envelopes from the Poller API
5
+ * (GET /secrets/spawn?pollerId=&workspaceId=&channelId=) and shapes the
6
+ * response into a child-process env map plus a metadata array for system
7
+ * reminder rendering.
8
+ *
9
+ * Defense-in-depth principles enforced here:
10
+ *
11
+ * 1. **Localized unwrap.** The decrypted env map is returned wrapped in
12
+ * an opaque container with a single `unwrap()` method. The caller
13
+ * (`claude-spawner.ts`) calls `unwrap()` exactly once at the child
14
+ * process spawn site. No other call site touches the values.
15
+ *
16
+ * 2. **Reminder rendering never sees values.** `reminders[]` carries
17
+ * only `{name, scope, expiresAt, overrodeScopes}`. A separate render
18
+ * function `buildSecretsReminder` produces the system-prompt text
19
+ * from this metadata; it has no access to the env map.
20
+ *
21
+ * 3. **Caller-side log redaction.** This module's logging functions
22
+ * explicitly never log response bodies. Error paths log only the
23
+ * HTTP status + a sanitised error message. We do NOT rely solely on
24
+ * the API's `x-tv-no-log: response-body` header — the poller is its
25
+ * own trust boundary.
26
+ *
27
+ * If the API call fails, `fetchSecretsForSpawn` returns an empty envelope
28
+ * + reminders so that spawn proceeds without secrets rather than failing
29
+ * the user's session. Errors are logged at warn level; production
30
+ * monitoring should alarm on `secrets-client.fetch_failed` metric counter.
31
+ */
32
+ import { logger } from './logger.js';
33
+ import { config } from './config.js';
34
+ /**
35
+ * Opaque envelope around the decrypted env map. The values inside MUST NOT
36
+ * be read except via `unwrap()`, and `unwrap()` is intended to be called
37
+ * exactly once at the child-process spawn site.
38
+ *
39
+ * Default JSON / toString render `[REDACTED]`. If you ever find yourself
40
+ * adding a new caller of `unwrap()`, treat it as a security-relevant
41
+ * change and ping DevGuru.
42
+ */
43
+ export class SpawnSecretsEnvelope {
44
+ #env;
45
+ constructor(env) {
46
+ this.#env = env;
47
+ }
48
+ /** Sole legitimate value access. Single call site = `claude-spawner.ts`. */
49
+ unwrap() {
50
+ return this.#env;
51
+ }
52
+ /** Number of secrets carried — safe for logging. */
53
+ size() {
54
+ return Object.keys(this.#env).length;
55
+ }
56
+ toString() {
57
+ return `[SpawnSecretsEnvelope size=${this.size()} REDACTED]`;
58
+ }
59
+ toJSON() {
60
+ return '[REDACTED]';
61
+ }
62
+ [Symbol.for('nodejs.util.inspect.custom')]() {
63
+ return this.toString();
64
+ }
65
+ }
66
+ /**
67
+ * Fetch secrets for a spawn. Returns an empty envelope (and empty
68
+ * reminders array) on any failure mode — spawn continues without
69
+ * secrets. The error is logged here so monitoring can alarm; do NOT
70
+ * pass error details into the spawn pipeline where they might surface
71
+ * to the user.
72
+ */
73
+ export async function fetchSecretsForSpawn(input) {
74
+ if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
75
+ logger.warn('secrets-client: TEAMVIBE_API_URL or TEAMVIBE_POLLER_TOKEN not set; skipping fetch');
76
+ return emptyResult();
77
+ }
78
+ // Upfront short-circuit: an empty pollerId would fail server-side
79
+ // validation and round-trip an HTTP error. Skip the call entirely so
80
+ // the warn log makes the cause obvious from poller stdout.
81
+ if (!input.pollerId) {
82
+ logger.warn('secrets-client: pollerId empty; skipping fetch (no secrets injected for spawn)');
83
+ return emptyResult();
84
+ }
85
+ // URL construction can throw on malformed TEAMVIBE_API_URL — must run
86
+ // inside the fail-soft envelope so a config typo doesn't crash the
87
+ // spawn pipeline. Wrapping here keeps the contract uniform with the
88
+ // fetch / parse error paths below.
89
+ let url;
90
+ try {
91
+ url = new URL('/secrets/spawn', config.TEAMVIBE_API_URL);
92
+ url.searchParams.set('pollerId', input.pollerId);
93
+ url.searchParams.set('workspaceId', input.workspaceId);
94
+ url.searchParams.set('channelId', input.channelId);
95
+ }
96
+ catch (err) {
97
+ logger.warn(`secrets-client.config_error: invalid TEAMVIBE_API_URL=${String(config.TEAMVIBE_API_URL)} ` +
98
+ `error=${err?.message ?? String(err)}`);
99
+ return emptyResult();
100
+ }
101
+ let response;
102
+ try {
103
+ response = await fetch(url.toString(), {
104
+ method: 'GET',
105
+ headers: {
106
+ Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
107
+ // Hint to any intermediary that this request returns sensitive
108
+ // data; the API itself sets `x-tv-no-log` on the response.
109
+ 'Cache-Control': 'no-store',
110
+ },
111
+ });
112
+ }
113
+ catch (err) {
114
+ logger.warn(`secrets-client.fetch_failed: network error pollerId=${input.pollerId} ` +
115
+ `workspaceId=${input.workspaceId} channelId=${input.channelId} ` +
116
+ `error=${err?.message ?? String(err)}`);
117
+ return emptyResult();
118
+ }
119
+ if (!response.ok) {
120
+ // Read body to surface a useful error log, but cap the size so a
121
+ // misconfigured API can't dump arbitrary content into our logs.
122
+ // Status + a small slice is sufficient diagnostics — no body
123
+ // pass-through.
124
+ let snippet = '';
125
+ try {
126
+ snippet = (await response.text()).slice(0, 200);
127
+ }
128
+ catch {
129
+ snippet = '<failed-to-read-body>';
130
+ }
131
+ logger.warn(`secrets-client.fetch_failed: status=${response.status} ` +
132
+ `pollerId=${input.pollerId} workspaceId=${input.workspaceId} ` +
133
+ `channelId=${input.channelId} bodySnippet=${snippet}`);
134
+ return emptyResult();
135
+ }
136
+ // The response is sensitive — parse it and immediately wrap. From this
137
+ // point on, the env map is only accessible via `.unwrap()`.
138
+ let parsed;
139
+ try {
140
+ parsed = (await response.json());
141
+ }
142
+ catch (err) {
143
+ logger.warn(`secrets-client.parse_failed: pollerId=${input.pollerId} ` +
144
+ `error=${err?.message ?? String(err)}`);
145
+ return emptyResult();
146
+ }
147
+ const env = {};
148
+ if (parsed.env && typeof parsed.env === 'object') {
149
+ for (const [k, v] of Object.entries(parsed.env)) {
150
+ if (typeof v === 'string')
151
+ env[k] = v;
152
+ }
153
+ }
154
+ const reminders = [];
155
+ if (Array.isArray(parsed.reminders)) {
156
+ for (const item of parsed.reminders) {
157
+ if (typeof item['name'] !== 'string' || typeof item['scope'] !== 'string')
158
+ continue;
159
+ reminders.push({
160
+ name: item['name'],
161
+ scope: item['scope'],
162
+ expiresAt: typeof item['expiresAt'] === 'string' ? item['expiresAt'] : undefined,
163
+ overrodeScopes: Array.isArray(item['overrodeScopes'])
164
+ ? item['overrodeScopes'].filter(s => typeof s === 'string')
165
+ : undefined,
166
+ });
167
+ }
168
+ }
169
+ // Log only the size — never names or values.
170
+ logger.info(`secrets-client: fetched ${Object.keys(env).length} secret(s) for spawn`);
171
+ return {
172
+ env: new SpawnSecretsEnvelope(env),
173
+ reminders,
174
+ };
175
+ }
176
+ function emptyResult() {
177
+ return { env: new SpawnSecretsEnvelope({}), reminders: [] };
178
+ }
179
+ /**
180
+ * Render the system-reminder text from reminder metadata. This function
181
+ * intentionally has NO access to the env map — its inputs are name +
182
+ * scope + expiration only. A future code-reader who needs to add a value
183
+ * to the reminder body must change the function signature, which is a
184
+ * loud signal to think twice.
185
+ */
186
+ export function buildSecretsReminder(reminders) {
187
+ if (reminders.length === 0)
188
+ return '';
189
+ const now = Date.now();
190
+ const lines = ['## Available Secrets'];
191
+ lines.push('The following ENV variables are loaded into this session:');
192
+ for (const r of reminders) {
193
+ let suffix = '';
194
+ if (r.expiresAt) {
195
+ const expMs = Date.parse(r.expiresAt);
196
+ if (Number.isFinite(expMs)) {
197
+ const daysLeft = Math.floor((expMs - now) / (24 * 60 * 60 * 1000));
198
+ if (daysLeft < 1) {
199
+ suffix = ` — **EXPIRES IN <1d** :warning: please rotate via \`set_secret\``;
200
+ }
201
+ else if (daysLeft < 7) {
202
+ suffix = ` (expires in ${daysLeft}d — please rotate via \`set_secret\`)`;
203
+ }
204
+ }
205
+ }
206
+ const overrideNote = r.overrodeScopes && r.overrodeScopes.length > 0
207
+ ? `, overrides ${r.overrodeScopes.join('+')}`
208
+ : '';
209
+ lines.push(`- \`${r.name}\` (${r.scope} scope${overrideNote})${suffix}`);
210
+ }
211
+ lines.push('');
212
+ lines.push('Access via `process.env.{NAME}` or shell `$NAME`.');
213
+ lines.push('Values are a snapshot taken at spawn time; long sessions may see stale values until the next spawn.');
214
+ return lines.join('\n');
215
+ }
package/dist/types.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface ControlMessage {
7
7
  export type QueueMessage = TeamVibeQueueMessage | ControlMessage;
8
8
  export interface TeamVibeQueueMessage {
9
9
  id: string;
10
- source: 'slack' | 'email' | 'cron' | 'heartbeat' | 'maintenance' | 'webhook';
10
+ source: 'slack' | 'email' | 'cron' | 'maintenance' | 'webhook';
11
11
  thread_id: string;
12
12
  sender: {
13
13
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "zod": "^3.22.0"
30
30
  },
31
31
  "devDependencies": {
32
+ "@types/bun": "^1.3.14",
32
33
  "@types/node": "^20.10.5",
33
34
  "tsx": "^4.7.0",
34
35
  "typescript": "^5.3.3"