@teamvibe/poller 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ import { appendFileSync, readdirSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { config } from './config.js';
4
+ import { logger } from './logger.js';
5
+ import { getActiveProcessCount, getLastSessionEndTs } from './claude-spawner.js';
6
+ import { emitEvent } from './event-queue.js';
7
+ // tv#317: self-restart once genuinely idle, to let Railway reclaim page-cache RAM that
8
+ // Linux never proactively frees under our large memory limit. See config.ts for the
9
+ // IDLE_RESTART_* thresholds this checks against.
10
+ const processStartTime = Date.now();
11
+ // cgroup v2 only. Railway containers run cgroup v2; on anything else (local dev, a
12
+ // future cgroup v1 host) this just returns null and the gate never fires — the memory
13
+ // pressure this exists to relieve is Railway-specific, so failing closed is correct
14
+ // rather than guessing at a v1 path/format.
15
+ const MEMORY_STAT_PATH = '/sys/fs/cgroup/memory.stat';
16
+ export function readReclaimableFileBytes(path = MEMORY_STAT_PATH) {
17
+ let text;
18
+ try {
19
+ text = readFileSync(path, 'utf-8');
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ for (const line of text.split('\n')) {
25
+ const spaceIdx = line.indexOf(' ');
26
+ if (spaceIdx > 0 && line.slice(0, spaceIdx) === 'file') {
27
+ const value = Number(line.slice(spaceIdx + 1));
28
+ return Number.isFinite(value) ? value : null;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+ // --- bg-task liveness ---------------------------------------------------------------
34
+ //
35
+ // Reimplemented (not imported): bg-task.mjs lives in poller-brain and runs inside a
36
+ // Claude session, not the poller process — there's no shared module to import here.
37
+ // This mirrors its parseStatus/lifecycleOf classification (poller-brain
38
+ // skills/background-task/scripts/bg-task.mjs) rather than a raw /proc scan, which
39
+ // false-negatives on interpreter/path variations and can't distinguish "stuck past TTL"
40
+ // from "healthy" the way the status file's own started/ttl fields can.
41
+ const ABANDON_GRACE_SEC = 60; // mirrors bg-task.mjs's ABANDON_GRACE_SEC
42
+ function parseStatus(text) {
43
+ const kv = {};
44
+ for (const line of text.split('\n')) {
45
+ const i = line.indexOf('=');
46
+ if (i > 0)
47
+ kv[line.slice(0, i)] = line.slice(i + 1);
48
+ }
49
+ return kv;
50
+ }
51
+ function pidAlive(pid) {
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ // Same four-way classification as bg-task.mjs's lifecycleOf, minus the tests-only
61
+ // `alive` injection point (this module has its own dedicated tests instead).
62
+ function lifecycleOf(kv, now) {
63
+ // Trust the runner's own claim, whatever it says (bg-task.mjs:139) — not just the two
64
+ // values the runner writes today (`finished`/`timed-out`, bg-task-runner.mjs:403).
65
+ // Mirroring the check itself, not just its current output, means the two
66
+ // implementations of this format can't quietly diverge if the runner ever starts
67
+ // writing a state value this list doesn't happen to enumerate — hardcoding a closed
68
+ // set here would silently fall through to the pid/TTL heuristics below instead.
69
+ if (kv.state)
70
+ return kv.state;
71
+ if (kv.runner_crashed)
72
+ return 'crashed';
73
+ if (Object.keys(kv).length === 0)
74
+ return 'unknown';
75
+ const pid = Number(kv.pid);
76
+ if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid))
77
+ return 'abandoned';
78
+ const started = kv.started ? Date.parse(kv.started) : NaN;
79
+ const ttl = Number(String(kv.ttl ?? '').replace(/s$/, ''));
80
+ if (Number.isFinite(started) && Number.isFinite(ttl) && now - started > (ttl + ABANDON_GRACE_SEC) * 1000) {
81
+ return 'abandoned';
82
+ }
83
+ return 'running';
84
+ }
85
+ function bgTaskRoot() {
86
+ // Mirrors bg-task.mjs's taskRoot() default exactly, minus the per-channel
87
+ // namespace — the poller checks liveness across every channel it serves, not just
88
+ // one, so it walks the parent directory instead of joining a single channel ID.
89
+ return process.env['BG_TASK_ROOT'] || join(config.PERSISTENT_STORAGE_PATH || '/tmp', 'bg-tasks');
90
+ }
91
+ function listDirs(path) {
92
+ try {
93
+ return readdirSync(path, { withFileTypes: true })
94
+ .filter((entry) => entry.isDirectory())
95
+ .map((entry) => entry.name);
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ }
101
+ // Returns a "channel/taskId" label for the first live (running or unknown-state) task
102
+ // found, or null if none. `unknown` (no status written yet) is treated as live —
103
+ // deliberately conservative, per tv#317: a task a few milliseconds from its first
104
+ // status line looks identical to an orphaned dir, and "skip this idle cycle" is cheap
105
+ // while restarting mid-launch is not recoverable.
106
+ export function findLiveBgTask(now = Date.now()) {
107
+ const root = bgTaskRoot();
108
+ for (const channel of listDirs(root)) {
109
+ const channelPath = join(root, channel);
110
+ for (const taskDir of listDirs(channelPath)) {
111
+ let statusText = '';
112
+ try {
113
+ statusText = readFileSync(join(channelPath, taskDir, 'status'), 'utf-8');
114
+ }
115
+ catch {
116
+ // No status file at all — same "unknown" treatment as an empty one below.
117
+ }
118
+ const state = lifecycleOf(parseStatus(statusText), now);
119
+ if (state === 'running' || state === 'unknown') {
120
+ return `${channel}/${taskDir}`;
121
+ }
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ // tv#317 "Startup reconciliation": bg-tasks don't survive a poller restart (their
127
+ // runner is a child of the container that just went away), but a status file on a
128
+ // persistent volume has no way to know that on its own. Without this, a task dir
129
+ // whose runner never wrote a terminal line looks identical to a live one — worse,
130
+ // after a restart the fresh container's own low PIDs can coincidentally collide with
131
+ // a stale recorded `pid=`, so pidAlive() alone isn't reliable evidence either. Called
132
+ // once at boot so every pre-restart dir is unambiguously stamped before the idle-restart
133
+ // gate (or `--list`) ever reads it again.
134
+ export function reconcileBgTasksOnBoot() {
135
+ const root = bgTaskRoot();
136
+ let stamped = 0;
137
+ for (const channel of listDirs(root)) {
138
+ const channelPath = join(root, channel);
139
+ for (const taskDir of listDirs(channelPath)) {
140
+ const statusPath = join(channelPath, taskDir, 'status');
141
+ let statusText;
142
+ try {
143
+ statusText = readFileSync(statusPath, 'utf-8');
144
+ }
145
+ catch {
146
+ continue; // no status file yet — nothing to stamp
147
+ }
148
+ const kv = parseStatus(statusText);
149
+ if (kv.state || kv.runner_crashed)
150
+ continue; // already terminal
151
+ try {
152
+ appendFileSync(statusPath, 'runner_crashed=container_restart\n');
153
+ stamped++;
154
+ }
155
+ catch (err) {
156
+ logger.warn(`[IdleRestart] Failed to stamp stale bg-task ${channel}/${taskDir}: ${err instanceof Error ? err.message : err}`);
157
+ }
158
+ }
159
+ }
160
+ if (stamped > 0) {
161
+ logger.info(`[IdleRestart] Reconciled ${stamped} bg-task dir(s) left over from before this boot`);
162
+ }
163
+ }
164
+ export function checkIdleRestart(deps = {}) {
165
+ const now = deps.now ?? Date.now();
166
+ const startTime = deps.startTime ?? processStartTime;
167
+ const activeProcessCount = deps.activeProcessCount ?? getActiveProcessCount;
168
+ const lastSessionEndTs = deps.lastSessionEndTs ?? getLastSessionEndTs;
169
+ const reclaimableBytesFn = deps.reclaimableBytes ?? readReclaimableFileBytes;
170
+ const liveBgTask = deps.liveBgTask ?? findLiveBgTask;
171
+ if (!config.IDLE_RESTART_ENABLED)
172
+ return false;
173
+ const uptimeMs = now - startTime;
174
+ if (uptimeMs < config.IDLE_RESTART_MIN_UPTIME_MS)
175
+ return false;
176
+ if (activeProcessCount() > 0)
177
+ return false;
178
+ const idleMs = now - lastSessionEndTs();
179
+ if (idleMs < config.IDLE_RESTART_IDLE_MS)
180
+ return false;
181
+ const reclaimableBytes = reclaimableBytesFn();
182
+ if (reclaimableBytes === null || reclaimableBytes < config.IDLE_RESTART_MEMORY_FILE_THRESHOLD_BYTES)
183
+ return false;
184
+ if (liveBgTask())
185
+ return false; // skip this cycle, recheck next time — never cut a bg-task short just to free memory sooner
186
+ // Re-verify the two liveness conditions right before signaling restart — a
187
+ // session or bg-task could have started in the time it took to reach this point.
188
+ if (activeProcessCount() > 0)
189
+ return false;
190
+ if (liveBgTask())
191
+ return false;
192
+ logger.info(`[IdleRestart] Idle ${Math.round(idleMs / 60000)}min, reclaimable page cache ` +
193
+ `${Math.round(reclaimableBytes / 1024 / 1024)}MB — restarting to let Railway reclaim it`);
194
+ emitEvent('poller.idle_restart', 'system', {
195
+ idleMs,
196
+ uptimeMs,
197
+ reclaimableBytes,
198
+ });
199
+ return true;
200
+ }
@@ -21,6 +21,72 @@ export declare function getTrackedBrains(): ReadonlyMap<string, TrackedBrainInfo
21
21
  * @internal
22
22
  */
23
23
  export declare function __resetTrackedBrainsForTest(): void;
24
+ /**
25
+ * Shape returned by GET /poller/brains (teamvibe.ai#127 Option B) — the
26
+ * fields registerBrain() needs to build a synthetic maintenance message,
27
+ * minus `lastRealMessageAt` (see seedTrackedBrains below for why that's
28
+ * deliberately not part of the bootstrap payload).
29
+ */
30
+ export interface BootstrapBrainInfo {
31
+ brainId: string;
32
+ gitRepoUrl: string;
33
+ branch: string;
34
+ workspaceId: string;
35
+ botId: string;
36
+ botToken: string;
37
+ channelId: string;
38
+ }
39
+ /**
40
+ * Seed trackedBrains at startup from the backend's authoritative brain list
41
+ * (teamvibe.ai#127) so a "quiet" brain — one that hasn't received a real
42
+ * message since this poller process started — still gets included in the
43
+ * maintenance/heartbeat cron, instead of being silently invisible until a
44
+ * human happens to message it.
45
+ *
46
+ * Deliberately does NOT set `lastRealMessageAt` (left undefined). That
47
+ * field specifically means "we've seen a real message since our own last
48
+ * restart" — shouldSkipMaintenance (maintenance-activity.ts, #131) treats
49
+ * `undefined` as fail-open (never skip) for exactly this reason. Bootstrap
50
+ * seeding isn't a real message; claiming one occurred here would risk the
51
+ * opposite bug — a genuinely-quiet brain wrongly evaluated as "nothing new,
52
+ * skip" on its very first post-restart cron tick.
53
+ *
54
+ * Skips (rather than overwrites) any brainId already present in
55
+ * trackedBrains. This guard is load-bearing, not just defensive (DevGuru
56
+ * review, round 4): since round 3's critical-path detachment
57
+ * (brains-bootstrap.ts's `void (async () => {...})()` retry), the retry
58
+ * branch can finish AFTER startPoller() has already returned and real user
59
+ * messages have started arriving. That means a real registerBrain() call
60
+ * for a brain (which sets `lastRealMessageAt`) can race ahead of — and
61
+ * complete before — the detached bootstrap retry's seedTrackedBrains()
62
+ * call for that same brain. Without this skip, the late-arriving bootstrap
63
+ * seed would silently overwrite the real registration back to a fresh
64
+ * entry with `lastRealMessageAt: undefined`, losing real activity data
65
+ * (and, per the fail-open contract above, artificially reopening the
66
+ * skip-maintenance window for a brain that's actually been active).
67
+ *
68
+ * DOES also register each seeded brain for hourly metrics (see
69
+ * `registerBrainForMetrics` below) — DevGuru review caught that this
70
+ * module's original "metrics has its own disk-walk path" reasoning was
71
+ * wrong: `metrics/scheduler.ts`'s `registered` Map has exactly one
72
+ * production caller (`registerBrain()` below), with no disk-walk discovery
73
+ * of its own. Disk reads only happen in `collector.ts`, and only for a
74
+ * brain already present in that Map. Skipping the metrics seed here would
75
+ * reproduce the exact same restart-amnesia bug for metrics that this whole
76
+ * change exists to fix for maintenance: a quiet brain would get maintenance
77
+ * triggers again, but zero metrics until someone messages it.
78
+ *
79
+ * Deliberately does NOT replicate `registerBrain()`'s *immediate*
80
+ * collect+send side effect (the `if (isNew) { void (async () => ... })()`
81
+ * block below) — only the Map insert. Firing an immediate collect+send for
82
+ * every brain this poller owns, all at once, on every restart would be a
83
+ * fleet-wide burst; the existing hourly cron with its per-brain jitter
84
+ * (`collectAndSendAll`, `jitterMsFor`) already picks up newly-registered
85
+ * brains on its own schedule without a startup spike.
86
+ *
87
+ * Returns the number of brains actually seeded (for startup logging).
88
+ */
89
+ export declare function seedTrackedBrains(brains: readonly BootstrapBrainInfo[]): number;
24
90
  /**
25
91
  * Register a brain from a real message so the maintenance cron knows about it.
26
92
  * Called from processMessage() after brain path is resolved.
@@ -27,6 +27,75 @@ export function __resetTrackedBrainsForTest() {
27
27
  trackedBrains.clear();
28
28
  }
29
29
  let maintenanceJob = null;
30
+ /**
31
+ * Seed trackedBrains at startup from the backend's authoritative brain list
32
+ * (teamvibe.ai#127) so a "quiet" brain — one that hasn't received a real
33
+ * message since this poller process started — still gets included in the
34
+ * maintenance/heartbeat cron, instead of being silently invisible until a
35
+ * human happens to message it.
36
+ *
37
+ * Deliberately does NOT set `lastRealMessageAt` (left undefined). That
38
+ * field specifically means "we've seen a real message since our own last
39
+ * restart" — shouldSkipMaintenance (maintenance-activity.ts, #131) treats
40
+ * `undefined` as fail-open (never skip) for exactly this reason. Bootstrap
41
+ * seeding isn't a real message; claiming one occurred here would risk the
42
+ * opposite bug — a genuinely-quiet brain wrongly evaluated as "nothing new,
43
+ * skip" on its very first post-restart cron tick.
44
+ *
45
+ * Skips (rather than overwrites) any brainId already present in
46
+ * trackedBrains. This guard is load-bearing, not just defensive (DevGuru
47
+ * review, round 4): since round 3's critical-path detachment
48
+ * (brains-bootstrap.ts's `void (async () => {...})()` retry), the retry
49
+ * branch can finish AFTER startPoller() has already returned and real user
50
+ * messages have started arriving. That means a real registerBrain() call
51
+ * for a brain (which sets `lastRealMessageAt`) can race ahead of — and
52
+ * complete before — the detached bootstrap retry's seedTrackedBrains()
53
+ * call for that same brain. Without this skip, the late-arriving bootstrap
54
+ * seed would silently overwrite the real registration back to a fresh
55
+ * entry with `lastRealMessageAt: undefined`, losing real activity data
56
+ * (and, per the fail-open contract above, artificially reopening the
57
+ * skip-maintenance window for a brain that's actually been active).
58
+ *
59
+ * DOES also register each seeded brain for hourly metrics (see
60
+ * `registerBrainForMetrics` below) — DevGuru review caught that this
61
+ * module's original "metrics has its own disk-walk path" reasoning was
62
+ * wrong: `metrics/scheduler.ts`'s `registered` Map has exactly one
63
+ * production caller (`registerBrain()` below), with no disk-walk discovery
64
+ * of its own. Disk reads only happen in `collector.ts`, and only for a
65
+ * brain already present in that Map. Skipping the metrics seed here would
66
+ * reproduce the exact same restart-amnesia bug for metrics that this whole
67
+ * change exists to fix for maintenance: a quiet brain would get maintenance
68
+ * triggers again, but zero metrics until someone messages it.
69
+ *
70
+ * Deliberately does NOT replicate `registerBrain()`'s *immediate*
71
+ * collect+send side effect (the `if (isNew) { void (async () => ... })()`
72
+ * block below) — only the Map insert. Firing an immediate collect+send for
73
+ * every brain this poller owns, all at once, on every restart would be a
74
+ * fleet-wide burst; the existing hourly cron with its per-brain jitter
75
+ * (`collectAndSendAll`, `jitterMsFor`) already picks up newly-registered
76
+ * brains on its own schedule without a startup spike.
77
+ *
78
+ * Returns the number of brains actually seeded (for startup logging).
79
+ */
80
+ export function seedTrackedBrains(brains) {
81
+ let seeded = 0;
82
+ for (const b of brains) {
83
+ if (trackedBrains.has(b.brainId))
84
+ continue;
85
+ trackedBrains.set(b.brainId, {
86
+ brainId: b.brainId,
87
+ gitRepoUrl: b.gitRepoUrl,
88
+ branch: b.branch,
89
+ workspaceId: b.workspaceId,
90
+ botId: b.botId,
91
+ botToken: b.botToken,
92
+ channelId: b.channelId,
93
+ });
94
+ registerBrainForMetrics(b.brainId);
95
+ seeded++;
96
+ }
97
+ return seeded;
98
+ }
30
99
  /**
31
100
  * Register a brain from a real message so the maintenance cron knows about it.
32
101
  * Called from processMessage() after brain path is resolved.
package/dist/poller.d.ts CHANGED
@@ -29,8 +29,11 @@ export declare function isPollerInitiatedFailure(result: {
29
29
  exitCode: number | null;
30
30
  killedByPoller?: KilledByPoller;
31
31
  }): boolean;
32
+ export declare function describeFailureReason(killedByPoller: KilledByPoller | undefined, exitCode: number | null): string;
33
+ export declare const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = "NO_FURTHER_RESPONSE_NEEDED";
32
34
  export declare function endOfTurnFallbackText(hasSlackContext: boolean, result: Pick<SpawnResult, 'success' | 'sawSendMessageCall' | 'lastPlainTextContent'>): string | undefined;
33
35
  export declare function runInboxLoop(threadId: string, kbPath: string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession>): Promise<void>;
36
+ export declare function __setSleepForTest(impl: (ms: number) => Promise<void>): void;
34
37
  export declare function __setShuttingDownForTest(value: boolean): void;
35
38
  export declare function startPoller(): Promise<void>;
36
39
  export {};