@teamvibe/poller 0.5.3 → 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.
@@ -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;
@@ -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: brain settings.json takes priority, then CLAUDE_MODEL env var
362
- const model = (typeof brainSettings['model'] === 'string' && brainSettings['model']) || config.CLAUDE_MODEL;
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/poller.js CHANGED
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {