@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/brain-manager.d.ts +43 -1
- package/dist/brain-manager.js +259 -6
- package/dist/brains-bootstrap.d.ts +45 -0
- package/dist/brains-bootstrap.js +217 -0
- package/dist/classify-failed-iteration.d.ts +1 -1
- package/dist/classify-failed-iteration.js +33 -1
- package/dist/claude-spawner.d.ts +179 -1
- package/dist/claude-spawner.js +697 -92
- package/dist/config.d.ts +40 -0
- package/dist/config.js +32 -2
- package/dist/cron-telemetry.d.ts +3 -0
- package/dist/cron-telemetry.js +16 -0
- package/dist/event-queue.d.ts +30 -0
- package/dist/event-queue.js +4 -0
- package/dist/idle-restart.d.ts +12 -0
- package/dist/idle-restart.js +200 -0
- package/dist/inbox-manager.d.ts +25 -0
- package/dist/inbox-manager.js +47 -0
- package/dist/maintenance-activity.d.ts +18 -0
- package/dist/maintenance-activity.js +28 -0
- package/dist/maintenance-cron.d.ts +73 -0
- package/dist/maintenance-cron.js +85 -1
- package/dist/metrics/collector.d.ts +2 -0
- package/dist/metrics/collector.js +7 -0
- package/dist/poller.d.ts +12 -0
- package/dist/poller.js +345 -8
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
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(
|
|
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);
|
package/dist/cron-telemetry.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/cron-telemetry.js
CHANGED
|
@@ -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
|
}
|
package/dist/event-queue.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/event-queue.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/inbox-manager.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/inbox-manager.js
CHANGED
|
@@ -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
|
+
}
|