@teamvibe/poller 0.6.2 → 0.7.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.
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>;
@@ -35,6 +41,10 @@ declare const configSchema: z.ZodObject<{
35
41
  AUTO_UPDATE_SCHEDULE: z.ZodDefault<z.ZodString>;
36
42
  INBOX_WATCH_FALLBACK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
37
43
  INBOX_WATCH_DEBOUNCE_MS: z.ZodDefault<z.ZodNumber>;
44
+ IDLE_RESTART_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
45
+ IDLE_RESTART_IDLE_MS: z.ZodDefault<z.ZodNumber>;
46
+ IDLE_RESTART_MIN_UPTIME_MS: z.ZodDefault<z.ZodNumber>;
47
+ IDLE_RESTART_MEMORY_FILE_THRESHOLD_BYTES: z.ZodDefault<z.ZodNumber>;
38
48
  }, "strip", z.ZodTypeAny, {
39
49
  AWS_REGION: string;
40
50
  TEAMVIBE_API_URL: string;
@@ -47,6 +57,7 @@ declare const configSchema: z.ZodObject<{
47
57
  LOCK_RETRY_INTERVAL_MS: number;
48
58
  IDLE_WATCHDOG_WARN_MS: number;
49
59
  IDLE_WATCHDOG_KILL_MS: number;
60
+ IDLE_WATCHDOG_COMPACTION_GRACE_MS: number;
50
61
  NETWORK_ERROR_KILL_MS: number;
51
62
  MAX_CONSECUTIVE_NET_FAILURES: number;
52
63
  TEAMVIBE_DATA_DIR: string;
@@ -60,12 +71,21 @@ declare const configSchema: z.ZodObject<{
60
71
  BASE_BRAIN_PATH: string;
61
72
  BRAIN_AUTO_UPDATE: boolean;
62
73
  BRAIN_UPDATE_COOLDOWN_MS: number;
74
+ BRAIN_MAX_BLOB_BYTES: number;
75
+ BRAIN_PUSH_RETRY_MAX: number;
76
+ BRAIN_PUSH_RETRY_BACKOFF_MS: number;
77
+ GIT_LOCK_MAX_AGE_MS: number;
78
+ GIT_SUBMODULE_LOCK_MAX_AGE_MS: number;
63
79
  MAINTENANCE_CRON: string;
64
80
  MAINTENANCE_ENABLED: boolean;
65
81
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
66
82
  AUTO_UPDATE_SCHEDULE: string;
67
83
  INBOX_WATCH_FALLBACK_INTERVAL_MS: number;
68
84
  INBOX_WATCH_DEBOUNCE_MS: number;
85
+ IDLE_RESTART_ENABLED: boolean;
86
+ IDLE_RESTART_IDLE_MS: number;
87
+ IDLE_RESTART_MIN_UPTIME_MS: number;
88
+ IDLE_RESTART_MEMORY_FILE_THRESHOLD_BYTES: number;
69
89
  SQS_QUEUE_URL?: string | undefined;
70
90
  SESSIONS_TABLE?: string | undefined;
71
91
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
@@ -85,6 +105,7 @@ declare const configSchema: z.ZodObject<{
85
105
  LOCK_RETRY_INTERVAL_MS?: number | undefined;
86
106
  IDLE_WATCHDOG_WARN_MS?: number | undefined;
87
107
  IDLE_WATCHDOG_KILL_MS?: number | undefined;
108
+ IDLE_WATCHDOG_COMPACTION_GRACE_MS?: number | undefined;
88
109
  NETWORK_ERROR_KILL_MS?: number | undefined;
89
110
  MAX_CONSECUTIVE_NET_FAILURES?: number | undefined;
90
111
  TEAMVIBE_DATA_DIR?: string | undefined;
@@ -98,6 +119,11 @@ declare const configSchema: z.ZodObject<{
98
119
  BASE_BRAIN_PATH?: string | undefined;
99
120
  BRAIN_AUTO_UPDATE?: string | undefined;
100
121
  BRAIN_UPDATE_COOLDOWN_MS?: number | undefined;
122
+ BRAIN_MAX_BLOB_BYTES?: number | undefined;
123
+ BRAIN_PUSH_RETRY_MAX?: number | undefined;
124
+ BRAIN_PUSH_RETRY_BACKOFF_MS?: number | undefined;
125
+ GIT_LOCK_MAX_AGE_MS?: number | undefined;
126
+ GIT_SUBMODULE_LOCK_MAX_AGE_MS?: number | undefined;
101
127
  POLLER_TZ?: string | undefined;
102
128
  MAINTENANCE_CRON?: string | undefined;
103
129
  MAINTENANCE_ENABLED?: string | undefined;
@@ -105,6 +131,10 @@ declare const configSchema: z.ZodObject<{
105
131
  AUTO_UPDATE_SCHEDULE?: string | undefined;
106
132
  INBOX_WATCH_FALLBACK_INTERVAL_MS?: number | undefined;
107
133
  INBOX_WATCH_DEBOUNCE_MS?: number | undefined;
134
+ IDLE_RESTART_ENABLED?: string | undefined;
135
+ IDLE_RESTART_IDLE_MS?: number | undefined;
136
+ IDLE_RESTART_MIN_UPTIME_MS?: number | undefined;
137
+ IDLE_RESTART_MEMORY_FILE_THRESHOLD_BYTES?: number | undefined;
108
138
  }>;
109
139
  export type Config = z.infer<typeof configSchema>;
110
140
  export declare function loadConfig(): Config;
@@ -120,6 +150,7 @@ export declare const config: {
120
150
  LOCK_RETRY_INTERVAL_MS: number;
121
151
  IDLE_WATCHDOG_WARN_MS: number;
122
152
  IDLE_WATCHDOG_KILL_MS: number;
153
+ IDLE_WATCHDOG_COMPACTION_GRACE_MS: number;
123
154
  NETWORK_ERROR_KILL_MS: number;
124
155
  MAX_CONSECUTIVE_NET_FAILURES: number;
125
156
  TEAMVIBE_DATA_DIR: string;
@@ -133,12 +164,21 @@ export declare const config: {
133
164
  BASE_BRAIN_PATH: string;
134
165
  BRAIN_AUTO_UPDATE: boolean;
135
166
  BRAIN_UPDATE_COOLDOWN_MS: number;
167
+ BRAIN_MAX_BLOB_BYTES: number;
168
+ BRAIN_PUSH_RETRY_MAX: number;
169
+ BRAIN_PUSH_RETRY_BACKOFF_MS: number;
170
+ GIT_LOCK_MAX_AGE_MS: number;
171
+ GIT_SUBMODULE_LOCK_MAX_AGE_MS: number;
136
172
  MAINTENANCE_CRON: string;
137
173
  MAINTENANCE_ENABLED: boolean;
138
174
  AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
139
175
  AUTO_UPDATE_SCHEDULE: string;
140
176
  INBOX_WATCH_FALLBACK_INTERVAL_MS: number;
141
177
  INBOX_WATCH_DEBOUNCE_MS: number;
178
+ IDLE_RESTART_ENABLED: boolean;
179
+ IDLE_RESTART_IDLE_MS: number;
180
+ IDLE_RESTART_MIN_UPTIME_MS: number;
181
+ IDLE_RESTART_MEMORY_FILE_THRESHOLD_BYTES: number;
142
182
  SQS_QUEUE_URL?: string | undefined;
143
183
  SESSIONS_TABLE?: string | undefined;
144
184
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
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
@@ -63,6 +76,23 @@ const configSchema = z.object({
63
76
  // exist at watcher startup, and prunes empty thread dirs.
64
77
  INBOX_WATCH_FALLBACK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 minutes
65
78
  INBOX_WATCH_DEBOUNCE_MS: z.coerce.number().default(300), // coalesce bursty fs events into one scan
79
+ // Idle restart (tv#317): Railway bills on cgroup memory usage, which includes page
80
+ // cache that Linux only reclaims under real memory pressure — a large Railway memory
81
+ // limit means that pressure never triggers, so cache from heavy sessions (bun install,
82
+ // vite build, apt) accumulates until something forces a restart. Rather than an
83
+ // external supervisor, the poller restarts itself once genuinely idle; Railway's
84
+ // ALWAYS restart policy brings it back up clean and any SQS message that arrives
85
+ // during the few-second restart window just waits in the queue.
86
+ //
87
+ // Defaults OFF: this is a Railway-specific mitigation (the memory-threshold check
88
+ // is gated on cgroup v2 anyway, see readReclaimableFileBytes), but the default
89
+ // should make that explicit rather than lean on an implicit cgroup-absence no-op —
90
+ // deploy targets that also happen to be cgroup v2 Linux shouldn't pick this up by
91
+ // just existing. Set IDLE_RESTART_ENABLED=true on Railway to opt in.
92
+ IDLE_RESTART_ENABLED: z.string().default('false').transform((v) => v.toLowerCase() === 'true'),
93
+ IDLE_RESTART_IDLE_MS: z.coerce.number().default(1_800_000), // 30 minutes since the last session ended
94
+ IDLE_RESTART_MIN_UPTIME_MS: z.coerce.number().default(1_800_000), // crash-loop guard — matches the idle floor above (tv#317 discussion), so it doesn't add its own separate cadence
95
+ IDLE_RESTART_MEMORY_FILE_THRESHOLD_BYTES: z.coerce.number().default(700 * 1024 * 1024), // cgroup memory.stat `file` (reclaimable page cache), not memory.current — gating on raw usage would also fire on a real anon leak and mask it instead of just clearing cache
66
96
  });
67
97
  export function loadConfig() {
68
98
  const result = configSchema.safeParse(process.env);
@@ -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 = [];
@@ -0,0 +1,12 @@
1
+ export declare function readReclaimableFileBytes(path?: string): number | null;
2
+ export declare function findLiveBgTask(now?: number): string | null;
3
+ export declare function reconcileBgTasksOnBoot(): void;
4
+ export interface CheckIdleRestartDeps {
5
+ now?: number;
6
+ startTime?: number;
7
+ activeProcessCount?: () => number;
8
+ lastSessionEndTs?: () => number;
9
+ reclaimableBytes?: () => number | null;
10
+ liveBgTask?: () => string | null;
11
+ }
12
+ export declare function checkIdleRestart(deps?: CheckIdleRestartDeps): boolean;
@@ -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
+ }
@@ -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
+ }