@teamvibe/poller 0.4.4 → 0.5.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 +5 -0
- package/dist/brain-manager.js +40 -2
- package/dist/classify-failed-iteration.d.ts +16 -0
- package/dist/classify-failed-iteration.js +53 -0
- package/dist/claude-spawner.d.ts +15 -1
- package/dist/claude-spawner.js +131 -26
- package/dist/config.d.ts +0 -4
- package/dist/config.js +2 -3
- package/dist/cron-telemetry.d.ts +5 -9
- package/dist/cron-telemetry.js +5 -17
- package/dist/{heartbeat-manager.d.ts → maintenance-cron.d.ts} +6 -17
- package/dist/{heartbeat-manager.js → maintenance-cron.js} +24 -52
- package/dist/metrics/collector.d.ts +0 -4
- package/dist/metrics/collector.js +7 -20
- package/dist/poller.d.ts +1 -1
- package/dist/poller.js +17 -12
- package/dist/secrets-client.d.ts +82 -0
- package/dist/secrets-client.js +215 -0
- package/dist/types.d.ts +1 -1
- package/package.json +2 -1
package/dist/brain-manager.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ interface BrainConfig {
|
|
|
4
4
|
branch: string;
|
|
5
5
|
}
|
|
6
6
|
export declare function getBrainPath(brain?: BrainConfig, channelId?: string, workspaceId?: string): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Update a brain repo (respects cooldown). Exported for testing — production
|
|
9
|
+
* call sites should go through `getBrainPath` / `ensureBaseBrain`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function updateBrain(brainDir: string, brainId: string, branch: string): Promise<void>;
|
|
7
12
|
/**
|
|
8
13
|
* Ensure base brain repo is cloned and up to date.
|
|
9
14
|
* Uses same cooldown pattern as channel brains.
|
package/dist/brain-manager.js
CHANGED
|
@@ -90,15 +90,53 @@ export async function getBrainPath(brain, channelId, workspaceId) {
|
|
|
90
90
|
return brainDir;
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
|
-
* Update a brain repo (respects cooldown)
|
|
93
|
+
* Update a brain repo (respects cooldown). Exported for testing — production
|
|
94
|
+
* call sites should go through `getBrainPath` / `ensureBaseBrain`.
|
|
94
95
|
*/
|
|
95
|
-
async function updateBrain(brainDir, brainId, branch) {
|
|
96
|
+
export async function updateBrain(brainDir, brainId, branch) {
|
|
96
97
|
const now = Date.now();
|
|
97
98
|
const lastUpdate = lastUpdateTimes.get(brainId) || 0;
|
|
98
99
|
if (now - lastUpdate < config.BRAIN_UPDATE_COOLDOWN_MS) {
|
|
99
100
|
logger.debug(`Brain ${brainId} update skipped - cooldown active (${Math.round((config.BRAIN_UPDATE_COOLDOWN_MS - (now - lastUpdate)) / 1000)}s remaining)`);
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
103
|
+
// Guard: skip `reset --hard` when the working tree has uncommitted writes or
|
|
104
|
+
// unpushed commits, otherwise an in-flight session's mid-message memory
|
|
105
|
+
// writes (e.g. memory/TODAY.md, MEM_REGISTRY.md) get silently wiped on the
|
|
106
|
+
// next message arrival after the cooldown elapses. Fail-safe direction —
|
|
107
|
+
// pushBrainChanges at session-end reconciles drift on the happy path; the
|
|
108
|
+
// structural fix is worktree isolation (see #205). Mirrors no-upstream
|
|
109
|
+
// handling in pushBrainChanges below.
|
|
110
|
+
try {
|
|
111
|
+
const { stdout: dirty } = await gitExec('git status --porcelain', { cwd: brainDir });
|
|
112
|
+
let hasLocalCommits = false;
|
|
113
|
+
try {
|
|
114
|
+
const { stdout: unpushed } = await gitExec('git log @{u}..HEAD --oneline', { cwd: brainDir });
|
|
115
|
+
hasLocalCommits = !!unpushed.trim();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// No upstream tracking (detached HEAD, mid-rebase, or `git init`-bootstrapped
|
|
119
|
+
// brain dir; a normal `git clone` always sets up tracking). Broken upstream
|
|
120
|
+
// tracking → skip aggressively; manual investigation preferred over a silent
|
|
121
|
+
// --hard reset.
|
|
122
|
+
const { stdout: commits } = await gitExec('git log --oneline -1', { cwd: brainDir });
|
|
123
|
+
hasLocalCommits = !!commits.trim();
|
|
124
|
+
}
|
|
125
|
+
if (dirty.trim() || hasLocalCommits) {
|
|
126
|
+
// TODO(#205-followup): emit operational alert if a brain skips reset for > N hours —
|
|
127
|
+
// long-term skip means pushBrainChanges is persistently failing and the brain is
|
|
128
|
+
// silently drifting away from base-brain / skills updates.
|
|
129
|
+
logger.info(`Brain ${brainId} update skipped — local changes present (dirty=${!!dirty.trim()}, hasLocalCommits=${hasLocalCommits})`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// Pre-check itself failed (e.g. brainDir not a git repo). Fail-safe to skip;
|
|
135
|
+
// the next iteration will retry.
|
|
136
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
137
|
+
logger.warn(`Brain ${brainId} pre-check failed, skipping update: ${errorMessage}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
102
140
|
try {
|
|
103
141
|
logger.info(`Updating brain ${brainId} at ${brainDir}...`);
|
|
104
142
|
await gitExec(`git fetch origin ${branch} && git reset --hard origin/${branch} && git submodule update --init --recursive`, {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type KilledByPoller = 'timeout' | 'idle' | 'zombie' | 'network';
|
|
2
|
+
export interface FailedIterationInput {
|
|
3
|
+
error?: string;
|
|
4
|
+
exitCode: number | null;
|
|
5
|
+
killedByPoller?: KilledByPoller;
|
|
6
|
+
}
|
|
7
|
+
export type FailedIterationDecision = {
|
|
8
|
+
kind: 'neutral';
|
|
9
|
+
text: string;
|
|
10
|
+
reaction: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'error';
|
|
13
|
+
text: string;
|
|
14
|
+
reaction: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function classifyFailedIteration(result: FailedIterationInput): FailedIterationDecision;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Decides how to surface a failed claude-spawner iteration to the user.
|
|
2
|
+
//
|
|
3
|
+
// Background (teamvibe.ai#193): poller-induced kills (timeout, idle watchdog,
|
|
4
|
+
// network watchdog, zombie cleanup) used to leak as generic
|
|
5
|
+
// "Error processing your request: Process exited with code 143" via
|
|
6
|
+
// sendSlackError. The explicit `killedByPoller` intent marker (set by the
|
|
7
|
+
// spawner before SIGTERM) lets us emit a neutral 1-sentence notice instead —
|
|
8
|
+
// "Session paused — send another message to resume." — which tells the user
|
|
9
|
+
// *what happened* and *what to do*, without false-flagging an internal kill
|
|
10
|
+
// as an application failure.
|
|
11
|
+
//
|
|
12
|
+
// Pattern: explicit intent marker > exit-code pattern matching (143/137).
|
|
13
|
+
// Exit codes are fragile — the Claude binary can legitimately return 143
|
|
14
|
+
// for unrelated reasons; the marker decides on poller intent, not process
|
|
15
|
+
// output.
|
|
16
|
+
export function classifyFailedIteration(result) {
|
|
17
|
+
switch (result.killedByPoller) {
|
|
18
|
+
case 'idle':
|
|
19
|
+
return {
|
|
20
|
+
kind: 'neutral',
|
|
21
|
+
text: 'Session paused (idle timeout) — send another message to resume.',
|
|
22
|
+
reaction: 'zzz',
|
|
23
|
+
};
|
|
24
|
+
case 'zombie':
|
|
25
|
+
return {
|
|
26
|
+
kind: 'neutral',
|
|
27
|
+
text: "Process didn't shut down cleanly — send another message to restart.",
|
|
28
|
+
reaction: 'recycle',
|
|
29
|
+
};
|
|
30
|
+
case 'network':
|
|
31
|
+
return {
|
|
32
|
+
kind: 'neutral',
|
|
33
|
+
text: 'Network connectivity lost — send another message to retry.',
|
|
34
|
+
reaction: 'satellite_antenna',
|
|
35
|
+
};
|
|
36
|
+
case 'timeout':
|
|
37
|
+
case undefined: {
|
|
38
|
+
// 'timeout' keeps the legacy ":warning: Session timed out after N
|
|
39
|
+
// minutes..." surface unchanged (the error string is set by the
|
|
40
|
+
// spawner). undefined = genuine failure → keep current behavior.
|
|
41
|
+
const text = result.error || `Process exited with code ${result.exitCode}`;
|
|
42
|
+
const isTimeout = result.killedByPoller === 'timeout' || text.includes('timed out');
|
|
43
|
+
return { kind: 'error', text, reaction: isTimeout ? 'hourglass' : 'x' };
|
|
44
|
+
}
|
|
45
|
+
default: {
|
|
46
|
+
// Exhaustiveness guard: adding a new member to KilledByPoller without
|
|
47
|
+
// handling it here will fail to compile (never assignment). Runtime
|
|
48
|
+
// throw is the fallback for tsconfigs without noImplicitReturns.
|
|
49
|
+
const _exhaustive = result.killedByPoller;
|
|
50
|
+
throw new Error(`Unhandled killedByPoller: ${String(_exhaustive)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import type { TeamVibeQueueMessage, SessionMetrics } from './types.js';
|
|
2
2
|
import { type SessionLogger } from './logger.js';
|
|
3
3
|
import type { TypingIndicator } from './slack-client.js';
|
|
4
|
+
import type { KilledByPoller } from './classify-failed-iteration.js';
|
|
5
|
+
export interface ContextHintParams {
|
|
6
|
+
isFirstMessage: boolean;
|
|
7
|
+
lastMessageTs?: string;
|
|
8
|
+
slack?: {
|
|
9
|
+
channel: string;
|
|
10
|
+
thread_ts: string;
|
|
11
|
+
message_ts: string;
|
|
12
|
+
};
|
|
13
|
+
countNewSinceTs: (channel: string, threadTs: string, sinceTs: string) => Promise<number>;
|
|
14
|
+
countPriorBeforeTs: (channel: string, threadTs: string, beforeTs: string) => Promise<number>;
|
|
15
|
+
}
|
|
16
|
+
export declare function buildContextHint(p: ContextHintParams): Promise<string | undefined>;
|
|
4
17
|
export interface SpawnResult {
|
|
5
18
|
success: boolean;
|
|
6
19
|
output: string;
|
|
7
20
|
exitCode: number | null;
|
|
8
21
|
error?: string;
|
|
9
22
|
metrics?: SessionMetrics;
|
|
23
|
+
killedByPoller?: KilledByPoller;
|
|
10
24
|
}
|
|
11
|
-
export declare function buildPrompt(msg: TeamVibeQueueMessage,
|
|
25
|
+
export declare function buildPrompt(msg: TeamVibeQueueMessage, contextHint?: string): string;
|
|
12
26
|
export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void, typingIndicator?: TypingIndicator): Promise<SpawnResult & {
|
|
13
27
|
newSessionId?: string;
|
|
14
28
|
}>;
|
package/dist/claude-spawner.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getBaseBrainPath } from './brain-manager.js';
|
|
|
8
8
|
import { getPollerId } from './auth-provider.js';
|
|
9
9
|
import { emitError } from './event-queue.js';
|
|
10
10
|
import { createWorktree, removeWorktree, isWorktreeIsolationEnabled } from './worktree-manager.js';
|
|
11
|
+
import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js';
|
|
11
12
|
/**
|
|
12
13
|
* Maps tool names (or prefixes) to human-readable status messages.
|
|
13
14
|
* More specific patterns are checked first.
|
|
@@ -86,6 +87,68 @@ async function countNewThreadMessages(botToken, channel, threadTs, sinceTs) {
|
|
|
86
87
|
return 0;
|
|
87
88
|
}
|
|
88
89
|
}
|
|
90
|
+
// Count messages strictly before the message being handled. Used to hint that
|
|
91
|
+
// prior thread context exists when the bot is invoked for the first time on a
|
|
92
|
+
// thread that already has history. Bot-side messages are included — the bot's
|
|
93
|
+
// own past replies are still context the model should be aware of (a fresh
|
|
94
|
+
// session has lost what it said before).
|
|
95
|
+
// TODO: detect has_more to surface a "100+" signal — conversations.replies
|
|
96
|
+
// saturates at limit:100 without telling the caller, so very long threads
|
|
97
|
+
// will be reported as exactly "100 previous messages".
|
|
98
|
+
async function countPriorThreadMessages(botToken, channel, threadTs, currentMessageTs) {
|
|
99
|
+
try {
|
|
100
|
+
const slack = getSlackClient(botToken);
|
|
101
|
+
const result = await slack.conversations.replies({
|
|
102
|
+
channel,
|
|
103
|
+
ts: threadTs,
|
|
104
|
+
limit: 100,
|
|
105
|
+
});
|
|
106
|
+
if (!result.messages)
|
|
107
|
+
return 0;
|
|
108
|
+
const priorMessages = result.messages.filter((msg) => {
|
|
109
|
+
if (!msg.ts)
|
|
110
|
+
return false;
|
|
111
|
+
return parseFloat(msg.ts) < parseFloat(currentMessageTs);
|
|
112
|
+
});
|
|
113
|
+
return priorMessages.length;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
logger.warn(`Failed to count prior thread messages: ${error}`);
|
|
117
|
+
emitError('slack', `Failed to count prior thread messages: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Select and render the context hint that goes into the prompt. Pure aside
|
|
122
|
+
// from the injected counters — extracted from runClaudeCode so the branching
|
|
123
|
+
// (resume vs. thread-context vs. no-hint) can be unit tested without spinning
|
|
124
|
+
// up a Slack WebClient.
|
|
125
|
+
//
|
|
126
|
+
// Branches:
|
|
127
|
+
// - resumed session (!isFirstMessage && lastMessageTs && slack thread)
|
|
128
|
+
// → "Resumed session: N new messages …" (or "No new messages" when N=0)
|
|
129
|
+
// - first-spawn on a thread reply (isFirstMessage && thread_ts !== message_ts)
|
|
130
|
+
// → "Thread context: N previous messages …" when N>0, else no hint
|
|
131
|
+
// - everything else (top-level message, resume-without-anchor)
|
|
132
|
+
// → no hint
|
|
133
|
+
export async function buildContextHint(p) {
|
|
134
|
+
if (!p.isFirstMessage && p.lastMessageTs && p.slack?.channel && p.slack?.thread_ts) {
|
|
135
|
+
const newMessageCount = await p.countNewSinceTs(p.slack.channel, p.slack.thread_ts, p.lastMessageTs);
|
|
136
|
+
return newMessageCount > 0
|
|
137
|
+
? `**Resumed session:** ${newMessageCount} new message(s) in the thread since your last response. Consider using \`read_thread\` to see what was discussed before responding.`
|
|
138
|
+
: `**Resumed session:** No new messages since your last response.`;
|
|
139
|
+
}
|
|
140
|
+
if (p.isFirstMessage &&
|
|
141
|
+
p.slack?.channel &&
|
|
142
|
+
p.slack?.thread_ts &&
|
|
143
|
+
p.slack?.message_ts &&
|
|
144
|
+
p.slack.thread_ts !== p.slack.message_ts) {
|
|
145
|
+
const priorCount = await p.countPriorBeforeTs(p.slack.channel, p.slack.thread_ts, p.slack.message_ts);
|
|
146
|
+
if (priorCount > 0) {
|
|
147
|
+
return `**Thread context:** ${priorCount} previous message(s) in this thread before yours.\nCall \`read_thread\` first to load conversation context.`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
89
152
|
/**
|
|
90
153
|
* Read brain settings.json from the brain working directory.
|
|
91
154
|
* Returns parsed settings or empty object if not found.
|
|
@@ -104,11 +167,11 @@ function readBrainSettings(cwd) {
|
|
|
104
167
|
return {};
|
|
105
168
|
}
|
|
106
169
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
170
|
+
// Tags procs at SIGTERM time so the close handler can report poller intent.
|
|
171
|
+
// WeakMap auto-cleans when proc is GC'd. Module-scope is required because
|
|
172
|
+
// killZombieForThread fires outside the runClaudeCode closure.
|
|
173
|
+
const killReasonByProc = new WeakMap();
|
|
174
|
+
export function buildPrompt(msg, contextHint) {
|
|
112
175
|
const isEmail = msg.source === 'email';
|
|
113
176
|
const isWebhook = msg.source === 'webhook';
|
|
114
177
|
let prompt = isEmail ? '## Incoming Email\n\n' : isWebhook ? '## Incoming Webhook\n\n' : '## Incoming Slack Message\n\n';
|
|
@@ -164,8 +227,8 @@ export function buildPrompt(msg, resumeHint) {
|
|
|
164
227
|
if (msg.teamvibe.emailHookPrompt) {
|
|
165
228
|
prompt += '\n\n**Channel Instructions:**\n' + msg.teamvibe.emailHookPrompt + '\n';
|
|
166
229
|
}
|
|
167
|
-
if (
|
|
168
|
-
prompt += `\n\n${
|
|
230
|
+
if (contextHint) {
|
|
231
|
+
prompt += `\n\n${contextHint}`;
|
|
169
232
|
}
|
|
170
233
|
if (isEmail) {
|
|
171
234
|
prompt += `\n\n---\n\nProcess this email and respond appropriately using available tools.`;
|
|
@@ -234,31 +297,44 @@ function formatStreamEvent(event) {
|
|
|
234
297
|
async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
|
|
235
298
|
const slackContext = msg.response_context.slack;
|
|
236
299
|
const isCronMessage = msg.source === 'cron';
|
|
237
|
-
const isHeartbeat = msg.source === 'heartbeat';
|
|
238
300
|
const isMaintenance = msg.source === 'maintenance';
|
|
239
301
|
const isEmailMessage = msg.source === 'email';
|
|
240
302
|
const isWebhookMessage = msg.source === 'webhook';
|
|
241
303
|
if (!slackContext &&
|
|
242
304
|
!isCronMessage &&
|
|
243
|
-
!isHeartbeat &&
|
|
244
305
|
!isMaintenance &&
|
|
245
306
|
!isEmailMessage &&
|
|
246
307
|
!isWebhookMessage) {
|
|
247
308
|
throw new Error('No Slack context in message');
|
|
248
309
|
}
|
|
249
|
-
// Build resume hint
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
310
|
+
// Build context hint — either resume hint (when continuing a session) or
|
|
311
|
+
// thread context hint (when first-spawning on a thread that already has
|
|
312
|
+
// prior messages). The two are mutually exclusive: a resume implies the bot
|
|
313
|
+
// already saw the thread once, so resume's "X new since last response" is
|
|
314
|
+
// the more informative signal. Logic extracted into buildContextHint for
|
|
315
|
+
// unit testability — the counters are injected so tests don't need to mock
|
|
316
|
+
// the Slack WebClient module.
|
|
317
|
+
const contextHint = await buildContextHint({
|
|
318
|
+
isFirstMessage,
|
|
319
|
+
lastMessageTs,
|
|
320
|
+
slack: slackContext,
|
|
321
|
+
countNewSinceTs: (ch, tts, since) => countNewThreadMessages(msg.teamvibe.botToken, ch, tts, since),
|
|
322
|
+
countPriorBeforeTs: (ch, tts, before) => countPriorThreadMessages(msg.teamvibe.botToken, ch, tts, before),
|
|
323
|
+
});
|
|
324
|
+
// Fetch secrets for this spawn before entering the Promise constructor —
|
|
325
|
+
// the executor callback is not async, so awaits must be hoisted out.
|
|
326
|
+
// Returns an opaque envelope; we unwrap exactly once at the spawn env
|
|
327
|
+
// construction site below. Reminder metadata (names + scope + expiration
|
|
328
|
+
// only — never values) feeds the system prompt so the session knows which
|
|
329
|
+
// ENV variables exist.
|
|
330
|
+
const spawnSecrets = await fetchSecretsForSpawn({
|
|
331
|
+
pollerId: getPollerId() || '',
|
|
332
|
+
workspaceId: msg.teamvibe.workspaceId,
|
|
333
|
+
channelId: msg.teamvibe.channelId,
|
|
334
|
+
});
|
|
335
|
+
const secretsReminder = buildSecretsReminder(spawnSecrets.reminders);
|
|
260
336
|
return new Promise((resolve) => {
|
|
261
|
-
const prompt = buildPrompt(msg,
|
|
337
|
+
const prompt = buildPrompt(msg, contextHint);
|
|
262
338
|
// MCP server config passed via --mcp-config flag (no temp files needed)
|
|
263
339
|
const mcpServerPath = join(getBaseBrainPath(), 'mcp', 'slack.mjs');
|
|
264
340
|
const teamvibeApiServerPath = join(getBaseBrainPath(), 'mcp', 'teamvibe-api.mjs');
|
|
@@ -302,6 +378,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
302
378
|
`If empty: continue your work.`,
|
|
303
379
|
`You MUST check the inbox: (1) before starting work, (2) after EVERY tool call, (3) after finishing all work.`,
|
|
304
380
|
`Never skip an inbox check. This is how the user communicates with you during long tasks.`,
|
|
381
|
+
...(secretsReminder ? ['', secretsReminder] : []),
|
|
305
382
|
].join('\n'));
|
|
306
383
|
// Handle session continuity
|
|
307
384
|
if (sessionId) {
|
|
@@ -323,12 +400,27 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
323
400
|
const proc = spawn(config.CLAUDE_CLI_PATH, args, {
|
|
324
401
|
cwd,
|
|
325
402
|
env: {
|
|
403
|
+
// Secrets platform (#105) injection — SOLE call site of
|
|
404
|
+
// `SpawnSecretsEnvelope.unwrap()` in the entire poller codebase.
|
|
405
|
+
// Placed FIRST so subsequent spreads can overwrite. Belt-and-
|
|
406
|
+
// suspenders: even if the API's reserved-prefix validator fails
|
|
407
|
+
// (`AWS_`, `TEAMVIBE_`, `SLACK_`, `CLAUDE_CODE_`, `ANTHROPIC_`)
|
|
408
|
+
// a name collision is silently won by the platform variable
|
|
409
|
+
// explicit field below.
|
|
410
|
+
//
|
|
411
|
+
// Phase 2 of teamvibe.ai#215 (poller v0.5.1): the explicit
|
|
412
|
+
// `SLACK_BOT_TOKEN: msg.teamvibe.botToken` injection that used
|
|
413
|
+
// to live here was removed. `SLACK_BOT_TOKEN` now sources from
|
|
414
|
+
// the `/secrets/spawn` platform overlay (the spread above) —
|
|
415
|
+
// backend v0.2.2+ resolves it via the `Channel → SlackBot`
|
|
416
|
+
// relation. Requires backend on Phase 1; pollers on 0.5.0 still
|
|
417
|
+
// read the explicit field, so split rollout is safe.
|
|
418
|
+
...spawnSecrets.env.unwrap(),
|
|
326
419
|
...process.env,
|
|
327
420
|
CLAUDECODE: undefined,
|
|
328
421
|
PATH: envPath,
|
|
329
422
|
CI: 'true',
|
|
330
423
|
CLAUDE_CONFIG_DIR: getBaseBrainPath(),
|
|
331
|
-
SLACK_BOT_TOKEN: msg.teamvibe.botToken,
|
|
332
424
|
...(config.PERSISTENT_STORAGE_PATH && {
|
|
333
425
|
PERSISTENT_STORAGE_PATH: config.PERSISTENT_STORAGE_PATH,
|
|
334
426
|
}),
|
|
@@ -439,15 +531,14 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
439
531
|
networkErrorStart = null;
|
|
440
532
|
}
|
|
441
533
|
});
|
|
442
|
-
let timedOut = false;
|
|
443
534
|
const timeoutMinutes = Math.round(config.CLAUDE_TIMEOUT_MS / 60000);
|
|
444
535
|
const timeout = setTimeout(() => {
|
|
445
|
-
timedOut = true;
|
|
446
536
|
logger.error(`Claude Code process timed out after ${timeoutMinutes} minutes`);
|
|
447
537
|
emitError('session', `Claude Code process timed out after ${timeoutMinutes} minutes`, {
|
|
448
538
|
context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
|
|
449
539
|
channelId: msg.teamvibe.channelId,
|
|
450
540
|
});
|
|
541
|
+
killReasonByProc.set(proc, 'timeout');
|
|
451
542
|
proc.kill('SIGTERM');
|
|
452
543
|
setTimeout(() => proc.kill('SIGKILL'), 5000);
|
|
453
544
|
}, config.CLAUDE_TIMEOUT_MS);
|
|
@@ -465,6 +556,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
465
556
|
context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
|
|
466
557
|
channelId: msg.teamvibe.channelId,
|
|
467
558
|
});
|
|
559
|
+
killReasonByProc.set(proc, 'network');
|
|
468
560
|
proc.kill('SIGTERM');
|
|
469
561
|
setTimeout(() => proc.kill('SIGKILL'), 5000);
|
|
470
562
|
return;
|
|
@@ -477,6 +569,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
477
569
|
context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
|
|
478
570
|
channelId: msg.teamvibe.channelId,
|
|
479
571
|
});
|
|
572
|
+
killReasonByProc.set(proc, 'idle');
|
|
480
573
|
proc.kill('SIGTERM');
|
|
481
574
|
setTimeout(() => proc.kill('SIGKILL'), 5000);
|
|
482
575
|
}
|
|
@@ -496,15 +589,26 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
496
589
|
clearInterval(idleWatchdog);
|
|
497
590
|
activeProcesses.delete(msg.id);
|
|
498
591
|
activeProcessesByThread.delete(msg.thread_id);
|
|
592
|
+
const killReason = killReasonByProc.get(proc);
|
|
499
593
|
const result = {
|
|
500
594
|
success: code === 0,
|
|
501
595
|
output: stdout,
|
|
502
596
|
exitCode: code,
|
|
503
597
|
metrics,
|
|
598
|
+
...(killReason ? { killedByPoller: killReason } : {}),
|
|
504
599
|
};
|
|
505
|
-
if (
|
|
600
|
+
if (killReason === 'timeout') {
|
|
506
601
|
result.error = `Session timed out after ${timeoutMinutes} minutes. The task was still running when the time limit was reached. Consider breaking complex tasks into smaller steps.`;
|
|
507
602
|
}
|
|
603
|
+
else if (killReason === 'idle') {
|
|
604
|
+
result.error = `Killed by idle watchdog (no output for >${Math.round(config.IDLE_WATCHDOG_KILL_MS / 60000)} minutes)`;
|
|
605
|
+
}
|
|
606
|
+
else if (killReason === 'network') {
|
|
607
|
+
result.error = 'Killed by network watchdog (sustained network errors)';
|
|
608
|
+
}
|
|
609
|
+
else if (killReason === 'zombie') {
|
|
610
|
+
result.error = 'Killed as zombie process (stale session lock)';
|
|
611
|
+
}
|
|
508
612
|
else if (code !== 0) {
|
|
509
613
|
result.error = stderr || `Process exited with code ${code}`;
|
|
510
614
|
}
|
|
@@ -532,7 +636,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
532
636
|
* Sources whose sessions should run in an isolated git worktree (teamvibe.ai#120).
|
|
533
637
|
* Foreground Slack messages always run in the main working dir.
|
|
534
638
|
*/
|
|
535
|
-
const ISOLATED_SOURCES = new Set(['cron', '
|
|
639
|
+
const ISOLATED_SOURCES = new Set(['cron', 'maintenance']);
|
|
536
640
|
function shouldIsolateInWorktree(msg) {
|
|
537
641
|
return isWorktreeIsolationEnabled() && ISOLATED_SOURCES.has(msg.source);
|
|
538
642
|
}
|
|
@@ -603,6 +707,7 @@ export function killZombieForThread(threadId) {
|
|
|
603
707
|
}
|
|
604
708
|
logger.warn(`Killing zombie process for thread ${threadId} (pid: ${proc.pid})`);
|
|
605
709
|
emitError('session', `Killing zombie process for thread ${threadId}`, { severity: 'warn' });
|
|
710
|
+
killReasonByProc.set(proc, 'zombie');
|
|
606
711
|
proc.kill('SIGTERM');
|
|
607
712
|
setTimeout(() => {
|
|
608
713
|
if (proc.exitCode === null) {
|
package/dist/config.d.ts
CHANGED
|
@@ -29,7 +29,6 @@ declare const configSchema: z.ZodObject<{
|
|
|
29
29
|
BRAIN_AUTO_UPDATE: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
|
|
30
30
|
BRAIN_UPDATE_COOLDOWN_MS: z.ZodDefault<z.ZodNumber>;
|
|
31
31
|
POLLER_TZ: z.ZodOptional<z.ZodString>;
|
|
32
|
-
HEARTBEAT_CRON: z.ZodDefault<z.ZodString>;
|
|
33
32
|
MAINTENANCE_CRON: z.ZodDefault<z.ZodString>;
|
|
34
33
|
MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
|
|
35
34
|
AUTO_UPDATE_POLICY: z.ZodDefault<z.ZodEnum<["patch", "minor", "off"]>>;
|
|
@@ -59,7 +58,6 @@ declare const configSchema: z.ZodObject<{
|
|
|
59
58
|
BASE_BRAIN_PATH: string;
|
|
60
59
|
BRAIN_AUTO_UPDATE: boolean;
|
|
61
60
|
BRAIN_UPDATE_COOLDOWN_MS: number;
|
|
62
|
-
HEARTBEAT_CRON: string;
|
|
63
61
|
MAINTENANCE_CRON: string;
|
|
64
62
|
MAINTENANCE_ENABLED: boolean;
|
|
65
63
|
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
@@ -97,7 +95,6 @@ declare const configSchema: z.ZodObject<{
|
|
|
97
95
|
BRAIN_AUTO_UPDATE?: string | undefined;
|
|
98
96
|
BRAIN_UPDATE_COOLDOWN_MS?: number | undefined;
|
|
99
97
|
POLLER_TZ?: string | undefined;
|
|
100
|
-
HEARTBEAT_CRON?: string | undefined;
|
|
101
98
|
MAINTENANCE_CRON?: string | undefined;
|
|
102
99
|
MAINTENANCE_ENABLED?: string | undefined;
|
|
103
100
|
AUTO_UPDATE_POLICY?: "patch" | "minor" | "off" | undefined;
|
|
@@ -130,7 +127,6 @@ export declare const config: {
|
|
|
130
127
|
BASE_BRAIN_PATH: string;
|
|
131
128
|
BRAIN_AUTO_UPDATE: boolean;
|
|
132
129
|
BRAIN_UPDATE_COOLDOWN_MS: number;
|
|
133
|
-
HEARTBEAT_CRON: string;
|
|
134
130
|
MAINTENANCE_CRON: string;
|
|
135
131
|
MAINTENANCE_ENABLED: boolean;
|
|
136
132
|
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
package/dist/config.js
CHANGED
|
@@ -43,14 +43,13 @@ const configSchema = z.object({
|
|
|
43
43
|
.default('true')
|
|
44
44
|
.transform((v) => v.toLowerCase() === 'true'),
|
|
45
45
|
BRAIN_UPDATE_COOLDOWN_MS: z.coerce.number().default(300000), // 5 minutes
|
|
46
|
-
// Brain
|
|
47
|
-
// POLLER_TZ: optional IANA timezone for
|
|
46
|
+
// Brain maintenance (cron-based)
|
|
47
|
+
// POLLER_TZ: optional IANA timezone for MAINTENANCE_CRON.
|
|
48
48
|
// When unset, croner uses the host system timezone (e.g. `Europe/Prague` on
|
|
49
49
|
// a CZ Mac mini, `UTC` on a default Railway container). Set explicitly
|
|
50
50
|
// (e.g. `POLLER_TZ=Europe/Prague` on Railway) when the host TZ doesn't match
|
|
51
51
|
// the intended cron interpretation. DST is handled by croner automatically.
|
|
52
52
|
POLLER_TZ: z.string().optional(),
|
|
53
|
-
HEARTBEAT_CRON: z.string().default('0 */6 * * *'), // Every 6 hours
|
|
54
53
|
MAINTENANCE_CRON: z.string().default('30 3 * * *'), // Daily at 03:30 in POLLER_TZ (or host TZ)
|
|
55
54
|
MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
|
|
56
55
|
// Auto-update settings
|
package/dist/cron-telemetry.d.ts
CHANGED
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module-scoped state for poller-level cron-job telemetry.
|
|
3
3
|
*
|
|
4
|
-
* Per teamvibeai/teamvibe.ai#119, the metrics collector reports
|
|
5
|
-
* last{
|
|
4
|
+
* Per teamvibeai/teamvibe.ai#119, the metrics collector reports four measures:
|
|
5
|
+
* last{Maintenance,AutoUpdate}Job{,Success}Ms
|
|
6
6
|
* each computed as `Date.now() - storedTs` at collection time, with the
|
|
7
7
|
* measure omitted entirely when the cron has never fired (storedTs null).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Lives in its own module to avoid a circular import — the metrics collector
|
|
10
|
+
* reads from these getters, and maintenance-cron.ts already imports from the
|
|
11
|
+
* collector.
|
|
12
12
|
*/
|
|
13
|
-
export declare function markHeartbeatJobFired(at?: number): void;
|
|
14
|
-
export declare function markHeartbeatJobSuccess(at?: number): void;
|
|
15
13
|
export declare function markMaintenanceJobFired(at?: number): void;
|
|
16
14
|
export declare function markMaintenanceJobSuccess(at?: number): void;
|
|
17
15
|
export declare function markAutoUpdateJobFired(at?: number): void;
|
|
18
16
|
export declare function markAutoUpdateJobSuccess(at?: number): void;
|
|
19
17
|
export interface CronJobTimestamps {
|
|
20
|
-
lastHeartbeatJobFiredAt: number | null;
|
|
21
|
-
lastHeartbeatJobSuccessAt: number | null;
|
|
22
18
|
lastMaintenanceJobFiredAt: number | null;
|
|
23
19
|
lastMaintenanceJobSuccessAt: number | null;
|
|
24
20
|
lastAutoUpdateJobFiredAt: number | null;
|
package/dist/cron-telemetry.js
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module-scoped state for poller-level cron-job telemetry.
|
|
3
3
|
*
|
|
4
|
-
* Per teamvibeai/teamvibe.ai#119, the metrics collector reports
|
|
5
|
-
* last{
|
|
4
|
+
* Per teamvibeai/teamvibe.ai#119, the metrics collector reports four measures:
|
|
5
|
+
* last{Maintenance,AutoUpdate}Job{,Success}Ms
|
|
6
6
|
* each computed as `Date.now() - storedTs` at collection time, with the
|
|
7
7
|
* measure omitted entirely when the cron has never fired (storedTs null).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Lives in its own module to avoid a circular import — the metrics collector
|
|
10
|
+
* reads from these getters, and maintenance-cron.ts already imports from the
|
|
11
|
+
* collector.
|
|
12
12
|
*/
|
|
13
|
-
let lastHeartbeatJobFiredAt = null;
|
|
14
|
-
let lastHeartbeatJobSuccessAt = null;
|
|
15
13
|
let lastMaintenanceJobFiredAt = null;
|
|
16
14
|
let lastMaintenanceJobSuccessAt = null;
|
|
17
15
|
let lastAutoUpdateJobFiredAt = null;
|
|
18
16
|
let lastAutoUpdateJobSuccessAt = null;
|
|
19
|
-
export function markHeartbeatJobFired(at = Date.now()) {
|
|
20
|
-
lastHeartbeatJobFiredAt = at;
|
|
21
|
-
}
|
|
22
|
-
export function markHeartbeatJobSuccess(at = Date.now()) {
|
|
23
|
-
lastHeartbeatJobSuccessAt = at;
|
|
24
|
-
}
|
|
25
17
|
export function markMaintenanceJobFired(at = Date.now()) {
|
|
26
18
|
lastMaintenanceJobFiredAt = at;
|
|
27
19
|
}
|
|
@@ -36,8 +28,6 @@ export function markAutoUpdateJobSuccess(at = Date.now()) {
|
|
|
36
28
|
}
|
|
37
29
|
export function getCronJobTimestamps() {
|
|
38
30
|
return {
|
|
39
|
-
lastHeartbeatJobFiredAt,
|
|
40
|
-
lastHeartbeatJobSuccessAt,
|
|
41
31
|
lastMaintenanceJobFiredAt,
|
|
42
32
|
lastMaintenanceJobSuccessAt,
|
|
43
33
|
lastAutoUpdateJobFiredAt,
|
|
@@ -49,8 +39,6 @@ export function getCronJobTimestamps() {
|
|
|
49
39
|
* @internal
|
|
50
40
|
*/
|
|
51
41
|
export function __resetCronTelemetryForTest() {
|
|
52
|
-
lastHeartbeatJobFiredAt = null;
|
|
53
|
-
lastHeartbeatJobSuccessAt = null;
|
|
54
42
|
lastMaintenanceJobFiredAt = null;
|
|
55
43
|
lastMaintenanceJobSuccessAt = null;
|
|
56
44
|
lastAutoUpdateJobFiredAt = null;
|
|
@@ -1,39 +1,28 @@
|
|
|
1
1
|
import type { TeamVibeQueueMessage } from './types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Register a brain from a real message so
|
|
3
|
+
* Register a brain from a real message so the maintenance cron knows about it.
|
|
4
4
|
* Called from processMessage() after brain path is resolved.
|
|
5
5
|
*/
|
|
6
6
|
export declare function registerBrain(msg: TeamVibeQueueMessage): void;
|
|
7
7
|
/**
|
|
8
|
-
* Start the cron-based
|
|
9
|
-
*
|
|
10
|
-
* - Heartbeat: lightweight, frequent (default: every 5 min)
|
|
11
|
-
* - Maintenance: heavy, daily (default: 03:30 UTC)
|
|
8
|
+
* Start the cron-based maintenance schedule (default: daily 03:30 in POLLER_TZ).
|
|
12
9
|
*/
|
|
13
|
-
export declare function
|
|
10
|
+
export declare function startMaintenanceLoop(processMessageFn: (msg: {
|
|
14
11
|
queueMessage: TeamVibeQueueMessage;
|
|
15
12
|
receiptHandle: string;
|
|
16
13
|
messageId: string;
|
|
17
14
|
}) => Promise<void>): void;
|
|
18
15
|
/**
|
|
19
|
-
* Run the
|
|
16
|
+
* Run the maintenance cron handler with telemetry instrumentation (#119).
|
|
20
17
|
* Records last-fire timestamp on entry and last-success timestamp on completion.
|
|
21
18
|
* Exported for use by the cron registration above and tests that fire it manually.
|
|
22
19
|
*/
|
|
23
|
-
export declare function runHeartbeatJob(processMessageFn: (msg: {
|
|
24
|
-
queueMessage: TeamVibeQueueMessage;
|
|
25
|
-
receiptHandle: string;
|
|
26
|
-
messageId: string;
|
|
27
|
-
}) => Promise<void>): Promise<void>;
|
|
28
|
-
/**
|
|
29
|
-
* Run the maintenance cron handler with telemetry instrumentation (#119).
|
|
30
|
-
*/
|
|
31
20
|
export declare function runMaintenanceJob(processMessageFn: (msg: {
|
|
32
21
|
queueMessage: TeamVibeQueueMessage;
|
|
33
22
|
receiptHandle: string;
|
|
34
23
|
messageId: string;
|
|
35
24
|
}) => Promise<void>): Promise<void>;
|
|
36
25
|
/**
|
|
37
|
-
* Stop the
|
|
26
|
+
* Stop the maintenance cron (for shutdown).
|
|
38
27
|
*/
|
|
39
|
-
export declare function
|
|
28
|
+
export declare function stopMaintenanceLoop(): void;
|
|
@@ -6,21 +6,20 @@ 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 {
|
|
9
|
+
import { markMaintenanceJobFired, markMaintenanceJobSuccess, } from './cron-telemetry.js';
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
const trackedBrains = new Map();
|
|
12
|
-
let heartbeatJob = null;
|
|
13
12
|
let maintenanceJob = null;
|
|
14
13
|
/**
|
|
15
|
-
* Register a brain from a real message so
|
|
14
|
+
* Register a brain from a real message so the maintenance cron knows about it.
|
|
16
15
|
* Called from processMessage() after brain path is resolved.
|
|
17
16
|
*/
|
|
18
17
|
export function registerBrain(msg) {
|
|
19
18
|
const brain = msg.teamvibe.brain;
|
|
20
19
|
if (!brain?.brainId)
|
|
21
20
|
return;
|
|
22
|
-
// Don't register from
|
|
23
|
-
if (msg.source === '
|
|
21
|
+
// Don't register from maintenance messages (avoid self-referential loop)
|
|
22
|
+
if (msg.source === 'maintenance')
|
|
24
23
|
return;
|
|
25
24
|
const isNew = !trackedBrains.has(brain.brainId);
|
|
26
25
|
trackedBrains.set(brain.brainId, {
|
|
@@ -32,7 +31,7 @@ export function registerBrain(msg) {
|
|
|
32
31
|
botToken: msg.teamvibe.botToken,
|
|
33
32
|
channelId: msg.teamvibe.channelId,
|
|
34
33
|
});
|
|
35
|
-
logger.debug(`[
|
|
34
|
+
logger.debug(`[Maintenance] Registered brain: ${brain.brainId}`);
|
|
36
35
|
// Metrics pipeline (teamvibe.ai#110): register for hourly collection. On the
|
|
37
36
|
// first sighting of this brain, fire one immediate collect+send so we don't
|
|
38
37
|
// wait up to an hour for the first sample.
|
|
@@ -52,25 +51,25 @@ export function registerBrain(msg) {
|
|
|
52
51
|
}
|
|
53
52
|
}
|
|
54
53
|
/**
|
|
55
|
-
* Send a synthetic message to all tracked brains.
|
|
54
|
+
* Send a synthetic maintenance message to all tracked brains.
|
|
56
55
|
*/
|
|
57
|
-
async function triggerAllBrains(
|
|
56
|
+
async function triggerAllBrains(processMessageFn) {
|
|
58
57
|
const now = Date.now();
|
|
59
58
|
for (const [brainId, info] of trackedBrains.entries()) {
|
|
60
59
|
if (isAtCapacity()) {
|
|
61
|
-
logger.debug(`[
|
|
60
|
+
logger.debug(`[Maintenance] At capacity, skipping ${brainId}`);
|
|
62
61
|
continue;
|
|
63
62
|
}
|
|
64
|
-
logger.info(`[
|
|
63
|
+
logger.info(`[Maintenance] Triggering maintenance for brain ${brainId}`);
|
|
65
64
|
const syntheticMessage = {
|
|
66
|
-
id:
|
|
67
|
-
source,
|
|
68
|
-
thread_id:
|
|
65
|
+
id: `maintenance_${brainId}_${now}`,
|
|
66
|
+
source: 'maintenance',
|
|
67
|
+
thread_id: `maintenance_${brainId}_${now}`,
|
|
69
68
|
sender: {
|
|
70
69
|
id: 'system',
|
|
71
70
|
name: 'System',
|
|
72
71
|
},
|
|
73
|
-
text:
|
|
72
|
+
text: 'maintenance',
|
|
74
73
|
attachments: [],
|
|
75
74
|
response_context: {},
|
|
76
75
|
type: 'message',
|
|
@@ -94,8 +93,8 @@ async function triggerAllBrains(source, processMessageFn) {
|
|
|
94
93
|
});
|
|
95
94
|
}
|
|
96
95
|
catch (error) {
|
|
97
|
-
logger.error(`[
|
|
98
|
-
emitError('session',
|
|
96
|
+
logger.error(`[Maintenance] Failed for ${brainId}: ${error instanceof Error ? error.message : error}`);
|
|
97
|
+
emitError('session', `maintenance failed for ${brainId}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
99
98
|
channelId: info.channelId,
|
|
100
99
|
context: { brainId },
|
|
101
100
|
});
|
|
@@ -103,14 +102,11 @@ async function triggerAllBrains(source, processMessageFn) {
|
|
|
103
102
|
}
|
|
104
103
|
}
|
|
105
104
|
/**
|
|
106
|
-
* Start the cron-based
|
|
107
|
-
*
|
|
108
|
-
* - Heartbeat: lightweight, frequent (default: every 5 min)
|
|
109
|
-
* - Maintenance: heavy, daily (default: 03:30 UTC)
|
|
105
|
+
* Start the cron-based maintenance schedule (default: daily 03:30 in POLLER_TZ).
|
|
110
106
|
*/
|
|
111
|
-
export function
|
|
107
|
+
export function startMaintenanceLoop(processMessageFn) {
|
|
112
108
|
if (!config.MAINTENANCE_ENABLED) {
|
|
113
|
-
logger.info('[
|
|
109
|
+
logger.info('[Maintenance] Maintenance disabled via config');
|
|
114
110
|
return;
|
|
115
111
|
}
|
|
116
112
|
// Croner uses the host system TZ when `timezone` option is omitted.
|
|
@@ -120,58 +116,34 @@ export function startHeartbeatLoop(processMessageFn) {
|
|
|
120
116
|
? { timezone: config.POLLER_TZ, protect: true }
|
|
121
117
|
: { protect: true };
|
|
122
118
|
const tzLabel = config.POLLER_TZ ?? `host (${Intl.DateTimeFormat().resolvedOptions().timeZone})`;
|
|
123
|
-
// Heartbeat cron — frequent, lightweight
|
|
124
|
-
logger.info(`[Heartbeat] Starting heartbeat cron: "${config.HEARTBEAT_CRON}" (tz: ${tzLabel})`);
|
|
125
|
-
heartbeatJob = new Cron(config.HEARTBEAT_CRON, cronOpts, async () => {
|
|
126
|
-
await runHeartbeatJob(processMessageFn);
|
|
127
|
-
});
|
|
128
|
-
// Maintenance cron — daily, heavy (consolidation, memory cleanup)
|
|
129
119
|
logger.info(`[Maintenance] Starting maintenance cron: "${config.MAINTENANCE_CRON}" (tz: ${tzLabel})`);
|
|
130
120
|
maintenanceJob = new Cron(config.MAINTENANCE_CRON, cronOpts, async () => {
|
|
131
121
|
await runMaintenanceJob(processMessageFn);
|
|
132
122
|
});
|
|
133
123
|
}
|
|
134
124
|
/**
|
|
135
|
-
* Run the
|
|
125
|
+
* Run the maintenance cron handler with telemetry instrumentation (#119).
|
|
136
126
|
* Records last-fire timestamp on entry and last-success timestamp on completion.
|
|
137
127
|
* Exported for use by the cron registration above and tests that fire it manually.
|
|
138
128
|
*/
|
|
139
|
-
export async function runHeartbeatJob(processMessageFn) {
|
|
140
|
-
markHeartbeatJobFired();
|
|
141
|
-
try {
|
|
142
|
-
await triggerAllBrains('heartbeat', processMessageFn);
|
|
143
|
-
markHeartbeatJobSuccess();
|
|
144
|
-
}
|
|
145
|
-
catch (e) {
|
|
146
|
-
logger.error(`[Heartbeat] heartbeatJob handler threw: ${e instanceof Error ? e.message : e}`);
|
|
147
|
-
throw e; // preserve existing behavior; croner protect handles overlap
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Run the maintenance cron handler with telemetry instrumentation (#119).
|
|
152
|
-
*/
|
|
153
129
|
export async function runMaintenanceJob(processMessageFn) {
|
|
154
130
|
markMaintenanceJobFired();
|
|
155
131
|
try {
|
|
156
|
-
await triggerAllBrains(
|
|
132
|
+
await triggerAllBrains(processMessageFn);
|
|
157
133
|
markMaintenanceJobSuccess();
|
|
158
134
|
}
|
|
159
135
|
catch (e) {
|
|
160
136
|
logger.error(`[Maintenance] maintenanceJob handler threw: ${e instanceof Error ? e.message : e}`);
|
|
161
|
-
throw e;
|
|
137
|
+
throw e; // preserve existing behavior; croner protect handles overlap
|
|
162
138
|
}
|
|
163
139
|
}
|
|
164
140
|
/**
|
|
165
|
-
* Stop the
|
|
141
|
+
* Stop the maintenance cron (for shutdown).
|
|
166
142
|
*/
|
|
167
|
-
export function
|
|
168
|
-
if (heartbeatJob) {
|
|
169
|
-
heartbeatJob.stop();
|
|
170
|
-
heartbeatJob = null;
|
|
171
|
-
}
|
|
143
|
+
export function stopMaintenanceLoop() {
|
|
172
144
|
if (maintenanceJob) {
|
|
173
145
|
maintenanceJob.stop();
|
|
174
146
|
maintenanceJob = null;
|
|
175
147
|
}
|
|
176
|
-
logger.info('[
|
|
148
|
+
logger.info('[Maintenance] Stopped maintenance cron');
|
|
177
149
|
}
|
|
@@ -11,8 +11,6 @@ export interface MetricsPayload {
|
|
|
11
11
|
todayMdLines?: number;
|
|
12
12
|
summaryMdBytes?: number;
|
|
13
13
|
summaryMdLines?: number;
|
|
14
|
-
heartbeatMdBytes?: number;
|
|
15
|
-
heartbeatMdLines?: number;
|
|
16
14
|
brainDirBytes?: number;
|
|
17
15
|
vmDiskFreeBytes?: number;
|
|
18
16
|
vmDiskUsedBytes?: number;
|
|
@@ -21,8 +19,6 @@ export interface MetricsPayload {
|
|
|
21
19
|
oldestInboxMessageAgeSeconds?: number;
|
|
22
20
|
uncommittedChangeCount?: number;
|
|
23
21
|
daysSinceMaintenance?: number;
|
|
24
|
-
lastHeartbeatJobMs?: number;
|
|
25
|
-
lastHeartbeatJobSuccessMs?: number;
|
|
26
22
|
lastMaintenanceJobMs?: number;
|
|
27
23
|
lastMaintenanceJobSuccessMs?: number;
|
|
28
24
|
lastAutoUpdateJobMs?: number;
|
|
@@ -7,6 +7,10 @@ import { getCronJobTimestamps } from '../cron-telemetry.js';
|
|
|
7
7
|
const statfsAsync = promisify(statfs);
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
9
|
const MS_PER_DAY = 86_400_000;
|
|
10
|
+
// Directory names skipped at any depth during brainDirBytes walk.
|
|
11
|
+
// Holds `du --exclude=.git --exclude=node_modules` parity for the brain dir signal.
|
|
12
|
+
// See teamvibeai/teamvibe.ai#114.
|
|
13
|
+
const EXCLUDED_DIR_NAMES = new Set(['.git', 'node_modules']);
|
|
10
14
|
/**
|
|
11
15
|
* Recursively sum file sizes under a directory.
|
|
12
16
|
* Skips entries we cannot stat (permission errors, broken symlinks).
|
|
@@ -21,6 +25,8 @@ async function dirSizeBytes(path) {
|
|
|
21
25
|
return 0;
|
|
22
26
|
}
|
|
23
27
|
for (const entry of entries) {
|
|
28
|
+
if (entry.isDirectory() && EXCLUDED_DIR_NAMES.has(entry.name))
|
|
29
|
+
continue;
|
|
24
30
|
const full = join(path, entry.name);
|
|
25
31
|
try {
|
|
26
32
|
if (entry.isDirectory()) {
|
|
@@ -135,19 +141,6 @@ export async function collectMetrics(brainPath, brainId) {
|
|
|
135
141
|
catch {
|
|
136
142
|
// omit
|
|
137
143
|
}
|
|
138
|
-
// HEARTBEAT.md size + line count — see poller-brain#101. The *number of
|
|
139
|
-
// items* in the file is what drives the Step 8b sweep crash risk, so we
|
|
140
|
-
// report size + line count rather than a binary presence flag. Both omitted
|
|
141
|
-
// on any error (file missing or unreadable).
|
|
142
|
-
try {
|
|
143
|
-
const heartbeatPath = join(brainPath, 'HEARTBEAT.md');
|
|
144
|
-
const s = await stat(heartbeatPath);
|
|
145
|
-
payload.heartbeatMdBytes = s.size;
|
|
146
|
-
payload.heartbeatMdLines = await countLines(heartbeatPath);
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
// omit
|
|
150
|
-
}
|
|
151
144
|
// brain dir total size
|
|
152
145
|
try {
|
|
153
146
|
payload.brainDirBytes = await dirSizeBytes(brainPath);
|
|
@@ -224,17 +217,11 @@ export async function collectMetrics(brainPath, brainId) {
|
|
|
224
217
|
catch {
|
|
225
218
|
// Non-git brain, missing git binary, timeout — omit.
|
|
226
219
|
}
|
|
227
|
-
// Poller-level cron-job telemetry (#119). Same
|
|
220
|
+
// Poller-level cron-job telemetry (#119). Same four values for every brain on
|
|
228
221
|
// the poller — see issue body for storage cost rationale. Each measure is
|
|
229
222
|
// `now - storedTs`; if the cron has never fired (storedTs null), omit it.
|
|
230
223
|
const now = Date.now();
|
|
231
224
|
const cron = getCronJobTimestamps();
|
|
232
|
-
if (cron.lastHeartbeatJobFiredAt !== null) {
|
|
233
|
-
payload.lastHeartbeatJobMs = now - cron.lastHeartbeatJobFiredAt;
|
|
234
|
-
}
|
|
235
|
-
if (cron.lastHeartbeatJobSuccessAt !== null) {
|
|
236
|
-
payload.lastHeartbeatJobSuccessMs = now - cron.lastHeartbeatJobSuccessAt;
|
|
237
|
-
}
|
|
238
225
|
if (cron.lastMaintenanceJobFiredAt !== null) {
|
|
239
226
|
payload.lastMaintenanceJobMs = now - cron.lastMaintenanceJobFiredAt;
|
|
240
227
|
}
|
package/dist/poller.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare function releaseAllActiveLocks(locks: Map<string, string>, releas
|
|
|
10
10
|
* `checkForUpdateFn` is injected (default = real checkForUpdate) so tests
|
|
11
11
|
* can lock in the "swallow but still mark fired" contract that
|
|
12
12
|
* distinguishes scenario (A) cron-doesn't-fire from (B/C) cron-fires-but-fails
|
|
13
|
-
* in morning-check telemetry. Same DI shape as
|
|
13
|
+
* in morning-check telemetry. Same DI shape as runMaintenanceJob.
|
|
14
14
|
*/
|
|
15
15
|
export declare function runAutoUpdateJob(checkForUpdateFn?: () => Promise<boolean>): Promise<void>;
|
|
16
16
|
export declare function startPoller(): Promise<void>;
|
package/dist/poller.js
CHANGED
|
@@ -2,13 +2,14 @@ import { config } from './config.js';
|
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { pollMessages, deleteMessage, } from './sqs-poller.js';
|
|
4
4
|
import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
|
|
5
|
-
import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
|
|
5
|
+
import { sendSlackError, sendSlackMessage, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
|
|
6
|
+
import { classifyFailedIteration } from './classify-failed-iteration.js';
|
|
6
7
|
import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
|
|
7
8
|
import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
|
|
8
9
|
import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
|
|
9
10
|
import { postMaintenanceReports } from './maintenance-reporter.js';
|
|
10
11
|
import { initAuth, stopRefresh } from './auth-provider.js';
|
|
11
|
-
import { registerBrain,
|
|
12
|
+
import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './maintenance-cron.js';
|
|
12
13
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent } from './event-queue.js';
|
|
13
14
|
import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
|
|
14
15
|
import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
|
|
@@ -39,7 +40,7 @@ export async function releaseAllActiveLocks(locks, release, log = logger) {
|
|
|
39
40
|
})));
|
|
40
41
|
locks.clear();
|
|
41
42
|
}
|
|
42
|
-
// Poller auto-update cron (separate from
|
|
43
|
+
// Poller auto-update cron (separate from the maintenance cron)
|
|
43
44
|
let autoUpdateJob = null;
|
|
44
45
|
/**
|
|
45
46
|
* Run the auto-update cron handler with telemetry instrumentation (#119).
|
|
@@ -48,7 +49,7 @@ let autoUpdateJob = null;
|
|
|
48
49
|
* `checkForUpdateFn` is injected (default = real checkForUpdate) so tests
|
|
49
50
|
* can lock in the "swallow but still mark fired" contract that
|
|
50
51
|
* distinguishes scenario (A) cron-doesn't-fire from (B/C) cron-fires-but-fails
|
|
51
|
-
* in morning-check telemetry. Same DI shape as
|
|
52
|
+
* in morning-check telemetry. Same DI shape as runMaintenanceJob.
|
|
52
53
|
*/
|
|
53
54
|
export async function runAutoUpdateJob(checkForUpdateFn = checkForUpdate) {
|
|
54
55
|
markAutoUpdateJobFired();
|
|
@@ -220,10 +221,14 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
|
220
221
|
lastExitCode = result.exitCode;
|
|
221
222
|
sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
|
|
222
223
|
if (hasSlackContext) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
const decision = classifyFailedIteration(result);
|
|
225
|
+
if (decision.kind === 'neutral') {
|
|
226
|
+
await sendSlackMessage(queueMessage, decision.text);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
await sendSlackError(queueMessage, decision.text);
|
|
230
|
+
}
|
|
231
|
+
await addReaction(queueMessage, decision.reaction);
|
|
227
232
|
}
|
|
228
233
|
}
|
|
229
234
|
}
|
|
@@ -275,7 +280,7 @@ async function processMessage(received) {
|
|
|
275
280
|
const threadId = queueMessage.thread_id;
|
|
276
281
|
const sessionLog = logger.createSession(logSessionId);
|
|
277
282
|
// Fetch user info if we only have ID
|
|
278
|
-
if (queueMessage.source !== 'cron' && queueMessage.source !== '
|
|
283
|
+
if (queueMessage.source !== 'cron' && queueMessage.source !== 'maintenance' &&
|
|
279
284
|
queueMessage.sender.name === 'Unknown User' &&
|
|
280
285
|
queueMessage.sender.id !== 'unknown') {
|
|
281
286
|
const userInfo = await getUserInfo(queueMessage.teamvibe.botToken, queueMessage.sender.id);
|
|
@@ -311,7 +316,7 @@ async function processMessage(received) {
|
|
|
311
316
|
}
|
|
312
317
|
return;
|
|
313
318
|
}
|
|
314
|
-
// Register brain
|
|
319
|
+
// Register brain so the maintenance cron knows about it.
|
|
315
320
|
registerBrain(queueMessage);
|
|
316
321
|
// Write raw message to inbox (spawnClaudeCode handles prompt formatting)
|
|
317
322
|
writeMessage(kbPath, threadId, JSON.stringify({
|
|
@@ -501,7 +506,7 @@ async function shutdown(signal, exitCode = 0) {
|
|
|
501
506
|
shuttingDown = true;
|
|
502
507
|
logger.info(`${signal} received, shutting down gracefully...`);
|
|
503
508
|
stopRefresh();
|
|
504
|
-
|
|
509
|
+
stopMaintenanceLoop();
|
|
505
510
|
stopAutoUpdateCron();
|
|
506
511
|
stopMetricsCron();
|
|
507
512
|
stopEventQueue();
|
|
@@ -549,7 +554,7 @@ export async function startPoller() {
|
|
|
549
554
|
const sweep = await cleanupOrphanWorktreesForAllBrains(config.BRAINS_PATH);
|
|
550
555
|
logger.info(` Orphan worktree sweep: scanned ${sweep.scanned} brains, removed ${sweep.removed} stale worktrees`);
|
|
551
556
|
}
|
|
552
|
-
|
|
557
|
+
startMaintenanceLoop(processMessage);
|
|
553
558
|
startAutoUpdateCron();
|
|
554
559
|
startMetricsCron();
|
|
555
560
|
await pollLoop();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poller-side secrets client — teamvibe.ai#105 (PR4).
|
|
3
|
+
*
|
|
4
|
+
* Fetches per-spawn secret envelopes from the Poller API
|
|
5
|
+
* (GET /secrets/spawn?pollerId=&workspaceId=&channelId=) and shapes the
|
|
6
|
+
* response into a child-process env map plus a metadata array for system
|
|
7
|
+
* reminder rendering.
|
|
8
|
+
*
|
|
9
|
+
* Defense-in-depth principles enforced here:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Localized unwrap.** The decrypted env map is returned wrapped in
|
|
12
|
+
* an opaque container with a single `unwrap()` method. The caller
|
|
13
|
+
* (`claude-spawner.ts`) calls `unwrap()` exactly once at the child
|
|
14
|
+
* process spawn site. No other call site touches the values.
|
|
15
|
+
*
|
|
16
|
+
* 2. **Reminder rendering never sees values.** `reminders[]` carries
|
|
17
|
+
* only `{name, scope, expiresAt, overrodeScopes}`. A separate render
|
|
18
|
+
* function `buildSecretsReminder` produces the system-prompt text
|
|
19
|
+
* from this metadata; it has no access to the env map.
|
|
20
|
+
*
|
|
21
|
+
* 3. **Caller-side log redaction.** This module's logging functions
|
|
22
|
+
* explicitly never log response bodies. Error paths log only the
|
|
23
|
+
* HTTP status + a sanitised error message. We do NOT rely solely on
|
|
24
|
+
* the API's `x-tv-no-log: response-body` header — the poller is its
|
|
25
|
+
* own trust boundary.
|
|
26
|
+
*
|
|
27
|
+
* If the API call fails, `fetchSecretsForSpawn` returns an empty envelope
|
|
28
|
+
* + reminders so that spawn proceeds without secrets rather than failing
|
|
29
|
+
* the user's session. Errors are logged at warn level; production
|
|
30
|
+
* monitoring should alarm on `secrets-client.fetch_failed` metric counter.
|
|
31
|
+
*/
|
|
32
|
+
export type SecretScope = 'poller' | 'workspace' | 'channel';
|
|
33
|
+
export interface SecretReminderEntry {
|
|
34
|
+
name: string;
|
|
35
|
+
scope: SecretScope;
|
|
36
|
+
expiresAt?: string;
|
|
37
|
+
overrodeScopes?: SecretScope[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Opaque envelope around the decrypted env map. The values inside MUST NOT
|
|
41
|
+
* be read except via `unwrap()`, and `unwrap()` is intended to be called
|
|
42
|
+
* exactly once at the child-process spawn site.
|
|
43
|
+
*
|
|
44
|
+
* Default JSON / toString render `[REDACTED]`. If you ever find yourself
|
|
45
|
+
* adding a new caller of `unwrap()`, treat it as a security-relevant
|
|
46
|
+
* change and ping DevGuru.
|
|
47
|
+
*/
|
|
48
|
+
export declare class SpawnSecretsEnvelope {
|
|
49
|
+
#private;
|
|
50
|
+
constructor(env: Record<string, string>);
|
|
51
|
+
/** Sole legitimate value access. Single call site = `claude-spawner.ts`. */
|
|
52
|
+
unwrap(): Record<string, string>;
|
|
53
|
+
/** Number of secrets carried — safe for logging. */
|
|
54
|
+
size(): number;
|
|
55
|
+
toString(): string;
|
|
56
|
+
toJSON(): string;
|
|
57
|
+
}
|
|
58
|
+
export interface SpawnSecretsResult {
|
|
59
|
+
env: SpawnSecretsEnvelope;
|
|
60
|
+
reminders: SecretReminderEntry[];
|
|
61
|
+
}
|
|
62
|
+
export interface FetchSecretsInput {
|
|
63
|
+
pollerId: string;
|
|
64
|
+
workspaceId: string;
|
|
65
|
+
channelId: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Fetch secrets for a spawn. Returns an empty envelope (and empty
|
|
69
|
+
* reminders array) on any failure mode — spawn continues without
|
|
70
|
+
* secrets. The error is logged here so monitoring can alarm; do NOT
|
|
71
|
+
* pass error details into the spawn pipeline where they might surface
|
|
72
|
+
* to the user.
|
|
73
|
+
*/
|
|
74
|
+
export declare function fetchSecretsForSpawn(input: FetchSecretsInput): Promise<SpawnSecretsResult>;
|
|
75
|
+
/**
|
|
76
|
+
* Render the system-reminder text from reminder metadata. This function
|
|
77
|
+
* intentionally has NO access to the env map — its inputs are name +
|
|
78
|
+
* scope + expiration only. A future code-reader who needs to add a value
|
|
79
|
+
* to the reminder body must change the function signature, which is a
|
|
80
|
+
* loud signal to think twice.
|
|
81
|
+
*/
|
|
82
|
+
export declare function buildSecretsReminder(reminders: SecretReminderEntry[]): string;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poller-side secrets client — teamvibe.ai#105 (PR4).
|
|
3
|
+
*
|
|
4
|
+
* Fetches per-spawn secret envelopes from the Poller API
|
|
5
|
+
* (GET /secrets/spawn?pollerId=&workspaceId=&channelId=) and shapes the
|
|
6
|
+
* response into a child-process env map plus a metadata array for system
|
|
7
|
+
* reminder rendering.
|
|
8
|
+
*
|
|
9
|
+
* Defense-in-depth principles enforced here:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Localized unwrap.** The decrypted env map is returned wrapped in
|
|
12
|
+
* an opaque container with a single `unwrap()` method. The caller
|
|
13
|
+
* (`claude-spawner.ts`) calls `unwrap()` exactly once at the child
|
|
14
|
+
* process spawn site. No other call site touches the values.
|
|
15
|
+
*
|
|
16
|
+
* 2. **Reminder rendering never sees values.** `reminders[]` carries
|
|
17
|
+
* only `{name, scope, expiresAt, overrodeScopes}`. A separate render
|
|
18
|
+
* function `buildSecretsReminder` produces the system-prompt text
|
|
19
|
+
* from this metadata; it has no access to the env map.
|
|
20
|
+
*
|
|
21
|
+
* 3. **Caller-side log redaction.** This module's logging functions
|
|
22
|
+
* explicitly never log response bodies. Error paths log only the
|
|
23
|
+
* HTTP status + a sanitised error message. We do NOT rely solely on
|
|
24
|
+
* the API's `x-tv-no-log: response-body` header — the poller is its
|
|
25
|
+
* own trust boundary.
|
|
26
|
+
*
|
|
27
|
+
* If the API call fails, `fetchSecretsForSpawn` returns an empty envelope
|
|
28
|
+
* + reminders so that spawn proceeds without secrets rather than failing
|
|
29
|
+
* the user's session. Errors are logged at warn level; production
|
|
30
|
+
* monitoring should alarm on `secrets-client.fetch_failed` metric counter.
|
|
31
|
+
*/
|
|
32
|
+
import { logger } from './logger.js';
|
|
33
|
+
import { config } from './config.js';
|
|
34
|
+
/**
|
|
35
|
+
* Opaque envelope around the decrypted env map. The values inside MUST NOT
|
|
36
|
+
* be read except via `unwrap()`, and `unwrap()` is intended to be called
|
|
37
|
+
* exactly once at the child-process spawn site.
|
|
38
|
+
*
|
|
39
|
+
* Default JSON / toString render `[REDACTED]`. If you ever find yourself
|
|
40
|
+
* adding a new caller of `unwrap()`, treat it as a security-relevant
|
|
41
|
+
* change and ping DevGuru.
|
|
42
|
+
*/
|
|
43
|
+
export class SpawnSecretsEnvelope {
|
|
44
|
+
#env;
|
|
45
|
+
constructor(env) {
|
|
46
|
+
this.#env = env;
|
|
47
|
+
}
|
|
48
|
+
/** Sole legitimate value access. Single call site = `claude-spawner.ts`. */
|
|
49
|
+
unwrap() {
|
|
50
|
+
return this.#env;
|
|
51
|
+
}
|
|
52
|
+
/** Number of secrets carried — safe for logging. */
|
|
53
|
+
size() {
|
|
54
|
+
return Object.keys(this.#env).length;
|
|
55
|
+
}
|
|
56
|
+
toString() {
|
|
57
|
+
return `[SpawnSecretsEnvelope size=${this.size()} REDACTED]`;
|
|
58
|
+
}
|
|
59
|
+
toJSON() {
|
|
60
|
+
return '[REDACTED]';
|
|
61
|
+
}
|
|
62
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
63
|
+
return this.toString();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetch secrets for a spawn. Returns an empty envelope (and empty
|
|
68
|
+
* reminders array) on any failure mode — spawn continues without
|
|
69
|
+
* secrets. The error is logged here so monitoring can alarm; do NOT
|
|
70
|
+
* pass error details into the spawn pipeline where they might surface
|
|
71
|
+
* to the user.
|
|
72
|
+
*/
|
|
73
|
+
export async function fetchSecretsForSpawn(input) {
|
|
74
|
+
if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
|
|
75
|
+
logger.warn('secrets-client: TEAMVIBE_API_URL or TEAMVIBE_POLLER_TOKEN not set; skipping fetch');
|
|
76
|
+
return emptyResult();
|
|
77
|
+
}
|
|
78
|
+
// Upfront short-circuit: an empty pollerId would fail server-side
|
|
79
|
+
// validation and round-trip an HTTP error. Skip the call entirely so
|
|
80
|
+
// the warn log makes the cause obvious from poller stdout.
|
|
81
|
+
if (!input.pollerId) {
|
|
82
|
+
logger.warn('secrets-client: pollerId empty; skipping fetch (no secrets injected for spawn)');
|
|
83
|
+
return emptyResult();
|
|
84
|
+
}
|
|
85
|
+
// URL construction can throw on malformed TEAMVIBE_API_URL — must run
|
|
86
|
+
// inside the fail-soft envelope so a config typo doesn't crash the
|
|
87
|
+
// spawn pipeline. Wrapping here keeps the contract uniform with the
|
|
88
|
+
// fetch / parse error paths below.
|
|
89
|
+
let url;
|
|
90
|
+
try {
|
|
91
|
+
url = new URL('/secrets/spawn', config.TEAMVIBE_API_URL);
|
|
92
|
+
url.searchParams.set('pollerId', input.pollerId);
|
|
93
|
+
url.searchParams.set('workspaceId', input.workspaceId);
|
|
94
|
+
url.searchParams.set('channelId', input.channelId);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
logger.warn(`secrets-client.config_error: invalid TEAMVIBE_API_URL=${String(config.TEAMVIBE_API_URL)} ` +
|
|
98
|
+
`error=${err?.message ?? String(err)}`);
|
|
99
|
+
return emptyResult();
|
|
100
|
+
}
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetch(url.toString(), {
|
|
104
|
+
method: 'GET',
|
|
105
|
+
headers: {
|
|
106
|
+
Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
|
|
107
|
+
// Hint to any intermediary that this request returns sensitive
|
|
108
|
+
// data; the API itself sets `x-tv-no-log` on the response.
|
|
109
|
+
'Cache-Control': 'no-store',
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
logger.warn(`secrets-client.fetch_failed: network error pollerId=${input.pollerId} ` +
|
|
115
|
+
`workspaceId=${input.workspaceId} channelId=${input.channelId} ` +
|
|
116
|
+
`error=${err?.message ?? String(err)}`);
|
|
117
|
+
return emptyResult();
|
|
118
|
+
}
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
// Read body to surface a useful error log, but cap the size so a
|
|
121
|
+
// misconfigured API can't dump arbitrary content into our logs.
|
|
122
|
+
// Status + a small slice is sufficient diagnostics — no body
|
|
123
|
+
// pass-through.
|
|
124
|
+
let snippet = '';
|
|
125
|
+
try {
|
|
126
|
+
snippet = (await response.text()).slice(0, 200);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
snippet = '<failed-to-read-body>';
|
|
130
|
+
}
|
|
131
|
+
logger.warn(`secrets-client.fetch_failed: status=${response.status} ` +
|
|
132
|
+
`pollerId=${input.pollerId} workspaceId=${input.workspaceId} ` +
|
|
133
|
+
`channelId=${input.channelId} bodySnippet=${snippet}`);
|
|
134
|
+
return emptyResult();
|
|
135
|
+
}
|
|
136
|
+
// The response is sensitive — parse it and immediately wrap. From this
|
|
137
|
+
// point on, the env map is only accessible via `.unwrap()`.
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = (await response.json());
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
logger.warn(`secrets-client.parse_failed: pollerId=${input.pollerId} ` +
|
|
144
|
+
`error=${err?.message ?? String(err)}`);
|
|
145
|
+
return emptyResult();
|
|
146
|
+
}
|
|
147
|
+
const env = {};
|
|
148
|
+
if (parsed.env && typeof parsed.env === 'object') {
|
|
149
|
+
for (const [k, v] of Object.entries(parsed.env)) {
|
|
150
|
+
if (typeof v === 'string')
|
|
151
|
+
env[k] = v;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const reminders = [];
|
|
155
|
+
if (Array.isArray(parsed.reminders)) {
|
|
156
|
+
for (const item of parsed.reminders) {
|
|
157
|
+
if (typeof item['name'] !== 'string' || typeof item['scope'] !== 'string')
|
|
158
|
+
continue;
|
|
159
|
+
reminders.push({
|
|
160
|
+
name: item['name'],
|
|
161
|
+
scope: item['scope'],
|
|
162
|
+
expiresAt: typeof item['expiresAt'] === 'string' ? item['expiresAt'] : undefined,
|
|
163
|
+
overrodeScopes: Array.isArray(item['overrodeScopes'])
|
|
164
|
+
? item['overrodeScopes'].filter(s => typeof s === 'string')
|
|
165
|
+
: undefined,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Log only the size — never names or values.
|
|
170
|
+
logger.info(`secrets-client: fetched ${Object.keys(env).length} secret(s) for spawn`);
|
|
171
|
+
return {
|
|
172
|
+
env: new SpawnSecretsEnvelope(env),
|
|
173
|
+
reminders,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function emptyResult() {
|
|
177
|
+
return { env: new SpawnSecretsEnvelope({}), reminders: [] };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Render the system-reminder text from reminder metadata. This function
|
|
181
|
+
* intentionally has NO access to the env map — its inputs are name +
|
|
182
|
+
* scope + expiration only. A future code-reader who needs to add a value
|
|
183
|
+
* to the reminder body must change the function signature, which is a
|
|
184
|
+
* loud signal to think twice.
|
|
185
|
+
*/
|
|
186
|
+
export function buildSecretsReminder(reminders) {
|
|
187
|
+
if (reminders.length === 0)
|
|
188
|
+
return '';
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
const lines = ['## Available Secrets'];
|
|
191
|
+
lines.push('The following ENV variables are loaded into this session:');
|
|
192
|
+
for (const r of reminders) {
|
|
193
|
+
let suffix = '';
|
|
194
|
+
if (r.expiresAt) {
|
|
195
|
+
const expMs = Date.parse(r.expiresAt);
|
|
196
|
+
if (Number.isFinite(expMs)) {
|
|
197
|
+
const daysLeft = Math.floor((expMs - now) / (24 * 60 * 60 * 1000));
|
|
198
|
+
if (daysLeft < 1) {
|
|
199
|
+
suffix = ` — **EXPIRES IN <1d** :warning: please rotate via \`set_secret\``;
|
|
200
|
+
}
|
|
201
|
+
else if (daysLeft < 7) {
|
|
202
|
+
suffix = ` (expires in ${daysLeft}d — please rotate via \`set_secret\`)`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const overrideNote = r.overrodeScopes && r.overrodeScopes.length > 0
|
|
207
|
+
? `, overrides ${r.overrodeScopes.join('+')}`
|
|
208
|
+
: '';
|
|
209
|
+
lines.push(`- \`${r.name}\` (${r.scope} scope${overrideNote})${suffix}`);
|
|
210
|
+
}
|
|
211
|
+
lines.push('');
|
|
212
|
+
lines.push('Access via `process.env.{NAME}` or shell `$NAME`.');
|
|
213
|
+
lines.push('Values are a snapshot taken at spawn time; long sessions may see stale values until the next spawn.');
|
|
214
|
+
return lines.join('\n');
|
|
215
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface ControlMessage {
|
|
|
7
7
|
export type QueueMessage = TeamVibeQueueMessage | ControlMessage;
|
|
8
8
|
export interface TeamVibeQueueMessage {
|
|
9
9
|
id: string;
|
|
10
|
-
source: 'slack' | 'email' | 'cron' | '
|
|
10
|
+
source: 'slack' | 'email' | 'cron' | 'maintenance' | 'webhook';
|
|
11
11
|
thread_id: string;
|
|
12
12
|
sender: {
|
|
13
13
|
id: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamvibe/poller",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"zod": "^3.22.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
+
"@types/bun": "^1.3.14",
|
|
32
33
|
"@types/node": "^20.10.5",
|
|
33
34
|
"tsx": "^4.7.0",
|
|
34
35
|
"typescript": "^5.3.3"
|