@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.
- 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/poller.js +5 -0
- 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/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) {
|