@teamvibe/poller 0.4.3 → 0.5.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 +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 +122 -25
- package/dist/config.d.ts +3 -7
- package/dist/config.js +8 -8
- 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} +33 -54
- 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,6 +400,13 @@ 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
|
+
...spawnSecrets.env.unwrap(),
|
|
326
410
|
...process.env,
|
|
327
411
|
CLAUDECODE: undefined,
|
|
328
412
|
PATH: envPath,
|
|
@@ -439,15 +523,14 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
439
523
|
networkErrorStart = null;
|
|
440
524
|
}
|
|
441
525
|
});
|
|
442
|
-
let timedOut = false;
|
|
443
526
|
const timeoutMinutes = Math.round(config.CLAUDE_TIMEOUT_MS / 60000);
|
|
444
527
|
const timeout = setTimeout(() => {
|
|
445
|
-
timedOut = true;
|
|
446
528
|
logger.error(`Claude Code process timed out after ${timeoutMinutes} minutes`);
|
|
447
529
|
emitError('session', `Claude Code process timed out after ${timeoutMinutes} minutes`, {
|
|
448
530
|
context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
|
|
449
531
|
channelId: msg.teamvibe.channelId,
|
|
450
532
|
});
|
|
533
|
+
killReasonByProc.set(proc, 'timeout');
|
|
451
534
|
proc.kill('SIGTERM');
|
|
452
535
|
setTimeout(() => proc.kill('SIGKILL'), 5000);
|
|
453
536
|
}, config.CLAUDE_TIMEOUT_MS);
|
|
@@ -465,6 +548,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
465
548
|
context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
|
|
466
549
|
channelId: msg.teamvibe.channelId,
|
|
467
550
|
});
|
|
551
|
+
killReasonByProc.set(proc, 'network');
|
|
468
552
|
proc.kill('SIGTERM');
|
|
469
553
|
setTimeout(() => proc.kill('SIGKILL'), 5000);
|
|
470
554
|
return;
|
|
@@ -477,6 +561,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
477
561
|
context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
|
|
478
562
|
channelId: msg.teamvibe.channelId,
|
|
479
563
|
});
|
|
564
|
+
killReasonByProc.set(proc, 'idle');
|
|
480
565
|
proc.kill('SIGTERM');
|
|
481
566
|
setTimeout(() => proc.kill('SIGKILL'), 5000);
|
|
482
567
|
}
|
|
@@ -496,15 +581,26 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
496
581
|
clearInterval(idleWatchdog);
|
|
497
582
|
activeProcesses.delete(msg.id);
|
|
498
583
|
activeProcessesByThread.delete(msg.thread_id);
|
|
584
|
+
const killReason = killReasonByProc.get(proc);
|
|
499
585
|
const result = {
|
|
500
586
|
success: code === 0,
|
|
501
587
|
output: stdout,
|
|
502
588
|
exitCode: code,
|
|
503
589
|
metrics,
|
|
590
|
+
...(killReason ? { killedByPoller: killReason } : {}),
|
|
504
591
|
};
|
|
505
|
-
if (
|
|
592
|
+
if (killReason === 'timeout') {
|
|
506
593
|
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
594
|
}
|
|
595
|
+
else if (killReason === 'idle') {
|
|
596
|
+
result.error = `Killed by idle watchdog (no output for >${Math.round(config.IDLE_WATCHDOG_KILL_MS / 60000)} minutes)`;
|
|
597
|
+
}
|
|
598
|
+
else if (killReason === 'network') {
|
|
599
|
+
result.error = 'Killed by network watchdog (sustained network errors)';
|
|
600
|
+
}
|
|
601
|
+
else if (killReason === 'zombie') {
|
|
602
|
+
result.error = 'Killed as zombie process (stale session lock)';
|
|
603
|
+
}
|
|
508
604
|
else if (code !== 0) {
|
|
509
605
|
result.error = stderr || `Process exited with code ${code}`;
|
|
510
606
|
}
|
|
@@ -532,7 +628,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
532
628
|
* Sources whose sessions should run in an isolated git worktree (teamvibe.ai#120).
|
|
533
629
|
* Foreground Slack messages always run in the main working dir.
|
|
534
630
|
*/
|
|
535
|
-
const ISOLATED_SOURCES = new Set(['cron', '
|
|
631
|
+
const ISOLATED_SOURCES = new Set(['cron', 'maintenance']);
|
|
536
632
|
function shouldIsolateInWorktree(msg) {
|
|
537
633
|
return isWorktreeIsolationEnabled() && ISOLATED_SOURCES.has(msg.source);
|
|
538
634
|
}
|
|
@@ -603,6 +699,7 @@ export function killZombieForThread(threadId) {
|
|
|
603
699
|
}
|
|
604
700
|
logger.warn(`Killing zombie process for thread ${threadId} (pid: ${proc.pid})`);
|
|
605
701
|
emitError('session', `Killing zombie process for thread ${threadId}`, { severity: 'warn' });
|
|
702
|
+
killReasonByProc.set(proc, 'zombie');
|
|
606
703
|
proc.kill('SIGTERM');
|
|
607
704
|
setTimeout(() => {
|
|
608
705
|
if (proc.exitCode === null) {
|
package/dist/config.d.ts
CHANGED
|
@@ -28,8 +28,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
28
28
|
BASE_BRAIN_PATH: z.ZodDefault<z.ZodString>;
|
|
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
|
-
POLLER_TZ: z.
|
|
32
|
-
HEARTBEAT_CRON: z.ZodDefault<z.ZodString>;
|
|
31
|
+
POLLER_TZ: z.ZodOptional<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,8 +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
|
-
POLLER_TZ: string;
|
|
63
|
-
HEARTBEAT_CRON: string;
|
|
64
61
|
MAINTENANCE_CRON: string;
|
|
65
62
|
MAINTENANCE_ENABLED: boolean;
|
|
66
63
|
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
@@ -68,6 +65,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
68
65
|
SQS_QUEUE_URL?: string | undefined;
|
|
69
66
|
SESSIONS_TABLE?: string | undefined;
|
|
70
67
|
TEAMVIBE_POLLER_TOKEN?: string | undefined;
|
|
68
|
+
POLLER_TZ?: string | undefined;
|
|
71
69
|
}, {
|
|
72
70
|
AWS_REGION?: string | undefined;
|
|
73
71
|
SQS_QUEUE_URL?: string | undefined;
|
|
@@ -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,8 +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
|
-
POLLER_TZ: string;
|
|
134
|
-
HEARTBEAT_CRON: string;
|
|
135
130
|
MAINTENANCE_CRON: string;
|
|
136
131
|
MAINTENANCE_ENABLED: boolean;
|
|
137
132
|
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
@@ -139,5 +134,6 @@ export declare const config: {
|
|
|
139
134
|
SQS_QUEUE_URL?: string | undefined;
|
|
140
135
|
SESSIONS_TABLE?: string | undefined;
|
|
141
136
|
TEAMVIBE_POLLER_TOKEN?: string | undefined;
|
|
137
|
+
POLLER_TZ?: string | undefined;
|
|
142
138
|
};
|
|
143
139
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -43,14 +43,14 @@ 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: IANA timezone
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
MAINTENANCE_CRON: z.string().default('30 3 * * *'), // Daily at 03:30 in POLLER_TZ
|
|
46
|
+
// Brain maintenance (cron-based)
|
|
47
|
+
// POLLER_TZ: optional IANA timezone for MAINTENANCE_CRON.
|
|
48
|
+
// When unset, croner uses the host system timezone (e.g. `Europe/Prague` on
|
|
49
|
+
// a CZ Mac mini, `UTC` on a default Railway container). Set explicitly
|
|
50
|
+
// (e.g. `POLLER_TZ=Europe/Prague` on Railway) when the host TZ doesn't match
|
|
51
|
+
// the intended cron interpretation. DST is handled by croner automatically.
|
|
52
|
+
POLLER_TZ: z.string().optional(),
|
|
53
|
+
MAINTENANCE_CRON: z.string().default('30 3 * * *'), // Daily at 03:30 in POLLER_TZ (or host TZ)
|
|
54
54
|
MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
|
|
55
55
|
// Auto-update settings
|
|
56
56
|
AUTO_UPDATE_POLICY: z.enum(['patch', 'minor', 'off']).default('patch'),
|
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;
|