@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/brain-manager.d.ts +43 -1
- package/dist/brain-manager.js +259 -6
- package/dist/classify-failed-iteration.d.ts +1 -1
- package/dist/classify-failed-iteration.js +33 -1
- package/dist/claude-spawner.d.ts +178 -1
- package/dist/claude-spawner.js +687 -92
- package/dist/config.d.ts +24 -0
- package/dist/config.js +15 -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/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 +7 -0
- package/dist/maintenance-cron.js +16 -1
- package/dist/metrics/collector.d.ts +2 -0
- package/dist/metrics/collector.js +7 -0
- package/dist/poller.d.ts +9 -0
- package/dist/poller.js +158 -4
- 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>;
|
|
@@ -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(
|
|
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
|
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 = [];
|
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
|
+
}
|
|
@@ -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.
|
package/dist/maintenance-cron.js
CHANGED
|
@@ -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 {};
|