@teamvibe/poller 0.6.2 → 0.7.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.
package/dist/config.d.ts CHANGED
@@ -15,6 +15,7 @@ declare const configSchema: z.ZodObject<{
15
15
  LOCK_RETRY_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
16
16
  IDLE_WATCHDOG_WARN_MS: z.ZodDefault<z.ZodNumber>;
17
17
  IDLE_WATCHDOG_KILL_MS: z.ZodDefault<z.ZodNumber>;
18
+ IDLE_WATCHDOG_COMPACTION_GRACE_MS: z.ZodDefault<z.ZodNumber>;
18
19
  NETWORK_ERROR_KILL_MS: z.ZodDefault<z.ZodNumber>;
19
20
  MAX_CONSECUTIVE_NET_FAILURES: z.ZodDefault<z.ZodNumber>;
20
21
  TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
@@ -28,6 +29,11 @@ declare const configSchema: z.ZodObject<{
28
29
  BASE_BRAIN_PATH: z.ZodDefault<z.ZodString>;
29
30
  BRAIN_AUTO_UPDATE: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
30
31
  BRAIN_UPDATE_COOLDOWN_MS: z.ZodDefault<z.ZodNumber>;
32
+ BRAIN_MAX_BLOB_BYTES: z.ZodDefault<z.ZodNumber>;
33
+ BRAIN_PUSH_RETRY_MAX: z.ZodDefault<z.ZodNumber>;
34
+ BRAIN_PUSH_RETRY_BACKOFF_MS: z.ZodDefault<z.ZodNumber>;
35
+ GIT_LOCK_MAX_AGE_MS: z.ZodDefault<z.ZodNumber>;
36
+ GIT_SUBMODULE_LOCK_MAX_AGE_MS: z.ZodDefault<z.ZodNumber>;
31
37
  POLLER_TZ: z.ZodOptional<z.ZodString>;
32
38
  MAINTENANCE_CRON: z.ZodDefault<z.ZodString>;
33
39
  MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
@@ -47,6 +53,7 @@ declare const configSchema: z.ZodObject<{
47
53
  LOCK_RETRY_INTERVAL_MS: number;
48
54
  IDLE_WATCHDOG_WARN_MS: number;
49
55
  IDLE_WATCHDOG_KILL_MS: number;
56
+ IDLE_WATCHDOG_COMPACTION_GRACE_MS: number;
50
57
  NETWORK_ERROR_KILL_MS: number;
51
58
  MAX_CONSECUTIVE_NET_FAILURES: number;
52
59
  TEAMVIBE_DATA_DIR: string;
@@ -60,6 +67,11 @@ declare const configSchema: z.ZodObject<{
60
67
  BASE_BRAIN_PATH: string;
61
68
  BRAIN_AUTO_UPDATE: boolean;
62
69
  BRAIN_UPDATE_COOLDOWN_MS: number;
70
+ BRAIN_MAX_BLOB_BYTES: number;
71
+ BRAIN_PUSH_RETRY_MAX: number;
72
+ BRAIN_PUSH_RETRY_BACKOFF_MS: number;
73
+ GIT_LOCK_MAX_AGE_MS: number;
74
+ GIT_SUBMODULE_LOCK_MAX_AGE_MS: number;
63
75
  MAINTENANCE_CRON: string;
64
76
  MAINTENANCE_ENABLED: boolean;
65
77
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
@@ -85,6 +97,7 @@ declare const configSchema: z.ZodObject<{
85
97
  LOCK_RETRY_INTERVAL_MS?: number | undefined;
86
98
  IDLE_WATCHDOG_WARN_MS?: number | undefined;
87
99
  IDLE_WATCHDOG_KILL_MS?: number | undefined;
100
+ IDLE_WATCHDOG_COMPACTION_GRACE_MS?: number | undefined;
88
101
  NETWORK_ERROR_KILL_MS?: number | undefined;
89
102
  MAX_CONSECUTIVE_NET_FAILURES?: number | undefined;
90
103
  TEAMVIBE_DATA_DIR?: string | undefined;
@@ -98,6 +111,11 @@ declare const configSchema: z.ZodObject<{
98
111
  BASE_BRAIN_PATH?: string | undefined;
99
112
  BRAIN_AUTO_UPDATE?: string | undefined;
100
113
  BRAIN_UPDATE_COOLDOWN_MS?: number | undefined;
114
+ BRAIN_MAX_BLOB_BYTES?: number | undefined;
115
+ BRAIN_PUSH_RETRY_MAX?: number | undefined;
116
+ BRAIN_PUSH_RETRY_BACKOFF_MS?: number | undefined;
117
+ GIT_LOCK_MAX_AGE_MS?: number | undefined;
118
+ GIT_SUBMODULE_LOCK_MAX_AGE_MS?: number | undefined;
101
119
  POLLER_TZ?: string | undefined;
102
120
  MAINTENANCE_CRON?: string | undefined;
103
121
  MAINTENANCE_ENABLED?: string | undefined;
@@ -120,6 +138,7 @@ export declare const config: {
120
138
  LOCK_RETRY_INTERVAL_MS: number;
121
139
  IDLE_WATCHDOG_WARN_MS: number;
122
140
  IDLE_WATCHDOG_KILL_MS: number;
141
+ IDLE_WATCHDOG_COMPACTION_GRACE_MS: number;
123
142
  NETWORK_ERROR_KILL_MS: number;
124
143
  MAX_CONSECUTIVE_NET_FAILURES: number;
125
144
  TEAMVIBE_DATA_DIR: string;
@@ -133,6 +152,11 @@ export declare const config: {
133
152
  BASE_BRAIN_PATH: string;
134
153
  BRAIN_AUTO_UPDATE: boolean;
135
154
  BRAIN_UPDATE_COOLDOWN_MS: number;
155
+ BRAIN_MAX_BLOB_BYTES: number;
156
+ BRAIN_PUSH_RETRY_MAX: number;
157
+ BRAIN_PUSH_RETRY_BACKOFF_MS: number;
158
+ GIT_LOCK_MAX_AGE_MS: number;
159
+ GIT_SUBMODULE_LOCK_MAX_AGE_MS: number;
136
160
  MAINTENANCE_CRON: string;
137
161
  MAINTENANCE_ENABLED: boolean;
138
162
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
package/dist/config.js CHANGED
@@ -20,10 +20,11 @@ const configSchema = z.object({
20
20
  VISIBILITY_TIMEOUT_SECONDS: z.coerce.number().default(60),
21
21
  HEARTBEAT_INTERVAL_MS: z.coerce.number().default(40000), // 40 seconds
22
22
  CLAUDE_TIMEOUT_MS: z.coerce.number().default(3600000), // 60 minutes
23
- STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(900000), // 15 minutes (above idle watchdog kill time of 10 min)
23
+ STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(1800000), // 30 minutes (comfortable margin above the ~15min worst-case compaction kill deadline below)
24
24
  LOCK_RETRY_INTERVAL_MS: z.coerce.number().default(30000), // 30 seconds
25
25
  IDLE_WATCHDOG_WARN_MS: z.coerce.number().default(300000), // 5 minutes
26
- IDLE_WATCHDOG_KILL_MS: z.coerce.number().default(600000), // 10 minutes
26
+ IDLE_WATCHDOG_KILL_MS: z.coerce.number().default(600000), // 10 minutes (default; on a compact_boundary event, becomes max(this-from-silence-start, event-time + IDLE_WATCHDOG_COMPACTION_GRACE_MS) — tv#32)
27
+ IDLE_WATCHDOG_COMPACTION_GRACE_MS: z.coerce.number().default(300000), // 5 minutes — fixed grace from compact_boundary arrival, not a large speculative ceiling (compact_boundary only proves compaction STARTED, never that it finishes in bounded time)
27
28
  NETWORK_ERROR_KILL_MS: z.coerce.number().default(120000), // Per-session: kill stuck session after 2 min of network errors
28
29
  MAX_CONSECUTIVE_NET_FAILURES: z.coerce.number().default(5), // Poll loop: DNS flush at N, exit(1) at 2×N consecutive failures
29
30
  // Paths
@@ -43,6 +44,18 @@ const configSchema = z.object({
43
44
  .default('true')
44
45
  .transform((v) => v.toLowerCase() === 'true'),
45
46
  BRAIN_UPDATE_COOLDOWN_MS: z.coerce.number().default(300000), // 5 minutes
47
+ // Push guard: refuse to push unpushed commits that carry a blob over this size.
48
+ // Prevents a single oversized scratch file (e.g. agent .local/tmp/ writes) from
49
+ // getting permanently rejected by GitHub's pre-receive hook (tv#246).
50
+ BRAIN_MAX_BLOB_BYTES: z.coerce.number().default(50 * 1024 * 1024), // 50 MB
51
+ // Brain push-race retry (tv#232): fetch+rebase+retry on non-fast-forward push rejection
52
+ BRAIN_PUSH_RETRY_MAX: z.coerce.number().default(3),
53
+ BRAIN_PUSH_RETRY_BACKOFF_MS: z.coerce.number().default(500), // linear backoff: backoffMs * attempt
54
+ // Stale git lock cleanup (tv#76): orphaned locks from killed processes
55
+ // (e.g. the 2026-05-01 outage) block all future brain updates indefinitely
56
+ // unless removed. Distinct from STALE_LOCK_TIMEOUT_MS above (DDB session lock).
57
+ GIT_LOCK_MAX_AGE_MS: z.coerce.number().default(120000), // 2 minutes — .git/index.lock
58
+ GIT_SUBMODULE_LOCK_MAX_AGE_MS: z.coerce.number().default(300000), // 5 minutes — .git/modules/*/config.lock
46
59
  // Brain maintenance (cron-based)
47
60
  // POLLER_TZ: optional IANA timezone for MAINTENANCE_CRON.
48
61
  // When unset, croner uses the host system timezone (e.g. `Europe/Prague` on
@@ -12,11 +12,14 @@
12
12
  */
13
13
  export declare function markMaintenanceJobFired(at?: number): void;
14
14
  export declare function markMaintenanceJobSuccess(at?: number): void;
15
+ export declare function markMaintenanceJobSkipped(at?: number): void;
15
16
  export declare function markAutoUpdateJobFired(at?: number): void;
16
17
  export declare function markAutoUpdateJobSuccess(at?: number): void;
17
18
  export interface CronJobTimestamps {
18
19
  lastMaintenanceJobFiredAt: number | null;
19
20
  lastMaintenanceJobSuccessAt: number | null;
21
+ lastMaintenanceJobSkippedAt: number | null;
22
+ maintenanceSkippedCountLastRun: number;
20
23
  lastAutoUpdateJobFiredAt: number | null;
21
24
  lastAutoUpdateJobSuccessAt: number | null;
22
25
  }
@@ -14,12 +14,24 @@ let lastMaintenanceJobFiredAt = null;
14
14
  let lastMaintenanceJobSuccessAt = null;
15
15
  let lastAutoUpdateJobFiredAt = null;
16
16
  let lastAutoUpdateJobSuccessAt = null;
17
+ // teamvibeai/teamvibe.ai#131 — activity-based skip observability. Count
18
+ // resets on each markMaintenanceJobFired() (i.e. once per cron run), so it
19
+ // reflects "brains skipped in the most recently started run", not a
20
+ // lifetime total. Without this, a `shouldSkipMaintenance` edge-case bug
21
+ // that over-skips would be invisible short of manual per-brain digging.
22
+ let lastMaintenanceJobSkippedAt = null;
23
+ let maintenanceSkippedCountLastRun = 0;
17
24
  export function markMaintenanceJobFired(at = Date.now()) {
18
25
  lastMaintenanceJobFiredAt = at;
26
+ maintenanceSkippedCountLastRun = 0;
19
27
  }
20
28
  export function markMaintenanceJobSuccess(at = Date.now()) {
21
29
  lastMaintenanceJobSuccessAt = at;
22
30
  }
31
+ export function markMaintenanceJobSkipped(at = Date.now()) {
32
+ lastMaintenanceJobSkippedAt = at;
33
+ maintenanceSkippedCountLastRun += 1;
34
+ }
23
35
  export function markAutoUpdateJobFired(at = Date.now()) {
24
36
  lastAutoUpdateJobFiredAt = at;
25
37
  }
@@ -30,6 +42,8 @@ export function getCronJobTimestamps() {
30
42
  return {
31
43
  lastMaintenanceJobFiredAt,
32
44
  lastMaintenanceJobSuccessAt,
45
+ lastMaintenanceJobSkippedAt,
46
+ maintenanceSkippedCountLastRun,
33
47
  lastAutoUpdateJobFiredAt,
34
48
  lastAutoUpdateJobSuccessAt,
35
49
  };
@@ -41,6 +55,8 @@ export function getCronJobTimestamps() {
41
55
  export function __resetCronTelemetryForTest() {
42
56
  lastMaintenanceJobFiredAt = null;
43
57
  lastMaintenanceJobSuccessAt = null;
58
+ lastMaintenanceJobSkippedAt = null;
59
+ maintenanceSkippedCountLastRun = 0;
44
60
  lastAutoUpdateJobFiredAt = null;
45
61
  lastAutoUpdateJobSuccessAt = null;
46
62
  }
@@ -1,5 +1,32 @@
1
1
  export type ErrorCategory = 'sqs' | 'brain' | 'auth' | 'session' | 'slack' | 'maintenance';
2
2
  export type ErrorSeverity = 'warn' | 'error';
3
+ interface ErrorEvent {
4
+ eventType: 'poller.error';
5
+ channelId: string;
6
+ metadata: {
7
+ category: ErrorCategory;
8
+ severity: ErrorSeverity;
9
+ message: string;
10
+ count: number;
11
+ context?: Record<string, unknown>;
12
+ };
13
+ error: {
14
+ code: string;
15
+ message: string;
16
+ };
17
+ timestamp: string;
18
+ }
19
+ interface GenericEvent {
20
+ eventType: string;
21
+ channelId: string;
22
+ metadata?: Record<string, unknown>;
23
+ error?: {
24
+ code: string;
25
+ message: string;
26
+ };
27
+ timestamp: string;
28
+ }
29
+ type QueuedEvent = ErrorEvent | GenericEvent;
3
30
  export declare function emitEvent(eventType: string, channelId: string, metadata?: Record<string, unknown>): void;
4
31
  export declare function flush(): Promise<void>;
5
32
  export declare function startEventQueue(): void;
@@ -11,5 +38,8 @@ export declare function emitError(category: ErrorCategory, message: string, opti
11
38
  }): void;
12
39
  /** For testing: get current queue length */
13
40
  export declare function getQueueLength(): number;
41
+ /** For testing: inspect queued events (e.g. assert on errorCode/severity) without flushing */
42
+ export declare function peekQueue(): QueuedEvent[];
14
43
  /** For testing: clear the queue */
15
44
  export declare function clearQueue(): void;
45
+ export {};
@@ -124,6 +124,10 @@ export function emitError(category, message, options) {
124
124
  export function getQueueLength() {
125
125
  return queue.length;
126
126
  }
127
+ /** For testing: inspect queued events (e.g. assert on errorCode/severity) without flushing */
128
+ export function peekQueue() {
129
+ return [...queue];
130
+ }
127
131
  /** For testing: clear the queue */
128
132
  export function clearQueue() {
129
133
  queue = [];
@@ -20,6 +20,31 @@ export declare function markFailed(brainPath: string, threadId: string, filename
20
20
  * Delete a message file from processing/ (marks it as done).
21
21
  */
22
22
  export declare function markProcessed(brainPath: string, threadId: string, filename: string): void;
23
+ /**
24
+ * Move a message from processing/ back to new/.
25
+ *
26
+ * Used specifically for a poller-initiated *shutdown* kill (teamvibe.ai#73,
27
+ * PR#308 round-5): pickNextMessage() above moves a file to processing/ as
28
+ * soon as it's picked up, not once it completes, so a message killed
29
+ * mid-flight by shutdown() is left sitting in processing/. The inbox
30
+ * watcher's wake condition (hasNewMessage(), inbox-watcher.ts) only scans
31
+ * new/ — a thread whose only pending message is in processing/ has nothing
32
+ * to start a fresh runInboxLoop() for it on the next boot, so it could sit
33
+ * stuck indefinitely. Moving it back to new/ here is what makes the
34
+ * shutdown-kill notice text ("your message is saved and will resume
35
+ * automatically", classify-failed-iteration.ts) literally true.
36
+ *
37
+ * Deliberately NOT used for timeout/idle/network/zombie kills — those retry
38
+ * immediately within the same still-running loop iteration (see
39
+ * isPollerInitiatedFailure() / the loop condition in poller.ts), so the
40
+ * existing processing/ crash-recovery branch in pickNextMessage() already
41
+ * covers them; requeueing those too would be redundant.
42
+ *
43
+ * Tolerant of the source file already being gone (e.g. a concurrent
44
+ * markProcessed/markFailed pass, or this being invoked twice for the same
45
+ * iteration) — that's a no-op, not an error.
46
+ */
47
+ export declare function requeueMessage(brainPath: string, threadId: string, filename: string): void;
23
48
  /**
24
49
  * Check if there are unprocessed messages in new/ or processing/.
25
50
  */
@@ -93,6 +93,53 @@ export function markProcessed(brainPath, threadId, filename) {
93
93
  logger.warn(`[Inbox] Failed to delete ${filename}: ${error}`);
94
94
  }
95
95
  }
96
+ /**
97
+ * Move a message from processing/ back to new/.
98
+ *
99
+ * Used specifically for a poller-initiated *shutdown* kill (teamvibe.ai#73,
100
+ * PR#308 round-5): pickNextMessage() above moves a file to processing/ as
101
+ * soon as it's picked up, not once it completes, so a message killed
102
+ * mid-flight by shutdown() is left sitting in processing/. The inbox
103
+ * watcher's wake condition (hasNewMessage(), inbox-watcher.ts) only scans
104
+ * new/ — a thread whose only pending message is in processing/ has nothing
105
+ * to start a fresh runInboxLoop() for it on the next boot, so it could sit
106
+ * stuck indefinitely. Moving it back to new/ here is what makes the
107
+ * shutdown-kill notice text ("your message is saved and will resume
108
+ * automatically", classify-failed-iteration.ts) literally true.
109
+ *
110
+ * Deliberately NOT used for timeout/idle/network/zombie kills — those retry
111
+ * immediately within the same still-running loop iteration (see
112
+ * isPollerInitiatedFailure() / the loop condition in poller.ts), so the
113
+ * existing processing/ crash-recovery branch in pickNextMessage() already
114
+ * covers them; requeueing those too would be redundant.
115
+ *
116
+ * Tolerant of the source file already being gone (e.g. a concurrent
117
+ * markProcessed/markFailed pass, or this being invoked twice for the same
118
+ * iteration) — that's a no-op, not an error.
119
+ */
120
+ export function requeueMessage(brainPath, threadId, filename) {
121
+ const inbox = getInboxPath(brainPath, threadId);
122
+ const srcPath = join(inbox, PROCESSING_DIR, filename);
123
+ const newDir = join(inbox, NEW_DIR);
124
+ mkdirSync(newDir, { recursive: true });
125
+ const dstPath = join(newDir, filename);
126
+ try {
127
+ if (existsSync(srcPath)) {
128
+ renameSync(srcPath, dstPath);
129
+ logger.info(`[Inbox] Requeued shutdown-killed message to new/: ${filename}`);
130
+ }
131
+ else {
132
+ logger.info(`[Inbox] Requeue skipped, ${filename} already gone from processing/`);
133
+ }
134
+ }
135
+ catch (error) {
136
+ // e.g. ENOENT from a race between the existsSync check above and the
137
+ // rename itself — same tolerance as the existsSync guard, just covering
138
+ // the narrower window. Log and continue; never let a requeue failure
139
+ // crash the shutdown path.
140
+ logger.warn(`[Inbox] Failed to requeue ${filename} to new/: ${error}`);
141
+ }
142
+ }
96
143
  /**
97
144
  * Check if there are unprocessed messages in new/ or processing/.
98
145
  */
@@ -0,0 +1,18 @@
1
+ export interface ActivityCheckDeps {
2
+ statFn: (path: string) => Promise<{
3
+ mtimeMs: number;
4
+ }>;
5
+ }
6
+ /**
7
+ * Poller-side half of teamvibe.ai#131: skip the nightly maintenance trigger
8
+ * for a brain that has had zero real (non-maintenance-sourced) activity
9
+ * since its last maintenance run, so a quiet brain doesn't pay for a Claude
10
+ * spawn just to no-op inside the session's own time-debounce guard.
11
+ *
12
+ * `lastRealMessageAt` is set by registerBrain() (maintenance-cron.ts) on
13
+ * every non-maintenance message. It is undefined when the poller hasn't
14
+ * seen one since its own last restart (trackedBrains is in-memory, #127) —
15
+ * in that case we fail open (never skip) rather than risk starving a brain
16
+ * of maintenance because of a poller restart, not actual inactivity.
17
+ */
18
+ export declare function shouldSkipMaintenance(brainsPath: string, brainId: string, lastRealMessageAt: number | undefined, deps?: ActivityCheckDeps): Promise<boolean>;
@@ -0,0 +1,28 @@
1
+ import { stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ const defaultDeps = { statFn: stat };
4
+ /**
5
+ * Poller-side half of teamvibe.ai#131: skip the nightly maintenance trigger
6
+ * for a brain that has had zero real (non-maintenance-sourced) activity
7
+ * since its last maintenance run, so a quiet brain doesn't pay for a Claude
8
+ * spawn just to no-op inside the session's own time-debounce guard.
9
+ *
10
+ * `lastRealMessageAt` is set by registerBrain() (maintenance-cron.ts) on
11
+ * every non-maintenance message. It is undefined when the poller hasn't
12
+ * seen one since its own last restart (trackedBrains is in-memory, #127) —
13
+ * in that case we fail open (never skip) rather than risk starving a brain
14
+ * of maintenance because of a poller restart, not actual inactivity.
15
+ */
16
+ export async function shouldSkipMaintenance(brainsPath, brainId, lastRealMessageAt, deps = defaultDeps) {
17
+ if (lastRealMessageAt === undefined)
18
+ return false;
19
+ try {
20
+ const guardPath = join(brainsPath, brainId, 'memory/.last_consolidation');
21
+ const s = await deps.statFn(guardPath);
22
+ return lastRealMessageAt < s.mtimeMs;
23
+ }
24
+ catch {
25
+ // No guard file yet (brand-new brain, never consolidated) — never skip.
26
+ return false;
27
+ }
28
+ }
@@ -7,6 +7,7 @@ export interface TrackedBrainInfo {
7
7
  botId: string;
8
8
  botToken: string;
9
9
  channelId: string;
10
+ lastRealMessageAt?: number;
10
11
  }
11
12
  /**
12
13
  * Read-only view of brains seen so far (via registerBrain). Used by the inbox
@@ -14,6 +15,12 @@ export interface TrackedBrainInfo {
14
15
  * workspace/bot credentials to use when starting a session for an idle thread.
15
16
  */
16
17
  export declare function getTrackedBrains(): ReadonlyMap<string, TrackedBrainInfo>;
18
+ /**
19
+ * Test-only helper to clear tracked brains between tests (module-scoped
20
+ * state persists across tests in the same file otherwise).
21
+ * @internal
22
+ */
23
+ export declare function __resetTrackedBrainsForTest(): void;
17
24
  /**
18
25
  * Register a brain from a real message so the maintenance cron knows about it.
19
26
  * Called from processMessage() after brain path is resolved.
@@ -6,7 +6,8 @@ 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 { markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
9
+ import { markMaintenanceJobFired, markMaintenanceJobSuccess, markMaintenanceJobSkipped, } from './cron-telemetry.js';
10
+ import { shouldSkipMaintenance } from './maintenance-activity.js';
10
11
  import { join } from 'path';
11
12
  const trackedBrains = new Map();
12
13
  /**
@@ -17,6 +18,14 @@ const trackedBrains = new Map();
17
18
  export function getTrackedBrains() {
18
19
  return trackedBrains;
19
20
  }
21
+ /**
22
+ * Test-only helper to clear tracked brains between tests (module-scoped
23
+ * state persists across tests in the same file otherwise).
24
+ * @internal
25
+ */
26
+ export function __resetTrackedBrainsForTest() {
27
+ trackedBrains.clear();
28
+ }
20
29
  let maintenanceJob = null;
21
30
  /**
22
31
  * Register a brain from a real message so the maintenance cron knows about it.
@@ -38,6 +47,7 @@ export function registerBrain(msg) {
38
47
  botId: msg.teamvibe.botId,
39
48
  botToken: msg.teamvibe.botToken,
40
49
  channelId: msg.teamvibe.channelId,
50
+ lastRealMessageAt: Date.now(),
41
51
  });
42
52
  logger.debug(`[Maintenance] Registered brain: ${brain.brainId}`);
43
53
  // Metrics pipeline (teamvibe.ai#110): register for hourly collection. On the
@@ -68,6 +78,11 @@ async function triggerAllBrains(processMessageFn) {
68
78
  logger.debug(`[Maintenance] At capacity, skipping ${brainId}`);
69
79
  continue;
70
80
  }
81
+ if (await shouldSkipMaintenance(config.BRAINS_PATH, brainId, info.lastRealMessageAt)) {
82
+ markMaintenanceJobSkipped();
83
+ logger.debug(`[Maintenance] Skipping ${brainId} — no real activity since last maintenance (#131)`);
84
+ continue;
85
+ }
71
86
  logger.info(`[Maintenance] Triggering maintenance for brain ${brainId}`);
72
87
  const syntheticMessage = {
73
88
  id: `maintenance_${brainId}_${now}`,
@@ -21,6 +21,8 @@ export interface MetricsPayload {
21
21
  daysSinceMaintenance?: number;
22
22
  lastMaintenanceJobMs?: number;
23
23
  lastMaintenanceJobSuccessMs?: number;
24
+ maintenanceSkippedCountLastRun?: number;
25
+ lastMaintenanceJobSkippedMs?: number;
24
26
  lastAutoUpdateJobMs?: number;
25
27
  lastAutoUpdateJobSuccessMs?: number;
26
28
  timestamp?: string;
@@ -228,6 +228,13 @@ export async function collectMetrics(brainPath, brainId) {
228
228
  if (cron.lastMaintenanceJobSuccessAt !== null) {
229
229
  payload.lastMaintenanceJobSuccessMs = now - cron.lastMaintenanceJobSuccessAt;
230
230
  }
231
+ // #131 — activity-based skip counter. Always sent (0 is a real "no skips
232
+ // this run" value, not an omission) so a stuck-high count is visible
233
+ // fleet-wide without per-brain digging.
234
+ payload.maintenanceSkippedCountLastRun = cron.maintenanceSkippedCountLastRun;
235
+ if (cron.lastMaintenanceJobSkippedAt !== null) {
236
+ payload.lastMaintenanceJobSkippedMs = now - cron.lastMaintenanceJobSkippedAt;
237
+ }
231
238
  if (cron.lastAutoUpdateJobFiredAt !== null) {
232
239
  payload.lastAutoUpdateJobMs = now - cron.lastAutoUpdateJobFiredAt;
233
240
  }
package/dist/poller.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { logger } from './logger.js';
2
+ import { type SpawnResult } from './claude-spawner.js';
3
+ import { type KilledByPoller } from './classify-failed-iteration.js';
2
4
  import type { TeamVibeQueueMessage } from './types.js';
3
5
  interface ReleaseLogger {
4
6
  info: (msg: string) => void;
@@ -22,6 +24,13 @@ export declare function runAutoUpdateJob(checkForUpdateFn?: () => Promise<boolea
22
24
  * will pick up any new file on its own.
23
25
  */
24
26
  export declare function isInboxLoopActive(threadId: string): boolean;
27
+ export declare function isIntentionalKillExitCode(exitCode: number | null): boolean;
28
+ export declare function isPollerInitiatedFailure(result: {
29
+ exitCode: number | null;
30
+ killedByPoller?: KilledByPoller;
31
+ }): boolean;
32
+ export declare function endOfTurnFallbackText(hasSlackContext: boolean, result: Pick<SpawnResult, 'success' | 'sawSendMessageCall' | 'lastPlainTextContent'>): string | undefined;
25
33
  export declare function runInboxLoop(threadId: string, kbPath: string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession>): Promise<void>;
34
+ export declare function __setShuttingDownForTest(value: boolean): void;
26
35
  export declare function startPoller(): Promise<void>;
27
36
  export {};