@teamvibe/poller 0.4.3 → 0.5.0

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,6 +400,13 @@ 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
+ ...spawnSecrets.env.unwrap(),
326
410
  ...process.env,
327
411
  CLAUDECODE: undefined,
328
412
  PATH: envPath,
@@ -439,15 +523,14 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
439
523
  networkErrorStart = null;
440
524
  }
441
525
  });
442
- let timedOut = false;
443
526
  const timeoutMinutes = Math.round(config.CLAUDE_TIMEOUT_MS / 60000);
444
527
  const timeout = setTimeout(() => {
445
- timedOut = true;
446
528
  logger.error(`Claude Code process timed out after ${timeoutMinutes} minutes`);
447
529
  emitError('session', `Claude Code process timed out after ${timeoutMinutes} minutes`, {
448
530
  context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
449
531
  channelId: msg.teamvibe.channelId,
450
532
  });
533
+ killReasonByProc.set(proc, 'timeout');
451
534
  proc.kill('SIGTERM');
452
535
  setTimeout(() => proc.kill('SIGKILL'), 5000);
453
536
  }, config.CLAUDE_TIMEOUT_MS);
@@ -465,6 +548,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
465
548
  context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
466
549
  channelId: msg.teamvibe.channelId,
467
550
  });
551
+ killReasonByProc.set(proc, 'network');
468
552
  proc.kill('SIGTERM');
469
553
  setTimeout(() => proc.kill('SIGKILL'), 5000);
470
554
  return;
@@ -477,6 +561,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
477
561
  context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
478
562
  channelId: msg.teamvibe.channelId,
479
563
  });
564
+ killReasonByProc.set(proc, 'idle');
480
565
  proc.kill('SIGTERM');
481
566
  setTimeout(() => proc.kill('SIGKILL'), 5000);
482
567
  }
@@ -496,15 +581,26 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
496
581
  clearInterval(idleWatchdog);
497
582
  activeProcesses.delete(msg.id);
498
583
  activeProcessesByThread.delete(msg.thread_id);
584
+ const killReason = killReasonByProc.get(proc);
499
585
  const result = {
500
586
  success: code === 0,
501
587
  output: stdout,
502
588
  exitCode: code,
503
589
  metrics,
590
+ ...(killReason ? { killedByPoller: killReason } : {}),
504
591
  };
505
- if (timedOut) {
592
+ if (killReason === 'timeout') {
506
593
  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
594
  }
595
+ else if (killReason === 'idle') {
596
+ result.error = `Killed by idle watchdog (no output for >${Math.round(config.IDLE_WATCHDOG_KILL_MS / 60000)} minutes)`;
597
+ }
598
+ else if (killReason === 'network') {
599
+ result.error = 'Killed by network watchdog (sustained network errors)';
600
+ }
601
+ else if (killReason === 'zombie') {
602
+ result.error = 'Killed as zombie process (stale session lock)';
603
+ }
508
604
  else if (code !== 0) {
509
605
  result.error = stderr || `Process exited with code ${code}`;
510
606
  }
@@ -532,7 +628,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
532
628
  * Sources whose sessions should run in an isolated git worktree (teamvibe.ai#120).
533
629
  * Foreground Slack messages always run in the main working dir.
534
630
  */
535
- const ISOLATED_SOURCES = new Set(['cron', 'heartbeat', 'maintenance']);
631
+ const ISOLATED_SOURCES = new Set(['cron', 'maintenance']);
536
632
  function shouldIsolateInWorktree(msg) {
537
633
  return isWorktreeIsolationEnabled() && ISOLATED_SOURCES.has(msg.source);
538
634
  }
@@ -603,6 +699,7 @@ export function killZombieForThread(threadId) {
603
699
  }
604
700
  logger.warn(`Killing zombie process for thread ${threadId} (pid: ${proc.pid})`);
605
701
  emitError('session', `Killing zombie process for thread ${threadId}`, { severity: 'warn' });
702
+ killReasonByProc.set(proc, 'zombie');
606
703
  proc.kill('SIGTERM');
607
704
  setTimeout(() => {
608
705
  if (proc.exitCode === null) {
package/dist/config.d.ts CHANGED
@@ -28,8 +28,7 @@ declare const configSchema: z.ZodObject<{
28
28
  BASE_BRAIN_PATH: z.ZodDefault<z.ZodString>;
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
- POLLER_TZ: z.ZodDefault<z.ZodString>;
32
- HEARTBEAT_CRON: z.ZodDefault<z.ZodString>;
31
+ POLLER_TZ: z.ZodOptional<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,8 +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
- POLLER_TZ: string;
63
- HEARTBEAT_CRON: string;
64
61
  MAINTENANCE_CRON: string;
65
62
  MAINTENANCE_ENABLED: boolean;
66
63
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
@@ -68,6 +65,7 @@ declare const configSchema: z.ZodObject<{
68
65
  SQS_QUEUE_URL?: string | undefined;
69
66
  SESSIONS_TABLE?: string | undefined;
70
67
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
68
+ POLLER_TZ?: string | undefined;
71
69
  }, {
72
70
  AWS_REGION?: string | undefined;
73
71
  SQS_QUEUE_URL?: string | undefined;
@@ -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,8 +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
- POLLER_TZ: string;
134
- HEARTBEAT_CRON: string;
135
130
  MAINTENANCE_CRON: string;
136
131
  MAINTENANCE_ENABLED: boolean;
137
132
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
@@ -139,5 +134,6 @@ export declare const config: {
139
134
  SQS_QUEUE_URL?: string | undefined;
140
135
  SESSIONS_TABLE?: string | undefined;
141
136
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
137
+ POLLER_TZ?: string | undefined;
142
138
  };
143
139
  export {};
package/dist/config.js CHANGED
@@ -43,14 +43,14 @@ 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: IANA timezone used to interpret HEARTBEAT_CRON & MAINTENANCE_CRON.
48
- // Default Europe/Prague — current fleet is CZ-based; ensures `30 3 * * *`
49
- // means "03:30 local night," not "03:30 UTC = 05:30 CEST." DST is handled
50
- // by croner automatically. Override with `POLLER_TZ=UTC` (or any IANA name).
51
- POLLER_TZ: z.string().default('Europe/Prague'),
52
- HEARTBEAT_CRON: z.string().default('0 */6 * * *'), // Every 6 hours
53
- MAINTENANCE_CRON: z.string().default('30 3 * * *'), // Daily at 03:30 in POLLER_TZ
46
+ // Brain maintenance (cron-based)
47
+ // POLLER_TZ: optional IANA timezone for MAINTENANCE_CRON.
48
+ // When unset, croner uses the host system timezone (e.g. `Europe/Prague` on
49
+ // a CZ Mac mini, `UTC` on a default Railway container). Set explicitly
50
+ // (e.g. `POLLER_TZ=Europe/Prague` on Railway) when the host TZ doesn't match
51
+ // the intended cron interpretation. DST is handled by croner automatically.
52
+ POLLER_TZ: z.string().optional(),
53
+ MAINTENANCE_CRON: z.string().default('30 3 * * *'), // Daily at 03:30 in POLLER_TZ (or host TZ)
54
54
  MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
55
55
  // Auto-update settings
56
56
  AUTO_UPDATE_POLICY: z.enum(['patch', 'minor', 'off']).default('patch'),
@@ -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;