@teamvibe/poller 0.8.0 → 0.8.2-rc.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/auth-provider.d.ts +6 -0
- package/dist/auth-provider.js +52 -7
- package/dist/base-brain-metadata.js +6 -1
- package/dist/brain-manager.js +4 -3
- package/dist/claude-spawner.d.ts +1 -0
- package/dist/claude-spawner.js +42 -11
- package/dist/maintenance-reporter.d.ts +8 -0
- package/dist/maintenance-reporter.js +20 -1
- package/dist/poller.js +33 -5
- package/dist/slack-client.d.ts +3 -1
- package/dist/slack-client.js +27 -5
- package/dist/startup-auth.d.ts +15 -0
- package/dist/startup-auth.js +137 -0
- package/npm-shrinkwrap.json +1606 -0
- package/package.json +13 -6
package/dist/auth-provider.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ interface AuthConfig {
|
|
|
9
9
|
sessionsTable: string;
|
|
10
10
|
region: string;
|
|
11
11
|
}
|
|
12
|
+
export declare class AuthHttpError extends Error {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
constructor(status: number, _body?: string);
|
|
15
|
+
}
|
|
16
|
+
export declare class StartupConfigurationError extends Error {
|
|
17
|
+
}
|
|
12
18
|
export declare function initAuth(apiUrl: string, token: string): Promise<void>;
|
|
13
19
|
export declare function getCredentials(): AuthCredentials;
|
|
14
20
|
export declare function getAuthConfig(): AuthConfig;
|
package/dist/auth-provider.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import { POLLER_VERSION } from './config.js';
|
|
1
|
+
import { config, POLLER_VERSION } from './config.js';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { emitError } from './event-queue.js';
|
|
4
|
+
import { getBaseBrainCommitSha } from './base-brain-metadata.js';
|
|
5
|
+
export class AuthHttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, _body) {
|
|
8
|
+
super(`Auth failed (${status})`);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = 'AuthHttpError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class StartupConfigurationError extends Error {
|
|
14
|
+
}
|
|
4
15
|
let currentCredentials = null;
|
|
5
16
|
let currentConfig = null;
|
|
6
17
|
let currentPollerId = null;
|
|
@@ -13,13 +24,16 @@ const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 minutes (before 1hr expiry)
|
|
|
13
24
|
const HEARTBEAT_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
14
25
|
async function sendHeartbeat(apiUrl, token, version) {
|
|
15
26
|
try {
|
|
27
|
+
const baseBrainSha = await getBaseBrainCommitSha(config.BASE_BRAIN_PATH);
|
|
28
|
+
if (!baseBrainSha)
|
|
29
|
+
logger.warn('[Heartbeat] Could not read base-brain HEAD for pin readback');
|
|
16
30
|
const response = await fetch(`${apiUrl}/heartbeat`, {
|
|
17
31
|
method: 'POST',
|
|
18
32
|
headers: {
|
|
19
33
|
Authorization: `Bearer ${token}`,
|
|
20
34
|
'Content-Type': 'application/json',
|
|
21
35
|
},
|
|
22
|
-
body: JSON.stringify({ version }),
|
|
36
|
+
body: JSON.stringify({ version, baseBrainSha, baseBrainBranch: config.BASE_BRAIN_BRANCH }),
|
|
23
37
|
});
|
|
24
38
|
if (!response.ok) {
|
|
25
39
|
logger.warn(`Heartbeat failed (${response.status})`);
|
|
@@ -31,25 +45,56 @@ async function sendHeartbeat(apiUrl, token, version) {
|
|
|
31
45
|
emitError('auth', `Heartbeat error: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
|
|
32
46
|
}
|
|
33
47
|
}
|
|
34
|
-
async function fetchCredentials(apiUrl, token) {
|
|
48
|
+
async function fetchCredentials(apiUrl, token, signal) {
|
|
35
49
|
const response = await fetch(`${apiUrl}/auth`, {
|
|
36
50
|
method: 'POST',
|
|
51
|
+
signal,
|
|
37
52
|
headers: {
|
|
38
53
|
Authorization: `Bearer ${token}`,
|
|
39
54
|
'Content-Type': 'application/json',
|
|
40
55
|
},
|
|
41
56
|
});
|
|
42
57
|
if (!response.ok) {
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
throw new AuthHttpError(response.status);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return await response.json();
|
|
45
62
|
}
|
|
46
|
-
|
|
63
|
+
catch {
|
|
64
|
+
throw new StartupConfigurationError('Auth response is not valid JSON');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function validateStartupAuthResponse(auth) {
|
|
68
|
+
const value = auth;
|
|
69
|
+
const authRecord = value;
|
|
70
|
+
const credentials = authRecord?.credentials;
|
|
71
|
+
const authConfig = authRecord?.config;
|
|
72
|
+
if (typeof authRecord?.pollerId !== 'string' || !authRecord.pollerId ||
|
|
73
|
+
typeof credentials?.accessKeyId !== 'string' || !credentials.accessKeyId ||
|
|
74
|
+
typeof credentials?.secretAccessKey !== 'string' || !credentials.secretAccessKey ||
|
|
75
|
+
typeof credentials?.sessionToken !== 'string' || !credentials.sessionToken ||
|
|
76
|
+
typeof credentials?.expiration !== 'string' || !credentials.expiration ||
|
|
77
|
+
typeof authConfig?.sqsQueueUrl !== 'string' || !authConfig.sqsQueueUrl ||
|
|
78
|
+
typeof authConfig?.sessionsTable !== 'string' || !authConfig.sessionsTable ||
|
|
79
|
+
typeof authConfig?.region !== 'string' || !authConfig.region)
|
|
80
|
+
throw new StartupConfigurationError('Auth response is missing or has malformed credentials/config');
|
|
47
81
|
}
|
|
48
82
|
export async function initAuth(apiUrl, token) {
|
|
83
|
+
if (!token.trim())
|
|
84
|
+
throw new StartupConfigurationError('TEAMVIBE_POLLER_TOKEN is missing or empty');
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(apiUrl);
|
|
87
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
88
|
+
throw new Error('unsupported protocol');
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new StartupConfigurationError('TEAMVIBE_API_URL is malformed');
|
|
92
|
+
}
|
|
49
93
|
logger.info('Authenticating with TeamVibe API...');
|
|
50
94
|
storedApiUrl = apiUrl;
|
|
51
95
|
storedToken = token;
|
|
52
|
-
const auth = await fetchCredentials(apiUrl, token);
|
|
96
|
+
const auth = await fetchCredentials(apiUrl, token, AbortSignal.timeout(15_000));
|
|
97
|
+
validateStartupAuthResponse(auth);
|
|
53
98
|
currentCredentials = auth.credentials;
|
|
54
99
|
currentConfig = auth.config;
|
|
55
100
|
currentPollerId = auth.pollerId;
|
|
@@ -3,6 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { execFile } from 'child_process';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
const GIT_HEAD_TIMEOUT_MS = 2_000;
|
|
6
7
|
/**
|
|
7
8
|
* Read the current HEAD commit SHA of a git repo. Returns null on any error
|
|
8
9
|
* (not a git repo, git missing, dir missing, etc.) so callers can attach a
|
|
@@ -10,7 +11,11 @@ const execFileAsync = promisify(execFile);
|
|
|
10
11
|
*/
|
|
11
12
|
export async function getBaseBrainCommitSha(repoDir) {
|
|
12
13
|
try {
|
|
13
|
-
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
|
14
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
|
15
|
+
cwd: repoDir,
|
|
16
|
+
timeout: GIT_HEAD_TIMEOUT_MS,
|
|
17
|
+
killSignal: 'SIGKILL',
|
|
18
|
+
});
|
|
14
19
|
const sha = stdout.trim();
|
|
15
20
|
return sha || null;
|
|
16
21
|
}
|
package/dist/brain-manager.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join, basename } from 'path';
|
|
|
6
6
|
import { config } from './config.js';
|
|
7
7
|
import { logger } from './logger.js';
|
|
8
8
|
import { emitError } from './event-queue.js';
|
|
9
|
+
import { startupFailureDetail } from './startup-auth.js';
|
|
9
10
|
const execAsync = promisify(exec);
|
|
10
11
|
// Clean env for git commands:
|
|
11
12
|
// - npm/npx sets GIT_ASKPASS which prevents credential helpers from working
|
|
@@ -257,7 +258,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
|
|
|
257
258
|
catch (error) {
|
|
258
259
|
// Pre-check itself failed (e.g. brainDir not a git repo). Fail-safe to skip;
|
|
259
260
|
// the next iteration will retry.
|
|
260
|
-
const errorMessage = error
|
|
261
|
+
const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
261
262
|
logger.warn(`Brain ${brainId} pre-check failed, skipping update: ${errorMessage}`);
|
|
262
263
|
return;
|
|
263
264
|
}
|
|
@@ -271,7 +272,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
|
|
|
271
272
|
logger.info(`Brain ${brainId} updated successfully`);
|
|
272
273
|
}
|
|
273
274
|
catch (error) {
|
|
274
|
-
const errorMessage = error
|
|
275
|
+
const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
275
276
|
logger.error(`Failed to update brain ${brainId}: ${errorMessage}`);
|
|
276
277
|
emitError('brain', `Failed to update brain ${brainId}: ${errorMessage}`, { context: { brainId, brainDir } });
|
|
277
278
|
}
|
|
@@ -283,7 +284,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
|
|
|
283
284
|
export async function ensureBaseBrain() {
|
|
284
285
|
const brainDir = config.BASE_BRAIN_PATH;
|
|
285
286
|
if (!existsSync(brainDir)) {
|
|
286
|
-
logger.info(`Cloning base brain
|
|
287
|
+
logger.info(`Cloning base brain (branch: ${config.BASE_BRAIN_BRANCH})...`);
|
|
287
288
|
await gitExec(`git clone --recurse-submodules --branch ${config.BASE_BRAIN_BRANCH} ${config.BASE_BRAIN_REPO} ${brainDir}`);
|
|
288
289
|
lastUpdateTimes.set('__base_brain__', Date.now());
|
|
289
290
|
logger.info('Base brain cloned successfully');
|
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -212,6 +212,7 @@ export interface SpawnStdinPlan {
|
|
|
212
212
|
export declare function buildSpawnStdinPlan(prompt: string, inlineImages: TeamVibeQueueMessage['inlineImages'], now?: number): SpawnStdinPlan;
|
|
213
213
|
export declare function extractAssistantText(event: any): string | undefined;
|
|
214
214
|
export declare function assistantCalledTool(event: any, toolName: string): boolean;
|
|
215
|
+
export declare function resolveTypingStatus(event: any, assistantText?: string): string | null;
|
|
215
216
|
/**
|
|
216
217
|
* Decides whether a spawn attempt should report its session ID back to the
|
|
217
218
|
* caller (poller.ts) for persistence to DynamoDB via updateSessionId().
|
package/dist/claude-spawner.js
CHANGED
|
@@ -699,6 +699,44 @@ export function assistantCalledTool(event, toolName) {
|
|
|
699
699
|
}
|
|
700
700
|
return event.message.content.some((c) => c?.type === 'tool_use' && c.name === toolName);
|
|
701
701
|
}
|
|
702
|
+
// Status text is a one-line UI label, not a chat message — cap it well short
|
|
703
|
+
// of a full sentence.
|
|
704
|
+
const TYPING_STATUS_MAX_LEN = 100;
|
|
705
|
+
function oneLineStatus(text) {
|
|
706
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
707
|
+
return oneLine.length > TYPING_STATUS_MAX_LEN
|
|
708
|
+
? oneLine.slice(0, TYPING_STATUS_MAX_LEN - 1) + '…'
|
|
709
|
+
: oneLine;
|
|
710
|
+
}
|
|
711
|
+
function resolveTypingStatusDetail(event, assistantText) {
|
|
712
|
+
if (event?.type !== 'assistant' || !Array.isArray(event.message?.content)) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
// The caller clears the indicator when this tool sends a reply. Do not
|
|
716
|
+
// restore a status from that same event, even if it also contains narration.
|
|
717
|
+
if (assistantCalledTool(event, 'mcp__slack__send_message'))
|
|
718
|
+
return null;
|
|
719
|
+
const text = assistantText ?? extractAssistantText(event);
|
|
720
|
+
if (text) {
|
|
721
|
+
// Collapse newlines/whitespace runs so a multi-line narration doesn't
|
|
722
|
+
// land a literal `\n` in the one-line status surface.
|
|
723
|
+
return { text: oneLineStatus(text), source: 'comment' };
|
|
724
|
+
}
|
|
725
|
+
for (const block of event.message.content) {
|
|
726
|
+
if (block?.type !== 'tool_use' || typeof block.name !== 'string')
|
|
727
|
+
continue;
|
|
728
|
+
const status = getStatusForTool(block.name);
|
|
729
|
+
if (status) {
|
|
730
|
+
const safeToolName = /^(?:Bash|Read|Edit|Write|WebFetch|WebSearch|Grep|Glob|Agent)$/.test(block.name)
|
|
731
|
+
? block.name : 'other';
|
|
732
|
+
return { text: oneLineStatus(status), source: `tool:${safeToolName}` };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return { text: 'Thinking...', source: 'default' };
|
|
736
|
+
}
|
|
737
|
+
export function resolveTypingStatus(event, assistantText) {
|
|
738
|
+
return resolveTypingStatusDetail(event, assistantText)?.text ?? null;
|
|
739
|
+
}
|
|
702
740
|
async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
|
|
703
741
|
const slackContext = msg.response_context.slack;
|
|
704
742
|
const isCronMessage = msg.source === 'cron';
|
|
@@ -1004,18 +1042,11 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1004
1042
|
scheduleProgressNotifications(progressNotificationMarks(minutes));
|
|
1005
1043
|
}
|
|
1006
1044
|
}
|
|
1007
|
-
// Auto-update typing indicator status based on tool usage
|
|
1045
|
+
// Auto-update typing indicator status based on tool usage (tv#369, tv#291)
|
|
1008
1046
|
if (typingIndicator) {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
typingIndicator.updateStatus(status);
|
|
1013
|
-
}
|
|
1014
|
-
// When status is '' (e.g. set_status tool), don't override — Claude is managing it
|
|
1015
|
-
}
|
|
1016
|
-
else if (event.type === 'assistant') {
|
|
1017
|
-
// Reset to "Thinking..." when Claude is reasoning between tool calls
|
|
1018
|
-
typingIndicator.updateStatus('Thinking...');
|
|
1047
|
+
const status = resolveTypingStatusDetail(event, assistantText);
|
|
1048
|
+
if (status) {
|
|
1049
|
+
typingIndicator.updateStatus(status.text, status.source);
|
|
1019
1050
|
}
|
|
1020
1051
|
}
|
|
1021
1052
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** Best-effort runtime identity, sampled when each report is posted. */
|
|
2
|
+
export declare function collectReportProvenance(brainPath: string): Promise<{
|
|
3
|
+
pollerId: string | null;
|
|
4
|
+
pollerVersion: string;
|
|
5
|
+
baseBrainSha: string | null;
|
|
6
|
+
baseBrainBranch: string;
|
|
7
|
+
brainSha: string | null;
|
|
8
|
+
}>;
|
|
1
9
|
/**
|
|
2
10
|
* Scan for JSON maintenance reports in the brain's reports/ directory
|
|
3
11
|
* and POST each to the /events endpoint. Fire-and-forget — never throws.
|
|
@@ -3,10 +3,29 @@ import { join } from 'path';
|
|
|
3
3
|
import { execFile } from 'child_process';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
import { config, POLLER_VERSION } from './config.js';
|
|
6
|
+
import { getPollerId } from './auth-provider.js';
|
|
6
7
|
import { logger } from './logger.js';
|
|
7
8
|
import { emitError } from './event-queue.js';
|
|
8
9
|
import { getBaseBrainCommitSha, getBaseBrainAgeHours, } from './base-brain-metadata.js';
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
11
|
+
/** Best-effort runtime identity, sampled when each report is posted. */
|
|
12
|
+
export async function collectReportProvenance(brainPath) {
|
|
13
|
+
const [baseBrainSha, brainSha] = await Promise.all([
|
|
14
|
+
getBaseBrainCommitSha(config.BASE_BRAIN_PATH),
|
|
15
|
+
getBaseBrainCommitSha(brainPath),
|
|
16
|
+
]);
|
|
17
|
+
if (!baseBrainSha)
|
|
18
|
+
logger.warn('[MaintenanceReporter] Could not read base-brain HEAD for report provenance');
|
|
19
|
+
if (!brainSha)
|
|
20
|
+
logger.warn('[MaintenanceReporter] Could not read channel-brain HEAD for report provenance');
|
|
21
|
+
return {
|
|
22
|
+
pollerId: getPollerId(),
|
|
23
|
+
pollerVersion: POLLER_VERSION,
|
|
24
|
+
baseBrainSha,
|
|
25
|
+
baseBrainBranch: config.BASE_BRAIN_BRANCH,
|
|
26
|
+
brainSha,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
10
29
|
const OPERATION_TO_EVENT_TYPE = {
|
|
11
30
|
consolidation: 'maintenance.consolidation',
|
|
12
31
|
reflection: 'maintenance.reflection',
|
|
@@ -131,7 +150,7 @@ export async function postMaintenanceReports(brainPath, channelId) {
|
|
|
131
150
|
const payload = {
|
|
132
151
|
eventType,
|
|
133
152
|
channelId,
|
|
134
|
-
|
|
153
|
+
...(await collectReportProvenance(brainPath)),
|
|
135
154
|
baseBrainCommitSha,
|
|
136
155
|
baseBrainAgeHours,
|
|
137
156
|
metadata: report,
|
package/dist/poller.js
CHANGED
|
@@ -9,6 +9,7 @@ import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } fr
|
|
|
9
9
|
import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
|
|
10
10
|
import { postMaintenanceReports } from './maintenance-reporter.js';
|
|
11
11
|
import { initAuth, stopRefresh, getPollerId } from './auth-provider.js';
|
|
12
|
+
import { retryStartupAuth, retryStartupOperation, startupFailureDetail } from './startup-auth.js';
|
|
12
13
|
import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './maintenance-cron.js';
|
|
13
14
|
import { bootstrapTrackedBrains } from './brains-bootstrap.js';
|
|
14
15
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
|
|
@@ -219,6 +220,14 @@ export function describeFailureReason(killedByPoller, exitCode) {
|
|
|
219
220
|
// content an agent forgot to send via send_message) still delivers as
|
|
220
221
|
// before, so this can't silently drop a forgotten reply.
|
|
221
222
|
export const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = 'NO_FURTHER_RESPONSE_NEEDED';
|
|
223
|
+
// tv#358 (revised): the sentinel agents emit and the guard matches is
|
|
224
|
+
// unchanged — still the exact ALL_CAPS literal above, still an exact-match
|
|
225
|
+
// suppression. What changes is purely cosmetic: poller-brain#428's leak
|
|
226
|
+
// failure mode (a sentence written before the sentinel breaks the exact
|
|
227
|
+
// match, so the whole thing delivers as-is) showed the raw literal reads as
|
|
228
|
+
// jarring when it does surface. Swap it for a quieter display token
|
|
229
|
+
// wherever it appears in text that's actually being delivered.
|
|
230
|
+
const END_OF_TURN_SENTINEL_DISPLAY = '[silent]';
|
|
222
231
|
export function endOfTurnFallbackText(hasSlackContext, result) {
|
|
223
232
|
if (!hasSlackContext || !result.success || result.sawSendMessageCall) {
|
|
224
233
|
return undefined;
|
|
@@ -227,7 +236,7 @@ export function endOfTurnFallbackText(hasSlackContext, result) {
|
|
|
227
236
|
if (!text || text === END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL) {
|
|
228
237
|
return undefined;
|
|
229
238
|
}
|
|
230
|
-
return text;
|
|
239
|
+
return text.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join(END_OF_TURN_SENTINEL_DISPLAY);
|
|
231
240
|
}
|
|
232
241
|
export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
233
242
|
if (activeLoops.has(threadId)) {
|
|
@@ -862,8 +871,9 @@ export async function startPoller() {
|
|
|
862
871
|
logger.info(`TeamVibe Poller v${POLLER_VERSION} starting...`);
|
|
863
872
|
logger.info(` Max concurrent: ${config.MAX_CONCURRENT_SESSIONS}`);
|
|
864
873
|
logger.info(` Brains path: ${config.BRAINS_PATH}`);
|
|
865
|
-
|
|
866
|
-
process.on('
|
|
874
|
+
const startupAbort = new AbortController();
|
|
875
|
+
process.on('SIGINT', () => { startupAbort.abort(); void shutdown('SIGINT'); });
|
|
876
|
+
process.on('SIGTERM', () => { startupAbort.abort(); void shutdown('SIGTERM'); });
|
|
867
877
|
// Last-resort handlers so a hard crash produces one tagged ERROR line (+ a
|
|
868
878
|
// best-effort event flush) instead of an untagged default Node stack trace —
|
|
869
879
|
// these are exactly the failures that never reach /events on their own.
|
|
@@ -891,7 +901,15 @@ export async function startPoller() {
|
|
|
891
901
|
});
|
|
892
902
|
// Token-based auth: fetch credentials before starting
|
|
893
903
|
if (config.TEAMVIBE_API_URL && config.TEAMVIBE_POLLER_TOKEN) {
|
|
894
|
-
|
|
904
|
+
try {
|
|
905
|
+
const ready = await retryStartupAuth(() => initAuth(config.TEAMVIBE_API_URL, config.TEAMVIBE_POLLER_TOKEN), startupAbort.signal, logger, Math.random, [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
906
|
+
if (!ready)
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
logger.error(`Startup authentication failed permanently: ${startupFailureDetail(error, 'StartupAuth', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO])}`);
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
895
913
|
const pollerId = getPollerId();
|
|
896
914
|
if (pollerId) {
|
|
897
915
|
setLogContext({ pollerId });
|
|
@@ -902,9 +920,19 @@ export async function startPoller() {
|
|
|
902
920
|
logger.info(` Queue: ${config.SQS_QUEUE_URL}`);
|
|
903
921
|
logger.info(` Sessions table: ${config.SESSIONS_TABLE}`);
|
|
904
922
|
}
|
|
923
|
+
if (startupAbort.signal.aborted)
|
|
924
|
+
return;
|
|
905
925
|
startEventQueue();
|
|
906
926
|
await ensureDirectories();
|
|
907
|
-
|
|
927
|
+
try {
|
|
928
|
+
const ready = await retryStartupOperation('BaseBrain', ensureBaseBrain, startupAbort.signal, logger, Math.random, [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
929
|
+
if (!ready)
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
catch (error) {
|
|
933
|
+
logger.error(`Startup base brain failed permanently: ${startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO])}`);
|
|
934
|
+
process.exit(1);
|
|
935
|
+
}
|
|
908
936
|
reconcileBgTasksOnBoot();
|
|
909
937
|
if (config.IDLE_RESTART_ENABLED) {
|
|
910
938
|
// Defaults off (tv#317 follow-up, DevGuru review): a silent disabled gate and an
|
package/dist/slack-client.d.ts
CHANGED
|
@@ -7,11 +7,13 @@ export declare function sendSlackMessage(msg: TeamVibeQueueMessage, text: string
|
|
|
7
7
|
export declare function sendSlackError(msg: TeamVibeQueueMessage, error: string): Promise<void>;
|
|
8
8
|
export declare function addReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
|
|
9
9
|
export declare function setThreadStatus(msg: TeamVibeQueueMessage, status: string): Promise<void>;
|
|
10
|
+
type TypingStatusSource = 'comment' | 'default' | `tool:${string}`;
|
|
10
11
|
export interface TypingIndicator {
|
|
11
12
|
/** Update the displayed status text */
|
|
12
|
-
updateStatus: (newStatus: string) => void;
|
|
13
|
+
updateStatus: (newStatus: string, source?: TypingStatusSource) => void;
|
|
13
14
|
/** Stop the typing indicator and clear the status */
|
|
14
15
|
stop: () => void;
|
|
15
16
|
}
|
|
16
17
|
export declare function startTypingIndicator(msg: TeamVibeQueueMessage, statusText?: string): TypingIndicator;
|
|
17
18
|
export declare function removeReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
|
|
19
|
+
export {};
|
package/dist/slack-client.js
CHANGED
|
@@ -94,21 +94,35 @@ export async function setThreadStatus(msg, status) {
|
|
|
94
94
|
return;
|
|
95
95
|
const { channel, thread_ts } = msg.response_context.slack;
|
|
96
96
|
const slack = getSlackClient(msg.teamvibe.botToken);
|
|
97
|
+
const safeStatus = neutralizeSlackMarkup(status);
|
|
97
98
|
try {
|
|
98
99
|
await slack.apiCall('assistant.threads.setStatus', {
|
|
99
100
|
channel_id: channel,
|
|
100
101
|
thread_ts,
|
|
101
|
-
status,
|
|
102
|
+
status: safeStatus,
|
|
103
|
+
...(safeStatus ? { loading_messages: [safeStatus] } : {}),
|
|
102
104
|
});
|
|
103
105
|
}
|
|
104
|
-
catch
|
|
105
|
-
|
|
106
|
+
catch {
|
|
107
|
+
// API errors may echo request fields; status can contain private narration.
|
|
108
|
+
logger.warn('Failed to set thread status');
|
|
106
109
|
emitError('slack', `Failed to set thread status`, { severity: 'warn' });
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
|
-
|
|
112
|
+
function neutralizeSlackMarkup(text) {
|
|
113
|
+
return text.replace(/[<>&]/g, (character) => ({ '<': '<', '>': '>', '&': '&' })[character]);
|
|
114
|
+
}
|
|
115
|
+
export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
110
116
|
let currentStatus = statusText;
|
|
117
|
+
let stopped = false;
|
|
118
|
+
const logStatus = (status, source) => {
|
|
119
|
+
if (!status)
|
|
120
|
+
logger.info('[Typing] status cleared');
|
|
121
|
+
else
|
|
122
|
+
logger.info(`[Typing] status -> ${source} (${status.length} chars)`);
|
|
123
|
+
};
|
|
111
124
|
// Set initial status
|
|
125
|
+
logStatus(currentStatus, statusText === 'Thinking...' ? 'default' : 'comment');
|
|
112
126
|
setThreadStatus(msg, currentStatus);
|
|
113
127
|
// Keepalive every 3 seconds (Slack clears after ~2 min timeout)
|
|
114
128
|
let failures = 0;
|
|
@@ -125,14 +139,22 @@ export function startTypingIndicator(msg, statusText = 'is thinking...') {
|
|
|
125
139
|
}
|
|
126
140
|
}, 3000);
|
|
127
141
|
return {
|
|
128
|
-
updateStatus: (newStatus) => {
|
|
142
|
+
updateStatus: (newStatus, source = 'comment') => {
|
|
143
|
+
if (stopped)
|
|
144
|
+
return;
|
|
129
145
|
if (newStatus !== currentStatus) {
|
|
130
146
|
currentStatus = newStatus;
|
|
147
|
+
logStatus(currentStatus, source);
|
|
131
148
|
setThreadStatus(msg, currentStatus);
|
|
132
149
|
}
|
|
133
150
|
},
|
|
134
151
|
stop: () => {
|
|
152
|
+
if (stopped)
|
|
153
|
+
return;
|
|
154
|
+
stopped = true;
|
|
135
155
|
clearInterval(interval);
|
|
156
|
+
if (currentStatus)
|
|
157
|
+
logStatus('', 'default');
|
|
136
158
|
setThreadStatus(msg, '');
|
|
137
159
|
},
|
|
138
160
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface StartupAuthLogger {
|
|
2
|
+
warn(message: string): void;
|
|
3
|
+
error(message: string): void;
|
|
4
|
+
}
|
|
5
|
+
export declare class UnknownStartupFailureError extends Error {
|
|
6
|
+
readonly attempts: number;
|
|
7
|
+
constructor(attempts: number);
|
|
8
|
+
}
|
|
9
|
+
/** The only formatter used for startup retry and terminal failure logs. */
|
|
10
|
+
export declare function redactStartupText(text: string, configuredTokens?: readonly string[]): string;
|
|
11
|
+
export declare function startupFailureDetail(error: unknown, operation: string, configuredTokens?: readonly string[]): string;
|
|
12
|
+
export declare function isPermanentStartupFailure(error: unknown): boolean;
|
|
13
|
+
/** Returns false when shutdown cancels startup. Permanent errors propagate to the caller. */
|
|
14
|
+
export declare function retryStartupOperation(operation: string, run: () => Promise<void>, signal: AbortSignal, log: StartupAuthLogger, random?: () => number, configuredTokens?: readonly string[]): Promise<boolean>;
|
|
15
|
+
export declare function retryStartupAuth(authenticate: () => Promise<void>, signal: AbortSignal, log: StartupAuthLogger, random?: () => number, configuredTokens?: readonly string[]): Promise<boolean>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { AuthHttpError, StartupConfigurationError } from './auth-provider.js';
|
|
2
|
+
export class UnknownStartupFailureError extends Error {
|
|
3
|
+
attempts;
|
|
4
|
+
constructor(attempts) {
|
|
5
|
+
super(`unclassified startup failure after ${attempts} attempts`);
|
|
6
|
+
this.attempts = attempts;
|
|
7
|
+
this.name = 'UnknownStartupFailureError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const NETWORK_CODES = new Set([
|
|
11
|
+
'EAI_AGAIN', 'ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT',
|
|
12
|
+
'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
|
|
13
|
+
]);
|
|
14
|
+
function isTransient(error) {
|
|
15
|
+
if (error instanceof AuthHttpError)
|
|
16
|
+
return error.status === 429 || error.status >= 500;
|
|
17
|
+
if (error instanceof StartupConfigurationError)
|
|
18
|
+
return false;
|
|
19
|
+
if (!(error instanceof Error))
|
|
20
|
+
return false;
|
|
21
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError')
|
|
22
|
+
return true;
|
|
23
|
+
if (error instanceof TypeError && /fetch failed|network|timeout/i.test(error.message))
|
|
24
|
+
return true;
|
|
25
|
+
if (/could not resolve host|could not resolve hostname|temporary failure in name resolution|failed to connect|connection (timed out|refused|reset)|network is unreachable|(?:HTTP|response|returned error:?)\s*(?:429|5\d\d)\b/i.test(error.message))
|
|
26
|
+
return true;
|
|
27
|
+
let cause = error;
|
|
28
|
+
for (let depth = 0; depth < 4 && cause && typeof cause === 'object'; depth++) {
|
|
29
|
+
const code = cause.code;
|
|
30
|
+
if (typeof code === 'string' && NETWORK_CODES.has(code))
|
|
31
|
+
return true;
|
|
32
|
+
cause = cause.cause;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/** The only formatter used for startup retry and terminal failure logs. */
|
|
37
|
+
export function redactStartupText(text, configuredTokens = []) {
|
|
38
|
+
let safe = text
|
|
39
|
+
.replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
|
|
40
|
+
.replace(/\b(?:Authorization\s*:\s*)?Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
|
|
41
|
+
.replace(/\bx-access-token\s*:\s*[^\s/@,;]+/gi, 'x-access-token:[REDACTED]');
|
|
42
|
+
for (const token of configuredTokens) {
|
|
43
|
+
if (token)
|
|
44
|
+
safe = safe.split(token).join('[REDACTED]');
|
|
45
|
+
}
|
|
46
|
+
return safe;
|
|
47
|
+
}
|
|
48
|
+
export function startupFailureDetail(error, operation, configuredTokens = []) {
|
|
49
|
+
let detail;
|
|
50
|
+
if (error instanceof AuthHttpError)
|
|
51
|
+
detail = `HTTP ${error.status}`;
|
|
52
|
+
else if (error instanceof StartupConfigurationError)
|
|
53
|
+
detail = 'invalid startup configuration';
|
|
54
|
+
else if (error instanceof UnknownStartupFailureError)
|
|
55
|
+
detail = error.message;
|
|
56
|
+
else if (operation === 'BaseBrain') {
|
|
57
|
+
const code = error && typeof error === 'object' ? error.code : undefined;
|
|
58
|
+
const exit = typeof code === 'number' ? code : 'unknown';
|
|
59
|
+
const message = error instanceof Error ? error.message : '';
|
|
60
|
+
const category = /could not resolve|name resolution|ENOTFOUND|EAI_AGAIN/i.test(message) ? 'DNS failure'
|
|
61
|
+
: /timed out|timeout|ETIMEDOUT/i.test(message) ? 'connection timeout'
|
|
62
|
+
: /failed to connect|connection refused|ECONNREFUSED/i.test(message) ? 'connection failure'
|
|
63
|
+
: /authentication failed|permission denied|403|401/i.test(message) ? 'authentication failure'
|
|
64
|
+
: 'git failure';
|
|
65
|
+
detail = `${category} (exit ${exit})`;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
let code = error;
|
|
69
|
+
for (let depth = 0; depth < 4 && code && typeof code === 'object'; depth++) {
|
|
70
|
+
const value = code.code;
|
|
71
|
+
if (typeof value === 'string' && NETWORK_CODES.has(value)) {
|
|
72
|
+
detail = value;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
code = code.cause;
|
|
76
|
+
}
|
|
77
|
+
detail ??= error instanceof TypeError && /fetch failed/i.test(error.message) ? 'fetch failed'
|
|
78
|
+
: error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name) ? error.name
|
|
79
|
+
: 'unclassified error';
|
|
80
|
+
}
|
|
81
|
+
return redactStartupText(detail ?? 'unclassified error', configuredTokens);
|
|
82
|
+
}
|
|
83
|
+
export function isPermanentStartupFailure(error) {
|
|
84
|
+
return error instanceof StartupConfigurationError ||
|
|
85
|
+
(error instanceof AuthHttpError && error.status !== 429 && error.status < 500);
|
|
86
|
+
}
|
|
87
|
+
function waitForBackoff(ms, signal) {
|
|
88
|
+
if (signal.aborted)
|
|
89
|
+
return Promise.resolve(false);
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const finish = (ready) => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
signal.removeEventListener('abort', onAbort);
|
|
94
|
+
resolve(ready);
|
|
95
|
+
};
|
|
96
|
+
const onAbort = () => finish(false);
|
|
97
|
+
const timer = setTimeout(() => finish(true), ms);
|
|
98
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
99
|
+
if (signal.aborted)
|
|
100
|
+
onAbort();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** Returns false when shutdown cancels startup. Permanent errors propagate to the caller. */
|
|
104
|
+
export async function retryStartupOperation(operation, run, signal, log, random = Math.random, configuredTokens = []) {
|
|
105
|
+
let attempt = 0;
|
|
106
|
+
while (!signal.aborted) {
|
|
107
|
+
attempt++;
|
|
108
|
+
try {
|
|
109
|
+
await run();
|
|
110
|
+
return !signal.aborted;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (signal.aborted)
|
|
114
|
+
return false;
|
|
115
|
+
if (isPermanentStartupFailure(error))
|
|
116
|
+
throw error;
|
|
117
|
+
const transient = isTransient(error);
|
|
118
|
+
if (!transient && attempt >= 5) {
|
|
119
|
+
log.error(`[${operation}] attempt ${attempt} failed: ${startupFailureDetail(error, operation, configuredTokens)}; unclassified failure limit reached`);
|
|
120
|
+
throw new UnknownStartupFailureError(attempt);
|
|
121
|
+
}
|
|
122
|
+
const baseMs = Math.min(1000 * 2 ** Math.min(attempt - 1, 6), 60_000);
|
|
123
|
+
const delayMs = Math.min(60_000, Math.round(baseMs * (1 + 0.2 * random())));
|
|
124
|
+
const message = `[${operation}] attempt ${attempt} failed: ${startupFailureDetail(error, operation, configuredTokens)}; retrying in ${delayMs}ms`;
|
|
125
|
+
if (transient)
|
|
126
|
+
log.warn(message);
|
|
127
|
+
else
|
|
128
|
+
log.error(message);
|
|
129
|
+
if (!await waitForBackoff(delayMs, signal))
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
export function retryStartupAuth(authenticate, signal, log, random = Math.random, configuredTokens = []) {
|
|
136
|
+
return retryStartupOperation('StartupAuth', authenticate, signal, log, random, configuredTokens);
|
|
137
|
+
}
|