@teamvibe/poller 0.5.2 → 0.6.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/claude-spawner.d.ts +32 -0
- package/dist/claude-spawner.js +54 -3
- package/dist/config-client.d.ts +44 -0
- package/dist/config-client.js +113 -0
- package/dist/logger.d.ts +1 -0
- package/dist/logger.js +32 -13
- package/dist/poller.js +40 -5
- package/package.json +1 -1
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 ResolvedModelConfig } from './config-client.js';
|
|
4
5
|
import type { KilledByPoller } from './classify-failed-iteration.js';
|
|
5
6
|
export interface ContextHintParams {
|
|
6
7
|
isFirstMessage: boolean;
|
|
@@ -21,7 +22,38 @@ export interface SpawnResult {
|
|
|
21
22
|
error?: string;
|
|
22
23
|
metrics?: SessionMetrics;
|
|
23
24
|
killedByPoller?: KilledByPoller;
|
|
25
|
+
modelSource?: ModelSource;
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* `settings.json` and `env-default` are poller-local, not DDB-resolved
|
|
29
|
+
* scopes — kept as a superset of `services/model-config.ts`'s
|
|
30
|
+
* `ModelConfigScope` rather than importing it, since the poller package
|
|
31
|
+
* doesn't depend on `modules-teamvibe`.
|
|
32
|
+
*/
|
|
33
|
+
export type ModelSource = 'settings.json' | 'poller' | 'workspace' | 'channel' | 'env-default' | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* 3-tier model resolution — teamvibe.ai#235 PR2.
|
|
36
|
+
*
|
|
37
|
+
* Precedence (highest to lowest): brain `settings.json` > DDB-resolved
|
|
38
|
+
* scoped config (poller/workspace/channel, fetched via
|
|
39
|
+
* `fetchConfigForSpawn`) > `CLAUDE_MODEL` env default.
|
|
40
|
+
*
|
|
41
|
+
* `settings.json` staying on top is a **known, documented, accepted
|
|
42
|
+
* blind spot** (see DevGuru's tv#235 approach-review addendum): a
|
|
43
|
+
* poller-level emergency model cap set via DDB can be silently
|
|
44
|
+
* overridden by any brain's local settings.json. Not fixed here — PR3
|
|
45
|
+
* adds a read-only "last observed override source" surfaced via the
|
|
46
|
+
* `session.completed` telemetry this function's return value feeds
|
|
47
|
+
* (see the `modelSource` field on `SpawnResult` / the `poller.ts`
|
|
48
|
+
* `emitEvent` call site).
|
|
49
|
+
*
|
|
50
|
+
* Pure function — no I/O — so it's unit-testable without mocking
|
|
51
|
+
* `fetch` or `child_process.spawn`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveModel(brainSettingsModel: string | undefined, ddbConfig: Pick<ResolvedModelConfig, 'model' | 'modelSource'>, envDefaultModel: string | undefined): {
|
|
54
|
+
model: string | undefined;
|
|
55
|
+
modelSource: ModelSource;
|
|
56
|
+
};
|
|
25
57
|
export declare function buildPrompt(msg: TeamVibeQueueMessage, contextHint?: string): string;
|
|
26
58
|
export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void, typingIndicator?: TypingIndicator): Promise<SpawnResult & {
|
|
27
59
|
newSessionId?: string;
|
package/dist/claude-spawner.js
CHANGED
|
@@ -9,6 +9,7 @@ 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
11
|
import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js';
|
|
12
|
+
import { fetchConfigForSpawn } from './config-client.js';
|
|
12
13
|
/**
|
|
13
14
|
* Maps tool names (or prefixes) to human-readable status messages.
|
|
14
15
|
* More specific patterns are checked first.
|
|
@@ -167,6 +168,37 @@ function readBrainSettings(cwd) {
|
|
|
167
168
|
return {};
|
|
168
169
|
}
|
|
169
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* 3-tier model resolution — teamvibe.ai#235 PR2.
|
|
173
|
+
*
|
|
174
|
+
* Precedence (highest to lowest): brain `settings.json` > DDB-resolved
|
|
175
|
+
* scoped config (poller/workspace/channel, fetched via
|
|
176
|
+
* `fetchConfigForSpawn`) > `CLAUDE_MODEL` env default.
|
|
177
|
+
*
|
|
178
|
+
* `settings.json` staying on top is a **known, documented, accepted
|
|
179
|
+
* blind spot** (see DevGuru's tv#235 approach-review addendum): a
|
|
180
|
+
* poller-level emergency model cap set via DDB can be silently
|
|
181
|
+
* overridden by any brain's local settings.json. Not fixed here — PR3
|
|
182
|
+
* adds a read-only "last observed override source" surfaced via the
|
|
183
|
+
* `session.completed` telemetry this function's return value feeds
|
|
184
|
+
* (see the `modelSource` field on `SpawnResult` / the `poller.ts`
|
|
185
|
+
* `emitEvent` call site).
|
|
186
|
+
*
|
|
187
|
+
* Pure function — no I/O — so it's unit-testable without mocking
|
|
188
|
+
* `fetch` or `child_process.spawn`.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveModel(brainSettingsModel, ddbConfig, envDefaultModel) {
|
|
191
|
+
if (brainSettingsModel) {
|
|
192
|
+
return { model: brainSettingsModel, modelSource: 'settings.json' };
|
|
193
|
+
}
|
|
194
|
+
if (ddbConfig.model) {
|
|
195
|
+
return { model: ddbConfig.model, modelSource: ddbConfig.modelSource };
|
|
196
|
+
}
|
|
197
|
+
if (envDefaultModel) {
|
|
198
|
+
return { model: envDefaultModel, modelSource: 'env-default' };
|
|
199
|
+
}
|
|
200
|
+
return { model: undefined, modelSource: undefined };
|
|
201
|
+
}
|
|
170
202
|
// Tags procs at SIGTERM time so the close handler can report poller intent.
|
|
171
203
|
// WeakMap auto-cleans when proc is GC'd. Module-scope is required because
|
|
172
204
|
// killZombieForThread fires outside the runClaudeCode closure.
|
|
@@ -191,6 +223,12 @@ export function buildPrompt(msg, contextHint) {
|
|
|
191
223
|
? 'Direct Message'
|
|
192
224
|
: `Channel ${slack.channel}`;
|
|
193
225
|
prompt += `**Channel:** ${channelType}\n`;
|
|
226
|
+
// The agent's reply defaults to this thread. Stating it here is what stops
|
|
227
|
+
// the agent from reconstructing "the thread we were in" from memory and
|
|
228
|
+
// replying into a stale one — see teamvibeai/teamvibe.ai#184.
|
|
229
|
+
if (slack.thread_ts) {
|
|
230
|
+
prompt += `**Thread TS:** \`${slack.thread_ts}\` (your reply defaults here)\n`;
|
|
231
|
+
}
|
|
194
232
|
}
|
|
195
233
|
// Inject human-readable timestamp from Slack message ts
|
|
196
234
|
if (msg.response_context.slack?.message_ts) {
|
|
@@ -333,6 +371,14 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
333
371
|
channelId: msg.teamvibe.channelId,
|
|
334
372
|
});
|
|
335
373
|
const secretsReminder = buildSecretsReminder(spawnSecrets.reminders);
|
|
374
|
+
// Fetch scoped model config for this spawn — same hoisting rationale as
|
|
375
|
+
// secrets above. Fail-soft: returns {} on any error, which resolveModel
|
|
376
|
+
// treats as "nothing at this tier" and falls through to the env default.
|
|
377
|
+
const ddbModelConfig = await fetchConfigForSpawn({
|
|
378
|
+
pollerId: getPollerId() || '',
|
|
379
|
+
workspaceId: msg.teamvibe.workspaceId,
|
|
380
|
+
channelId: msg.teamvibe.channelId,
|
|
381
|
+
});
|
|
336
382
|
return new Promise((resolve) => {
|
|
337
383
|
const prompt = buildPrompt(msg, contextHint);
|
|
338
384
|
// MCP server config passed via --mcp-config flag (no temp files needed)
|
|
@@ -358,11 +404,15 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
358
404
|
'--disallowedTools',
|
|
359
405
|
'NotebookEdit',
|
|
360
406
|
];
|
|
361
|
-
// Model override
|
|
362
|
-
|
|
407
|
+
// Model override — 3-tier precedence (teamvibe.ai#235 PR2): brain
|
|
408
|
+
// settings.json > DDB-resolved scoped config (poller/workspace/channel)
|
|
409
|
+
// > CLAUDE_MODEL env default. See resolveModel's docstring for why
|
|
410
|
+
// settings.json stays on top (known, accepted blind spot).
|
|
411
|
+
const brainSettingsModel = typeof brainSettings['model'] === 'string' ? brainSettings['model'] : undefined;
|
|
412
|
+
const { model, modelSource } = resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
|
|
363
413
|
if (model) {
|
|
364
414
|
args.push('--model', model);
|
|
365
|
-
sessionLog.info(`Using model: ${model}`);
|
|
415
|
+
sessionLog.info(`Using model: ${model} (source: ${modelSource ?? 'unknown'})`);
|
|
366
416
|
}
|
|
367
417
|
// Inbox protocol: instruct Claude to check for mid-task messages (thread-scoped)
|
|
368
418
|
const inboxNewDir = `.inbox/${msg.thread_id}/new`;
|
|
@@ -595,6 +645,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
|
|
|
595
645
|
output: stdout,
|
|
596
646
|
exitCode: code,
|
|
597
647
|
metrics,
|
|
648
|
+
modelSource,
|
|
598
649
|
...(killReason ? { killedByPoller: killReason } : {}),
|
|
599
650
|
};
|
|
600
651
|
if (killReason === 'timeout') {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poller-side non-secret scoped config client — teamvibe.ai#235 (PR2).
|
|
3
|
+
*
|
|
4
|
+
* Fetches the resolved scoped config from the Poller API
|
|
5
|
+
* (GET /config/spawn?pollerId=&workspaceId=&channelId=) before spawn.
|
|
6
|
+
* Mirrors `secrets-client.ts`'s defensive-failure pattern: on any failure
|
|
7
|
+
* mode (missing config, network error, non-2xx, malformed JSON) this
|
|
8
|
+
* returns an empty result rather than throwing — spawn must never fail
|
|
9
|
+
* because this fetch failed, it just falls through to the next
|
|
10
|
+
* precedence tier (settings.json / CLAUDE_MODEL env default) in
|
|
11
|
+
* `claude-spawner.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Unlike secrets, this config is NOT sensitive — no `Redacted`/opaque
|
|
14
|
+
* envelope wrapper needed, the resolved value is returned as a plain
|
|
15
|
+
* object.
|
|
16
|
+
*
|
|
17
|
+
* Wire shape is a generic `env`/`sources` envelope (see
|
|
18
|
+
* `poller-config.ts`'s `buildConfigResponse` on the server side) so
|
|
19
|
+
* future non-secret settings land as additional `env` keys without a new
|
|
20
|
+
* endpoint. This client unwraps that envelope into the typed
|
|
21
|
+
* `ResolvedModelConfig` shape `claude-spawner.ts` already consumes —
|
|
22
|
+
* today only the `MODEL`/`REASONING_EFFORT` keys exist, so the unwrap is
|
|
23
|
+
* a fixed 1:1 mapping, not a generic loop; widen it if/when a third key
|
|
24
|
+
* appears.
|
|
25
|
+
*/
|
|
26
|
+
export type ModelConfigScope = 'poller' | 'workspace' | 'channel';
|
|
27
|
+
export interface ResolvedModelConfig {
|
|
28
|
+
model?: string;
|
|
29
|
+
reasoningEffort?: string;
|
|
30
|
+
modelSource?: ModelConfigScope;
|
|
31
|
+
reasoningEffortSource?: ModelConfigScope;
|
|
32
|
+
}
|
|
33
|
+
export interface FetchModelConfigInput {
|
|
34
|
+
pollerId: string;
|
|
35
|
+
workspaceId: string;
|
|
36
|
+
channelId: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fetch the resolved config for a spawn. Returns `{}` on any failure
|
|
40
|
+
* mode — spawn continues, falling through to the env-default model tier.
|
|
41
|
+
* Errors are logged at warn level so monitoring can alarm; mirrors
|
|
42
|
+
* `fetchSecretsForSpawn`'s fail-soft contract exactly.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fetchConfigForSpawn(input: FetchModelConfigInput): Promise<ResolvedModelConfig>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poller-side non-secret scoped config client — teamvibe.ai#235 (PR2).
|
|
3
|
+
*
|
|
4
|
+
* Fetches the resolved scoped config from the Poller API
|
|
5
|
+
* (GET /config/spawn?pollerId=&workspaceId=&channelId=) before spawn.
|
|
6
|
+
* Mirrors `secrets-client.ts`'s defensive-failure pattern: on any failure
|
|
7
|
+
* mode (missing config, network error, non-2xx, malformed JSON) this
|
|
8
|
+
* returns an empty result rather than throwing — spawn must never fail
|
|
9
|
+
* because this fetch failed, it just falls through to the next
|
|
10
|
+
* precedence tier (settings.json / CLAUDE_MODEL env default) in
|
|
11
|
+
* `claude-spawner.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Unlike secrets, this config is NOT sensitive — no `Redacted`/opaque
|
|
14
|
+
* envelope wrapper needed, the resolved value is returned as a plain
|
|
15
|
+
* object.
|
|
16
|
+
*
|
|
17
|
+
* Wire shape is a generic `env`/`sources` envelope (see
|
|
18
|
+
* `poller-config.ts`'s `buildConfigResponse` on the server side) so
|
|
19
|
+
* future non-secret settings land as additional `env` keys without a new
|
|
20
|
+
* endpoint. This client unwraps that envelope into the typed
|
|
21
|
+
* `ResolvedModelConfig` shape `claude-spawner.ts` already consumes —
|
|
22
|
+
* today only the `MODEL`/`REASONING_EFFORT` keys exist, so the unwrap is
|
|
23
|
+
* a fixed 1:1 mapping, not a generic loop; widen it if/when a third key
|
|
24
|
+
* appears.
|
|
25
|
+
*/
|
|
26
|
+
import { logger } from './logger.js';
|
|
27
|
+
import { config } from './config.js';
|
|
28
|
+
const SCOPES = new Set(['poller', 'workspace', 'channel']);
|
|
29
|
+
function isModelConfigScope(v) {
|
|
30
|
+
return typeof v === 'string' && SCOPES.has(v);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Fetch the resolved config for a spawn. Returns `{}` on any failure
|
|
34
|
+
* mode — spawn continues, falling through to the env-default model tier.
|
|
35
|
+
* Errors are logged at warn level so monitoring can alarm; mirrors
|
|
36
|
+
* `fetchSecretsForSpawn`'s fail-soft contract exactly.
|
|
37
|
+
*/
|
|
38
|
+
export async function fetchConfigForSpawn(input) {
|
|
39
|
+
if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
|
|
40
|
+
logger.warn('config-client: TEAMVIBE_API_URL or TEAMVIBE_POLLER_TOKEN not set; skipping fetch');
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
// Upfront short-circuit: an empty pollerId would fail server-side
|
|
44
|
+
// validation and round-trip an HTTP error. Skip the call entirely so
|
|
45
|
+
// the warn log makes the cause obvious from poller stdout.
|
|
46
|
+
if (!input.pollerId) {
|
|
47
|
+
logger.warn('config-client: pollerId empty; skipping fetch (no scoped config for spawn)');
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL('/config/spawn', config.TEAMVIBE_API_URL);
|
|
53
|
+
url.searchParams.set('pollerId', input.pollerId);
|
|
54
|
+
url.searchParams.set('workspaceId', input.workspaceId);
|
|
55
|
+
url.searchParams.set('channelId', input.channelId);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
logger.warn(`config-client.config_error: invalid TEAMVIBE_API_URL=${String(config.TEAMVIBE_API_URL)} ` +
|
|
59
|
+
`error=${err?.message ?? String(err)}`);
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
let response;
|
|
63
|
+
try {
|
|
64
|
+
response = await fetch(url.toString(), {
|
|
65
|
+
method: 'GET',
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
logger.warn(`config-client.fetch_failed: network error pollerId=${input.pollerId} ` +
|
|
73
|
+
`workspaceId=${input.workspaceId} channelId=${input.channelId} ` +
|
|
74
|
+
`error=${err?.message ?? String(err)}`);
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
let snippet = '';
|
|
79
|
+
try {
|
|
80
|
+
snippet = (await response.text()).slice(0, 200);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
snippet = '<failed-to-read-body>';
|
|
84
|
+
}
|
|
85
|
+
logger.warn(`config-client.fetch_failed: status=${response.status} ` +
|
|
86
|
+
`pollerId=${input.pollerId} workspaceId=${input.workspaceId} ` +
|
|
87
|
+
`channelId=${input.channelId} bodySnippet=${snippet}`);
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = (await response.json());
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
logger.warn(`config-client.parse_failed: pollerId=${input.pollerId} ` +
|
|
96
|
+
`error=${err?.message ?? String(err)}`);
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
const env = parsed.env ?? {};
|
|
100
|
+
const sources = parsed.sources ?? {};
|
|
101
|
+
const result = {};
|
|
102
|
+
if (typeof env.MODEL === 'string' && env.MODEL)
|
|
103
|
+
result.model = env.MODEL;
|
|
104
|
+
if (typeof env.REASONING_EFFORT === 'string' && env.REASONING_EFFORT) {
|
|
105
|
+
result.reasoningEffort = env.REASONING_EFFORT;
|
|
106
|
+
}
|
|
107
|
+
if (isModelConfigScope(sources.MODEL))
|
|
108
|
+
result.modelSource = sources.MODEL;
|
|
109
|
+
if (isModelConfigScope(sources.REASONING_EFFORT))
|
|
110
|
+
result.reasoningEffortSource = sources.REASONING_EFFORT;
|
|
111
|
+
logger.info(`config-client: resolved model=${result.model ?? '(none)'} source=${result.modelSource ?? '(none)'}`);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
package/dist/logger.d.ts
CHANGED
package/dist/logger.js
CHANGED
|
@@ -15,6 +15,17 @@ function formatDate(date) {
|
|
|
15
15
|
return date.toISOString();
|
|
16
16
|
}
|
|
17
17
|
const now = () => formatDate(new Date());
|
|
18
|
+
// Optional global context (e.g. pollerId) prepended to every log line so an
|
|
19
|
+
// operator watching Railway stdout can filter by poller without a session id.
|
|
20
|
+
// Set once after auth resolves; see setLogContext call in poller startup.
|
|
21
|
+
let logContext = {};
|
|
22
|
+
export function setLogContext(ctx) {
|
|
23
|
+
logContext = { ...logContext, ...ctx };
|
|
24
|
+
}
|
|
25
|
+
function contextPrefix() {
|
|
26
|
+
const entries = Object.entries(logContext);
|
|
27
|
+
return entries.length ? entries.map(([k, v]) => `${k}=${v}`).join(' ') + ' ' : '';
|
|
28
|
+
}
|
|
18
29
|
function writeToFile(level, message, timestamp, ...args) {
|
|
19
30
|
const formattedArgs = args
|
|
20
31
|
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
|
@@ -46,30 +57,34 @@ export class SessionLogger {
|
|
|
46
57
|
}
|
|
47
58
|
info(message) {
|
|
48
59
|
const ts = now();
|
|
49
|
-
|
|
60
|
+
const cp = contextPrefix();
|
|
61
|
+
console.log(`[${ts}] [${this.sessionId}] ${cp}${message}`);
|
|
50
62
|
this.write('INFO', message, ts);
|
|
51
63
|
writeToFile('INFO', `[${this.sessionId}] ${message}`, ts);
|
|
52
64
|
}
|
|
53
65
|
error(message) {
|
|
54
66
|
const ts = now();
|
|
55
|
-
|
|
67
|
+
const cp = contextPrefix();
|
|
68
|
+
console.error(`[${ts}] [${this.sessionId}] ${cp}${message}`);
|
|
56
69
|
this.write('ERROR', message, ts);
|
|
57
70
|
writeToFile('ERROR', `[${this.sessionId}] ${message}`, ts);
|
|
58
71
|
}
|
|
59
72
|
warn(message) {
|
|
60
73
|
const ts = now();
|
|
61
|
-
|
|
74
|
+
const cp = contextPrefix();
|
|
75
|
+
console.warn(`[${ts}] [${this.sessionId}] ${cp}${message}`);
|
|
62
76
|
this.write('WARN', message, ts);
|
|
63
77
|
writeToFile('WARN', `[${this.sessionId}] ${message}`, ts);
|
|
64
78
|
}
|
|
65
79
|
claude(stream, message) {
|
|
66
80
|
const ts = now();
|
|
81
|
+
const cp = contextPrefix();
|
|
67
82
|
const prefix = `[Claude ${stream}]`;
|
|
68
83
|
if (stream === 'stderr') {
|
|
69
|
-
console.error(`[${ts}] [${this.sessionId}] ${prefix}`, message);
|
|
84
|
+
console.error(`[${ts}] [${this.sessionId}] ${cp}${prefix}`, message);
|
|
70
85
|
}
|
|
71
86
|
else {
|
|
72
|
-
console.log(`[${ts}] [${this.sessionId}] ${prefix}`, message);
|
|
87
|
+
console.log(`[${ts}] [${this.sessionId}] ${cp}${prefix}`, message);
|
|
73
88
|
}
|
|
74
89
|
this.write(`CLAUDE_${stream.toUpperCase()}`, message, ts);
|
|
75
90
|
}
|
|
@@ -83,25 +98,29 @@ export class SessionLogger {
|
|
|
83
98
|
export const logger = {
|
|
84
99
|
info(message, ...args) {
|
|
85
100
|
const ts = now();
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
const cp = contextPrefix();
|
|
102
|
+
console.log(`[${ts}] [INFO] ${cp}${message}`, ...args);
|
|
103
|
+
writeToFile('INFO', `${cp}${message}`, ts, ...args);
|
|
88
104
|
},
|
|
89
105
|
error(message, ...args) {
|
|
90
106
|
const ts = now();
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
const cp = contextPrefix();
|
|
108
|
+
console.error(`[${ts}] [ERROR] ${cp}${message}`, ...args);
|
|
109
|
+
writeToFile('ERROR', `${cp}${message}`, ts, ...args);
|
|
93
110
|
},
|
|
94
111
|
warn(message, ...args) {
|
|
95
112
|
const ts = now();
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
const cp = contextPrefix();
|
|
114
|
+
console.warn(`[${ts}] [WARN] ${cp}${message}`, ...args);
|
|
115
|
+
writeToFile('WARN', `${cp}${message}`, ts, ...args);
|
|
98
116
|
},
|
|
99
117
|
debug(message, ...args) {
|
|
100
118
|
const ts = now();
|
|
119
|
+
const cp = contextPrefix();
|
|
101
120
|
if (process.env['DEBUG']) {
|
|
102
|
-
console.debug(`[${ts}] [DEBUG] ${message}`, ...args);
|
|
121
|
+
console.debug(`[${ts}] [DEBUG] ${cp}${message}`, ...args);
|
|
103
122
|
}
|
|
104
|
-
writeToFile('DEBUG', message
|
|
123
|
+
writeToFile('DEBUG', `${cp}${message}`, ts, ...args);
|
|
105
124
|
},
|
|
106
125
|
createSession(sessionId) {
|
|
107
126
|
return new SessionLogger(sessionId);
|
package/dist/poller.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { config } from './config.js';
|
|
2
|
-
import { logger } from './logger.js';
|
|
1
|
+
import { config, POLLER_VERSION } from './config.js';
|
|
2
|
+
import { logger, setLogContext } 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
5
|
import { sendSlackError, sendSlackMessage, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
|
|
@@ -8,9 +8,9 @@ import { acquireSessionLock, releaseSessionLock, updateSessionId } from './sessi
|
|
|
8
8
|
import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
|
|
9
9
|
import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
|
|
10
10
|
import { postMaintenanceReports } from './maintenance-reporter.js';
|
|
11
|
-
import { initAuth, stopRefresh } from './auth-provider.js';
|
|
11
|
+
import { initAuth, stopRefresh, getPollerId } from './auth-provider.js';
|
|
12
12
|
import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './maintenance-cron.js';
|
|
13
|
-
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent } from './event-queue.js';
|
|
13
|
+
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
|
|
14
14
|
import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
|
|
15
15
|
import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
|
|
16
16
|
import { Cron } from 'croner';
|
|
@@ -210,6 +210,11 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
|
210
210
|
duration_api_ms: result.metrics.duration_api_ms,
|
|
211
211
|
num_turns: result.metrics.num_turns,
|
|
212
212
|
model: result.metrics.model,
|
|
213
|
+
// Which config layer actually chose the `--model` flag
|
|
214
|
+
// (settings.json / poller / workspace / channel / env-default)
|
|
215
|
+
// — teamvibe.ai#235 PR2. Distinct from `model` above, which is
|
|
216
|
+
// Claude CLI's own report of the model it used.
|
|
217
|
+
modelSource: result.modelSource,
|
|
213
218
|
});
|
|
214
219
|
}
|
|
215
220
|
if (queueMessage.teamvibe.brain?.brainId) {
|
|
@@ -533,14 +538,44 @@ async function shutdown(signal, exitCode = 0) {
|
|
|
533
538
|
process.exit(exitCode);
|
|
534
539
|
}
|
|
535
540
|
export async function startPoller() {
|
|
536
|
-
logger.info(
|
|
541
|
+
logger.info(`TeamVibe Poller v${POLLER_VERSION} starting...`);
|
|
537
542
|
logger.info(` Max concurrent: ${config.MAX_CONCURRENT_SESSIONS}`);
|
|
538
543
|
logger.info(` Brains path: ${config.BRAINS_PATH}`);
|
|
539
544
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
540
545
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
546
|
+
// Last-resort handlers so a hard crash produces one tagged ERROR line (+ a
|
|
547
|
+
// best-effort event flush) instead of an untagged default Node stack trace —
|
|
548
|
+
// these are exactly the failures that never reach /events on their own.
|
|
549
|
+
process.on('uncaughtException', (err) => {
|
|
550
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
551
|
+
logger.error(`Uncaught exception, shutting down: ${detail}`);
|
|
552
|
+
emitError('session', `Uncaught exception: ${err instanceof Error ? err.message : String(err)}`, {
|
|
553
|
+
severity: 'error',
|
|
554
|
+
});
|
|
555
|
+
// The only invariant that matters in an undefined process state is that we
|
|
556
|
+
// actually exit. A stuck flush (e.g. the hung platform connection we want to
|
|
557
|
+
// report) must never block termination, so time-bound the best-effort flush.
|
|
558
|
+
const boundedFlush = Promise.race([
|
|
559
|
+
flushEventQueue(),
|
|
560
|
+
new Promise((resolve) => setTimeout(resolve, 2000)),
|
|
561
|
+
]);
|
|
562
|
+
void boundedFlush.finally(() => process.exit(1));
|
|
563
|
+
});
|
|
564
|
+
process.on('unhandledRejection', (reason) => {
|
|
565
|
+
const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
|
|
566
|
+
logger.error(`Unhandled promise rejection: ${detail}`);
|
|
567
|
+
emitError('session', `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`, {
|
|
568
|
+
severity: 'error',
|
|
569
|
+
});
|
|
570
|
+
});
|
|
541
571
|
// Token-based auth: fetch credentials before starting
|
|
542
572
|
if (config.TEAMVIBE_API_URL && config.TEAMVIBE_POLLER_TOKEN) {
|
|
543
573
|
await initAuth(config.TEAMVIBE_API_URL, config.TEAMVIBE_POLLER_TOKEN);
|
|
574
|
+
const pollerId = getPollerId();
|
|
575
|
+
if (pollerId) {
|
|
576
|
+
setLogContext({ pollerId });
|
|
577
|
+
logger.info(` Poller ID: ${pollerId}`);
|
|
578
|
+
}
|
|
544
579
|
}
|
|
545
580
|
else {
|
|
546
581
|
logger.info(` Queue: ${config.SQS_QUEUE_URL}`);
|