@teamvibe/poller 0.8.3 → 0.8.4-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/README.md +13 -0
- package/dist/brain-directory.d.ts +14 -0
- package/dist/brain-directory.js +16 -0
- package/dist/brain-manager.d.ts +2 -2
- package/dist/brain-manager.js +32 -19
- package/dist/brain-path.d.ts +9 -0
- package/dist/brain-path.js +65 -0
- package/dist/claude-spawner.d.ts +3 -2
- package/dist/claude-spawner.js +189 -71
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/event-queue.js +14 -1
- package/dist/inbox-manager.d.ts +11 -0
- package/dist/inbox-manager.js +101 -6
- package/dist/inbox-watcher.d.ts +9 -4
- package/dist/inbox-watcher.js +219 -49
- package/dist/maintenance-cron.d.ts +4 -2
- package/dist/maintenance-cron.js +56 -14
- package/dist/metrics/scheduler.d.ts +4 -1
- package/dist/metrics/scheduler.js +8 -6
- package/dist/poller.d.ts +2 -1
- package/dist/poller.js +50 -24
- package/dist/pool-logging.js +2 -2
- package/dist/pool-telemetry.d.ts +20 -4
- package/dist/pool-telemetry.js +23 -0
- package/dist/session-store.d.ts +1 -1
- package/dist/session-store.js +6 -2
- package/dist/startup-auth.js +4 -1
- package/dist/subscription-pool-client.js +7 -0
- package/dist/subscription-pool.d.ts +1 -0
- package/dist/subscription-pool.js +1 -1
- package/dist/subscription-runtime.d.ts +20 -2
- package/dist/subscription-runtime.js +4 -4
- package/dist/subscription-selection.d.ts +13 -3
- package/dist/subscription-selection.js +18 -3
- package/dist/thread-routing.d.ts +23 -0
- package/dist/thread-routing.js +81 -0
- package/dist/types.d.ts +4 -10
- package/dist/usage-limit-messages.d.ts +1 -1
- package/dist/usage-limit-messages.js +1 -1
- package/dist/usage-probe-contract.d.ts +15 -0
- package/dist/usage-probe-contract.js +14 -0
- package/dist/usage-probe-job.js +18 -5
- package/dist/worktree-manager.d.ts +2 -1
- package/dist/worktree-manager.js +38 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/claude-spawner.js
CHANGED
|
@@ -3,10 +3,10 @@ import { accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './p
|
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { readFileSync, existsSync } from 'fs';
|
|
6
|
-
import { mkdir } from 'fs/promises';
|
|
7
6
|
import { WebClient } from '@slack/web-api';
|
|
8
7
|
import { config } from './config.js';
|
|
9
8
|
import { logger } from './logger.js';
|
|
9
|
+
import { executionDirectory } from './brain-directory.js';
|
|
10
10
|
import { getBaseBrainPath } from './brain-manager.js';
|
|
11
11
|
import { getPollerId } from './auth-provider.js';
|
|
12
12
|
import { sendSlackMessage } from './slack-client.js';
|
|
@@ -19,6 +19,44 @@ import { parseClaudeQuotaFailure } from './claude-quota.js';
|
|
|
19
19
|
import { accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
|
|
20
20
|
import { selectPoolForResolvedModel } from './subscription-pool.js';
|
|
21
21
|
import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
|
|
22
|
+
import { redactStartupText } from './startup-auth.js';
|
|
23
|
+
/** Inspect Claude's user/project settings without changing any brain files. */
|
|
24
|
+
function warnTokenAliasSettings(claudeConfigDir, cwd, brainPath, sessionLog) {
|
|
25
|
+
// CLAUDE_CONFIG_DIR replaces ~/.claude for user settings. settings.local.json
|
|
26
|
+
// is a project source, not a second user-settings file. Recent CLI versions
|
|
27
|
+
// also read local settings from the main checkout when running in a worktree.
|
|
28
|
+
// https://code.claude.com/docs/en/settings
|
|
29
|
+
const paths = new Set([
|
|
30
|
+
join(claudeConfigDir, 'settings.json'),
|
|
31
|
+
join(cwd, '.claude', 'settings.json'),
|
|
32
|
+
join(cwd, '.claude', 'settings.local.json'),
|
|
33
|
+
...(cwd !== brainPath && existsSync(join(brainPath, '.git'))
|
|
34
|
+
? [join(brainPath, '.claude', 'settings.local.json')] : []),
|
|
35
|
+
]);
|
|
36
|
+
const conflicts = [];
|
|
37
|
+
for (const path of paths) {
|
|
38
|
+
let settings;
|
|
39
|
+
try {
|
|
40
|
+
settings = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Missing/unreadable/malformed files are CLI concerns. In particular,
|
|
44
|
+
// never log parse errors: their messages can contain setting values.
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const env = settings && typeof settings === 'object' && 'env' in settings ? settings.env : undefined;
|
|
48
|
+
if (!env || typeof env !== 'object' || Array.isArray(env))
|
|
49
|
+
continue;
|
|
50
|
+
for (const key of ['TEAMVIBE_CLAUDE_OAUTH_TOKEN', 'TEAMVIBE_POOL_ACCOUNT_ID']) {
|
|
51
|
+
if (Object.hasOwn(env, key)) {
|
|
52
|
+
conflicts.push(`${path} defines env.${key}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (conflicts.length) {
|
|
57
|
+
sessionLog.warn(`[Claude settings] ${conflicts.join('; ')}; Claude can override the poller-injected environment, including when EXPOSE_CLAUDE_TOKEN_TO_AGENT is off`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
22
60
|
/**
|
|
23
61
|
* Maps tool names (or prefixes) to human-readable status messages.
|
|
24
62
|
* More specific patterns are checked first.
|
|
@@ -197,9 +235,10 @@ function readBrainSettings(cwd) {
|
|
|
197
235
|
const raw = readFileSync(settingsPath, 'utf-8');
|
|
198
236
|
return JSON.parse(raw);
|
|
199
237
|
}
|
|
200
|
-
catch
|
|
201
|
-
|
|
202
|
-
|
|
238
|
+
catch {
|
|
239
|
+
// JSON parser errors can quote env values from the source file.
|
|
240
|
+
logger.warn(`Failed to read brain settings at ${settingsPath}`);
|
|
241
|
+
emitError('brain', `Failed to read brain settings at ${settingsPath}`, { severity: 'warn' });
|
|
203
242
|
return {};
|
|
204
243
|
}
|
|
205
244
|
}
|
|
@@ -421,6 +460,9 @@ export function sweepProcessGroup(proc, graceMs, options = {}) {
|
|
|
421
460
|
* would look like if left uncapped.
|
|
422
461
|
*/
|
|
423
462
|
export function buildSpawnEnv(params) {
|
|
463
|
+
const exposeToken = /^(1|true|yes|on)$/i.test(params.processEnv['EXPOSE_CLAUDE_TOKEN_TO_AGENT'] ?? '');
|
|
464
|
+
const accountToken = params.subscriptionAccount?.credential.unwrap();
|
|
465
|
+
const sessionToken = accountToken ?? params.processEnv['CLAUDE_CODE_OAUTH_TOKEN'];
|
|
424
466
|
return {
|
|
425
467
|
// Secrets platform (#105) injection — SOLE call site of
|
|
426
468
|
// `SpawnSecretsEnvelope.unwrap()` in the entire poller codebase.
|
|
@@ -470,8 +512,13 @@ export function buildSpawnEnv(params) {
|
|
|
470
512
|
// drops against this var so they land somewhere still being
|
|
471
513
|
// watched after the session (and worktree) is gone. See tv#265.
|
|
472
514
|
POLLER_BRAIN_PATH: params.brainPath,
|
|
515
|
+
// Claude strips its own token name from Bash subprocesses. This alias
|
|
516
|
+
// deliberately opts out of that protection, only at the poller level.
|
|
517
|
+
// Explicit undefined values also clear inherited/overlay aliases.
|
|
518
|
+
TEAMVIBE_CLAUDE_OAUTH_TOKEN: exposeToken ? sessionToken || undefined : undefined,
|
|
519
|
+
TEAMVIBE_POOL_ACCOUNT_ID: exposeToken ? params.subscriptionAccount?.accountId : undefined,
|
|
473
520
|
...(params.subscriptionAccount ? {
|
|
474
|
-
CLAUDE_CODE_OAUTH_TOKEN:
|
|
521
|
+
CLAUDE_CODE_OAUTH_TOKEN: accountToken,
|
|
475
522
|
ANTHROPIC_API_KEY: undefined,
|
|
476
523
|
ANTHROPIC_AUTH_TOKEN: undefined,
|
|
477
524
|
ANTHROPIC_BASE_URL: undefined,
|
|
@@ -800,7 +847,7 @@ function resultMetrics(event, pooled, lastAssistant) {
|
|
|
800
847
|
metrics.context_window = window;
|
|
801
848
|
return metrics;
|
|
802
849
|
}
|
|
803
|
-
async function runClaudeCode(msg, sessionLog, cwd,
|
|
850
|
+
async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
|
|
804
851
|
const slackContext = msg.response_context.slack;
|
|
805
852
|
const isCronMessage = msg.source === 'cron';
|
|
806
853
|
const isMaintenance = msg.source === 'maintenance';
|
|
@@ -941,25 +988,44 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
941
988
|
: process.env['PATH'];
|
|
942
989
|
if (runtime?.account)
|
|
943
990
|
poolInfo(sessionLog, `token target account=${poolLabel(runtime.account)} session=${JSON.stringify(sessionId ?? 'new')} credential=selected account token`);
|
|
991
|
+
const spawnEnv = buildSpawnEnv({
|
|
992
|
+
spawnSecretsEnv: spawnSecrets.env.unwrap(),
|
|
993
|
+
processEnv: process.env,
|
|
994
|
+
envPath,
|
|
995
|
+
claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
|
|
996
|
+
persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
|
|
997
|
+
slackContext,
|
|
998
|
+
teamvibeApiUrl: config.TEAMVIBE_API_URL,
|
|
999
|
+
teamvibePollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
|
|
1000
|
+
teamvibeWorkspaceId: msg.teamvibe.workspaceId,
|
|
1001
|
+
teamvibeChannelId: msg.teamvibe.channelId,
|
|
1002
|
+
teamvibeBotId: msg.teamvibe.botId,
|
|
1003
|
+
teamvibePollerId: getPollerId() || '',
|
|
1004
|
+
inboxThreadId: msg.thread_id,
|
|
1005
|
+
brainPath: directory.path,
|
|
1006
|
+
subscriptionAccount: runtime?.account,
|
|
1007
|
+
});
|
|
1008
|
+
// Capture the actual credentials for this attempt, including pool selection
|
|
1009
|
+
// and secrets overlays, regardless of whether alias exposure is enabled.
|
|
1010
|
+
const outputTokens = [spawnEnv['CLAUDE_CODE_OAUTH_TOKEN'], spawnEnv['TEAMVIBE_CLAUDE_OAUTH_TOKEN']]
|
|
1011
|
+
.filter((token) => !!token);
|
|
1012
|
+
const redactOutput = (text) => redactStartupText(text, outputTokens);
|
|
1013
|
+
const safeEvent = (value) => {
|
|
1014
|
+
if (typeof value === 'string')
|
|
1015
|
+
return redactOutput(value);
|
|
1016
|
+
if (Array.isArray(value))
|
|
1017
|
+
return value.map(safeEvent);
|
|
1018
|
+
if (!value || typeof value !== 'object')
|
|
1019
|
+
return value;
|
|
1020
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
1021
|
+
redactOutput(key),
|
|
1022
|
+
/^(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)$/i.test(key) ? '[REDACTED]' : safeEvent(entry),
|
|
1023
|
+
]));
|
|
1024
|
+
};
|
|
1025
|
+
warnTokenAliasSettings(spawnEnv['CLAUDE_CONFIG_DIR'], cwd, directory.path, sessionLog);
|
|
944
1026
|
const proc = spawn(config.CLAUDE_CLI_PATH, args, {
|
|
945
1027
|
cwd,
|
|
946
|
-
env:
|
|
947
|
-
spawnSecretsEnv: spawnSecrets.env.unwrap(),
|
|
948
|
-
processEnv: process.env,
|
|
949
|
-
envPath,
|
|
950
|
-
claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
|
|
951
|
-
persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
|
|
952
|
-
slackContext,
|
|
953
|
-
teamvibeApiUrl: config.TEAMVIBE_API_URL,
|
|
954
|
-
teamvibePollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
|
|
955
|
-
teamvibeWorkspaceId: msg.teamvibe.workspaceId,
|
|
956
|
-
teamvibeChannelId: msg.teamvibe.channelId,
|
|
957
|
-
teamvibeBotId: msg.teamvibe.botId,
|
|
958
|
-
teamvibePollerId: getPollerId() || '',
|
|
959
|
-
inboxThreadId: msg.thread_id,
|
|
960
|
-
brainPath,
|
|
961
|
-
subscriptionAccount: runtime?.account,
|
|
962
|
-
}),
|
|
1028
|
+
env: spawnEnv,
|
|
963
1029
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
964
1030
|
// teamvibeai/teamvibe.ai#73: run `claude` (via setsid) as the leader of
|
|
965
1031
|
// its own new process group, separate from the poller's. This is what
|
|
@@ -980,6 +1046,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
980
1046
|
proc.stdin?.end();
|
|
981
1047
|
let stdout = '';
|
|
982
1048
|
let stderr = '';
|
|
1049
|
+
let stderrLogBuffer = '';
|
|
1050
|
+
const flushStderrLog = () => {
|
|
1051
|
+
if (stderrLogBuffer)
|
|
1052
|
+
sessionLog.claude('stderr', redactOutput(stderrLogBuffer).trimEnd());
|
|
1053
|
+
stderrLogBuffer = '';
|
|
1054
|
+
};
|
|
983
1055
|
let stdoutBuffer = '';
|
|
984
1056
|
// Retain CLI error/result evidence, not the full parsed assistant/tool stream.
|
|
985
1057
|
const failureEvents = [];
|
|
@@ -1039,12 +1111,15 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1039
1111
|
const lines = completeData.split('\n').filter((line) => line.trim());
|
|
1040
1112
|
for (const line of lines) {
|
|
1041
1113
|
try {
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1114
|
+
const rawEvent = JSON.parse(line);
|
|
1115
|
+
// Redact before formatting/truncation or forwarding any string fields
|
|
1116
|
+
// (including metadata keys) to logs, telemetry, typing, or Slack.
|
|
1117
|
+
const event = safeEvent(rawEvent);
|
|
1118
|
+
if (rawEvent?.type === 'rate_limit_event') {
|
|
1044
1119
|
rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
|
|
1045
1120
|
}
|
|
1046
|
-
if (
|
|
1047
|
-
failureEvents.push(
|
|
1121
|
+
if (rawEvent?.type === 'result' || rawEvent?.type === 'error' || (rawEvent?.type === 'assistant' && rawEvent.error)) {
|
|
1122
|
+
failureEvents.push(rawEvent);
|
|
1048
1123
|
}
|
|
1049
1124
|
// Capture the pre-event activity timestamp BEFORE the unconditional
|
|
1050
1125
|
// overwrite below — needed to compute the "original 10min deadline
|
|
@@ -1093,7 +1168,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1093
1168
|
}
|
|
1094
1169
|
const formatted = formatStreamEvent(event);
|
|
1095
1170
|
if (formatted) {
|
|
1096
|
-
sessionLog.claude('stdout', formatted);
|
|
1171
|
+
sessionLog.claude('stdout', redactOutput(formatted));
|
|
1097
1172
|
}
|
|
1098
1173
|
// teamvibe.ai#284: track the last non-empty plain-text assistant
|
|
1099
1174
|
// content seen, regardless of tool calls — candidate for the
|
|
@@ -1131,7 +1206,8 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1131
1206
|
}
|
|
1132
1207
|
}
|
|
1133
1208
|
catch {
|
|
1134
|
-
const
|
|
1209
|
+
const safeLine = redactOutput(line);
|
|
1210
|
+
const truncated = safeLine.length > 500 ? safeLine.slice(0, 500) + '...' : safeLine;
|
|
1135
1211
|
sessionLog.claude('stdout', truncated.trimEnd());
|
|
1136
1212
|
}
|
|
1137
1213
|
}
|
|
@@ -1140,7 +1216,14 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1140
1216
|
proc.stderr.on('data', (data) => {
|
|
1141
1217
|
const chunk = data.toString();
|
|
1142
1218
|
stderr += chunk;
|
|
1143
|
-
|
|
1219
|
+
// Wait for complete lines so a token split across data events cannot
|
|
1220
|
+
// escape redaction. Flush a final unterminated line on close/error.
|
|
1221
|
+
stderrLogBuffer += chunk;
|
|
1222
|
+
const lastNewline = stderrLogBuffer.lastIndexOf('\n');
|
|
1223
|
+
if (lastNewline !== -1) {
|
|
1224
|
+
sessionLog.claude('stderr', redactOutput(stderrLogBuffer.slice(0, lastNewline + 1)).trimEnd());
|
|
1225
|
+
stderrLogBuffer = stderrLogBuffer.slice(lastNewline + 1);
|
|
1226
|
+
}
|
|
1144
1227
|
// Track continuous network errors for early kill
|
|
1145
1228
|
if (NETWORK_ERROR_PATTERNS.test(chunk)) {
|
|
1146
1229
|
if (networkErrorStart === null) {
|
|
@@ -1249,16 +1332,19 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1249
1332
|
// not something shutdown() awaits.
|
|
1250
1333
|
void sweepProcessGroup(proc, CLOSE_SWEEP_GRACE_MS, { unref: true });
|
|
1251
1334
|
const killReason = killReasonByProc.get(proc);
|
|
1335
|
+
flushStderrLog();
|
|
1252
1336
|
// Preserve a final unterminated stream-json error/result and its metrics.
|
|
1253
1337
|
if (stdoutBuffer.trim()) {
|
|
1254
1338
|
try {
|
|
1255
|
-
const
|
|
1339
|
+
const rawEvent = JSON.parse(stdoutBuffer);
|
|
1340
|
+
const event = safeEvent(rawEvent);
|
|
1256
1341
|
if (event?.type === 'rate_limit_event') {
|
|
1257
1342
|
rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
|
|
1258
1343
|
}
|
|
1259
|
-
failureEvents.push(
|
|
1260
|
-
if (event?.type === 'assistant')
|
|
1344
|
+
failureEvents.push(rawEvent);
|
|
1345
|
+
if (event?.type === 'assistant') {
|
|
1261
1346
|
lastAssistant = { usage: event.message?.usage, model: event.message?.model };
|
|
1347
|
+
}
|
|
1262
1348
|
if (event?.type === 'result')
|
|
1263
1349
|
metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
|
|
1264
1350
|
}
|
|
@@ -1272,7 +1358,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1272
1358
|
attemptedSessionId: sessionId,
|
|
1273
1359
|
...(quota ? { quota } : {}),
|
|
1274
1360
|
...(rateLimitInfo ? { rateLimitInfo } : {}),
|
|
1275
|
-
output: stdout,
|
|
1361
|
+
output: redactOutput(stdout),
|
|
1276
1362
|
exitCode: code,
|
|
1277
1363
|
metrics,
|
|
1278
1364
|
modelSource,
|
|
@@ -1305,25 +1391,26 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1305
1391
|
// buildSpawnEnv above), a future OOM is more likely to surface this
|
|
1306
1392
|
// way — a cgroup-level SIGKILL — rather than as a V8-level FATAL
|
|
1307
1393
|
// ERROR visible in stderr, so this is the replacement visibility.
|
|
1308
|
-
result.error = stderr || (signal ? `Process was killed by signal ${signal}` : `Process exited with code ${code}`);
|
|
1394
|
+
result.error = redactOutput(stderr || (signal ? `Process was killed by signal ${signal}` : `Process exited with code ${code}`));
|
|
1309
1395
|
}
|
|
1310
1396
|
resolve(result);
|
|
1311
1397
|
});
|
|
1312
1398
|
proc.on('error', (error) => {
|
|
1399
|
+
flushStderrLog();
|
|
1313
1400
|
clearTimeout(timeout);
|
|
1314
1401
|
clearInterval(idleWatchdog);
|
|
1315
1402
|
activeProcesses.delete(msg.id);
|
|
1316
1403
|
activeProcessesByThread.delete(msg.thread_id);
|
|
1317
1404
|
lastSessionEndTs = Date.now();
|
|
1318
|
-
emitError('session', `Claude Code process spawn error: ${error.message}`, {
|
|
1405
|
+
emitError('session', `Claude Code process spawn error: ${redactOutput(error.message)}`, {
|
|
1319
1406
|
channelId: msg.teamvibe.channelId,
|
|
1320
1407
|
context: { threadId: msg.thread_id },
|
|
1321
1408
|
});
|
|
1322
1409
|
resolve({
|
|
1323
1410
|
success: false,
|
|
1324
|
-
output: stdout,
|
|
1411
|
+
output: redactOutput(stdout),
|
|
1325
1412
|
exitCode: null,
|
|
1326
|
-
error: error.message,
|
|
1413
|
+
error: redactOutput(error.message),
|
|
1327
1414
|
...(rateLimitInfo ? { rateLimitInfo } : {}),
|
|
1328
1415
|
attemptedSessionId: sessionId,
|
|
1329
1416
|
sawSendMessageCall,
|
|
@@ -1361,7 +1448,9 @@ export function finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessio
|
|
|
1361
1448
|
}
|
|
1362
1449
|
return retryResult;
|
|
1363
1450
|
}
|
|
1364
|
-
export async function spawnClaudeCode(msg, sessionLog,
|
|
1451
|
+
export async function spawnClaudeCode(msg, sessionLog, selectedDirectory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
|
|
1452
|
+
const directory = executionDirectory(selectedDirectory);
|
|
1453
|
+
const cwd = directory.path;
|
|
1365
1454
|
// Worktree isolation: spawned (non-foreground) sessions run in
|
|
1366
1455
|
// <brain>/.worktrees/<thread_id>/ so the sync-watchdog's reset --hard on
|
|
1367
1456
|
// the main working dir can't trample in-flight writes or pre-push commits.
|
|
@@ -1387,12 +1476,12 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
1387
1476
|
const { randomUUID } = await import('crypto');
|
|
1388
1477
|
effectiveSessionId = randomUUID();
|
|
1389
1478
|
}
|
|
1390
|
-
const result = await runClaudeCode(msg, sessionLog, effectiveCwd,
|
|
1479
|
+
const result = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
|
|
1391
1480
|
// If --resume failed, retry as a fresh session (session files may have been lost on container restart)
|
|
1392
1481
|
let retryResult = result;
|
|
1393
1482
|
if (!result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
|
|
1394
1483
|
sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
|
|
1395
|
-
retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd,
|
|
1484
|
+
retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
|
|
1396
1485
|
}
|
|
1397
1486
|
// If session ID is "already in use" (stale lock file), retry immediately with a
|
|
1398
1487
|
// fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
|
|
@@ -1401,7 +1490,7 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
1401
1490
|
sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
|
|
1402
1491
|
const { randomUUID } = await import('crypto');
|
|
1403
1492
|
const freshId = randomUUID();
|
|
1404
|
-
const freshResult = await runClaudeCode(msg, sessionLog, effectiveCwd,
|
|
1493
|
+
const freshResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, freshId, true, lastMessageTs, onMessageSent, typingIndicator);
|
|
1405
1494
|
return finalizeSpawnResult(freshResult, true, freshId);
|
|
1406
1495
|
}
|
|
1407
1496
|
return finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessionId);
|
|
@@ -1415,44 +1504,51 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
1415
1504
|
}
|
|
1416
1505
|
}
|
|
1417
1506
|
// Configured-pool orchestration is deliberately separate from legacy spawns.
|
|
1418
|
-
export async function spawnPoolClaudeCode(msg, sessionLog,
|
|
1507
|
+
export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, poolRuntime) {
|
|
1508
|
+
const directory = executionDirectory(selectedDirectory);
|
|
1509
|
+
const cwd = directory.path;
|
|
1419
1510
|
// Worktree isolation: spawned (non-foreground) sessions run in
|
|
1420
1511
|
// <brain>/.worktrees/<thread_id>/ so the sync-watchdog's reset --hard on
|
|
1421
1512
|
// the main working dir can't trample in-flight writes or pre-push commits.
|
|
1422
1513
|
// See teamvibe.ai#120. cwd remains the main brain path on fallback / failure.
|
|
1514
|
+
const claudeConfigDir = getBaseBrainPath();
|
|
1515
|
+
const isolate = shouldIsolateInWorktree(msg);
|
|
1516
|
+
const derivedCwd = isolate ? join(cwd, '.worktrees', msg.thread_id) : cwd;
|
|
1517
|
+
const pin = poolRuntime?.pin;
|
|
1518
|
+
// Persisted directories are agent-writable evidence, never execution inputs.
|
|
1519
|
+
// Compare against the same platform-selected paths used by a fresh spawn.
|
|
1520
|
+
if (pin?.resumePending && (pin.effectiveCwd !== undefined && pin.effectiveCwd !== derivedCwd ||
|
|
1521
|
+
pin.claudeConfigDir !== undefined && pin.claudeConfigDir !== claudeConfigDir)) {
|
|
1522
|
+
sessionLog.warn('[Pool] Pending session directory mismatch; starting a fresh session in the current channel brain');
|
|
1523
|
+
emitError('session', 'Pending session directory mismatch; starting a fresh session', {
|
|
1524
|
+
severity: 'warn', channelId: msg.teamvibe.channelId,
|
|
1525
|
+
context: { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, reason: 'directory_mismatch' },
|
|
1526
|
+
});
|
|
1527
|
+
poolRuntime = { ...poolRuntime, pin: { ...pin, resumePending: false, sawSendMessageCall: false } };
|
|
1528
|
+
sessionId = undefined;
|
|
1529
|
+
isFirstMessage = true;
|
|
1530
|
+
}
|
|
1531
|
+
const resumePending = poolRuntime?.pin?.resumePending === true;
|
|
1423
1532
|
let effectiveCwd = cwd;
|
|
1424
1533
|
let loggingPool = false;
|
|
1425
|
-
let retainWorktree =
|
|
1534
|
+
let retainWorktree = resumePending;
|
|
1426
1535
|
const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
|
|
1427
1536
|
let worktreeOwner;
|
|
1428
|
-
if (
|
|
1429
|
-
const pinnedCwd = poolRuntime.pin.effectiveCwd;
|
|
1430
|
-
if (pinnedCwd !== cwd && pinnedCwd !== join(cwd, '.worktrees', msg.thread_id))
|
|
1431
|
-
throw new PoolDeferredError();
|
|
1432
|
-
if (!existsSync(pinnedCwd)) {
|
|
1433
|
-
sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${pinnedCwd} for pending session ${sessionId ?? 'unknown'}`);
|
|
1434
|
-
try {
|
|
1435
|
-
if (pinnedCwd === cwd)
|
|
1436
|
-
await mkdir(cwd, { recursive: true });
|
|
1437
|
-
else
|
|
1438
|
-
await createWorktree(cwd, msg.thread_id);
|
|
1439
|
-
}
|
|
1440
|
-
catch {
|
|
1441
|
-
sessionLog.warn('[Worktree] Pinned worktree recreation failed; pending work retained for the next recheck');
|
|
1442
|
-
throw new PoolDeferredError();
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
effectiveCwd = pinnedCwd;
|
|
1446
|
-
if (effectiveCwd !== cwd)
|
|
1447
|
-
worktreeOwner = { brainPath: cwd, id: msg.thread_id };
|
|
1448
|
-
}
|
|
1449
|
-
else if (shouldIsolateInWorktree(msg)) {
|
|
1537
|
+
if (isolate) {
|
|
1450
1538
|
try {
|
|
1451
|
-
|
|
1539
|
+
// Retain pending work only at the independently derived worktree path.
|
|
1540
|
+
if (resumePending && !existsSync(derivedCwd)) {
|
|
1541
|
+
sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${derivedCwd} for pending session ${sessionId ?? 'unknown'}`);
|
|
1542
|
+
}
|
|
1543
|
+
effectiveCwd = resumePending && existsSync(derivedCwd) ? derivedCwd : await createWorktree(cwd, msg.thread_id);
|
|
1452
1544
|
worktreeOwner = { brainPath: cwd, id: msg.thread_id };
|
|
1453
1545
|
sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
|
|
1454
1546
|
}
|
|
1455
1547
|
catch (err) {
|
|
1548
|
+
if (resumePending) {
|
|
1549
|
+
sessionLog.warn('[Worktree] Pending worktree recreation failed; retaining work for the next recheck');
|
|
1550
|
+
throw new PoolDeferredError();
|
|
1551
|
+
}
|
|
1456
1552
|
sessionLog.warn(`[Worktree] Failed to create worktree, falling back to main brain dir: ${err instanceof Error ? err.message : err}`);
|
|
1457
1553
|
}
|
|
1458
1554
|
}
|
|
@@ -1475,7 +1571,6 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
|
|
|
1475
1571
|
? { model: poolRuntime.pin.model, modelSource: poolRuntime.pin.modelSource }
|
|
1476
1572
|
: resolveModel(typeof settings['model'] === 'string' ? settings['model'] : undefined, scoped ?? {}, config.CLAUDE_MODEL);
|
|
1477
1573
|
const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool, sessionLog) : undefined;
|
|
1478
|
-
const claudeConfigDir = poolRuntime?.pin?.resumePending ? poolRuntime.pin.claudeConfigDir ?? getBaseBrainPath() : getBaseBrainPath();
|
|
1479
1574
|
const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
|
|
1480
1575
|
? scoped?.modelNormalizedFrom : undefined;
|
|
1481
1576
|
const runtime = poolRuntime ? { resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, shouldStop: poolRuntime.shouldStop, resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true } : undefined;
|
|
@@ -1493,12 +1588,30 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
|
|
|
1493
1588
|
// fallback must report the fresh attempt's delivery flags independently.
|
|
1494
1589
|
if (!runtime?.resumePending)
|
|
1495
1590
|
sawSendMessageCall = false;
|
|
1496
|
-
const result = await runClaudeCode(msg, sessionLog, effectiveCwd,
|
|
1591
|
+
const result = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
|
|
1497
1592
|
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
1498
1593
|
if (prepared?.account) {
|
|
1499
1594
|
if (poolAttempts.length >= 1000)
|
|
1500
1595
|
poolAttempts.shift();
|
|
1501
|
-
|
|
1596
|
+
const selection = prepared.selection;
|
|
1597
|
+
poolAttempts.push({
|
|
1598
|
+
...(selection?.kind === 'selected' && selection.provenance === 'known'
|
|
1599
|
+
? { score: selection.score, rateWindow: selection.rateWindow, reason: selection.reason }
|
|
1600
|
+
: {}),
|
|
1601
|
+
workspaceId: prepared.account.workspaceId,
|
|
1602
|
+
accountId: prepared.account.accountId,
|
|
1603
|
+
credentialVersion: prepared.account.credentialVersion,
|
|
1604
|
+
accountLabel: prepared.account.label ?? prepared.account.accountId,
|
|
1605
|
+
observedAt: Date.now(),
|
|
1606
|
+
rateLimitInfo: result.rateLimitInfo,
|
|
1607
|
+
sessionId: id,
|
|
1608
|
+
model: resolved.model ?? result.metrics?.model,
|
|
1609
|
+
success: result.success,
|
|
1610
|
+
exitCode: result.exitCode,
|
|
1611
|
+
killedByPoller: result.killedByPoller,
|
|
1612
|
+
quota: result.quota,
|
|
1613
|
+
metrics: result.metrics,
|
|
1614
|
+
});
|
|
1502
1615
|
emitEvent('pool.account_attempted', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime?.quotaDeferEpisodes) });
|
|
1503
1616
|
if (result.success && id && poolRuntime) {
|
|
1504
1617
|
// Complete the inbox before any fallible post-inference store writes.
|
|
@@ -1556,11 +1669,16 @@ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFir
|
|
|
1556
1669
|
const fromAccount = prepared.account;
|
|
1557
1670
|
poolInfo(sessionLog, `switch from=${poolLabel(fromAccount)} to=${poolLabel(selected.account)} session=${JSON.stringify(effectiveSessionId)}`);
|
|
1558
1671
|
prepared.account = selected.account;
|
|
1672
|
+
prepared.selection = selected;
|
|
1559
1673
|
runtime.account = selected.account;
|
|
1560
1674
|
runtime.resumePending = true;
|
|
1561
1675
|
switched = true;
|
|
1562
1676
|
const selectedUsage = await prepared.store.read(selected.account).catch(() => ({ reactive: [], passive: undefined }));
|
|
1563
|
-
|
|
1677
|
+
// A switch reports the newly selected account's decision.
|
|
1678
|
+
const selectionDetails = selected.provenance === 'known'
|
|
1679
|
+
? { score: selected.score, reason: selected.reason, rateWindow: { window: selected.rateWindow.window, scope: selected.rateWindow.scope, percent: selected.rateWindow.percent, resetAt: selected.rateWindow.resetAt } }
|
|
1680
|
+
: { score: undefined, reason: undefined, rateWindow: undefined };
|
|
1681
|
+
emitEvent('pool.account_switched', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime.quotaDeferEpisodes), ...selectionDetails, ...accountUsageTelemetry(selected.account, selectedUsage), lastRateLimitInfo: selectedUsage.passive?.rateLimitInfo, observedAt: selectedUsage.passive?.observedAt, fromAccountId: fromAccount.accountId, fromAccountLabel: fromAccount.label ?? fromAccount.accountId, model: resolved.model, chainLength: poolAttempts.length + 1, switchedAccounts: true });
|
|
1564
1682
|
result = await attempt(effectiveSessionId, false);
|
|
1565
1683
|
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
1566
1684
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
41
41
|
AUTO_UPDATE_POLICY: z.ZodDefault<z.ZodEnum<["patch", "minor", "off"]>>;
|
|
42
42
|
AUTO_UPDATE_SCHEDULE: z.ZodDefault<z.ZodString>;
|
|
43
43
|
INBOX_WATCH_FALLBACK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
|
|
44
|
+
INBOX_ROUTING_TTL_DAYS: z.ZodDefault<z.ZodNumber>;
|
|
44
45
|
INBOX_WATCH_DEBOUNCE_MS: z.ZodDefault<z.ZodNumber>;
|
|
45
46
|
IDLE_RESTART_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
|
|
46
47
|
IDLE_RESTART_IDLE_MS: z.ZodDefault<z.ZodNumber>;
|
|
@@ -83,6 +84,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
83
84
|
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
84
85
|
AUTO_UPDATE_SCHEDULE: string;
|
|
85
86
|
INBOX_WATCH_FALLBACK_INTERVAL_MS: number;
|
|
87
|
+
INBOX_ROUTING_TTL_DAYS: number;
|
|
86
88
|
INBOX_WATCH_DEBOUNCE_MS: number;
|
|
87
89
|
IDLE_RESTART_ENABLED: boolean;
|
|
88
90
|
IDLE_RESTART_IDLE_MS: number;
|
|
@@ -133,6 +135,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
133
135
|
AUTO_UPDATE_POLICY?: "patch" | "minor" | "off" | undefined;
|
|
134
136
|
AUTO_UPDATE_SCHEDULE?: string | undefined;
|
|
135
137
|
INBOX_WATCH_FALLBACK_INTERVAL_MS?: number | undefined;
|
|
138
|
+
INBOX_ROUTING_TTL_DAYS?: number | undefined;
|
|
136
139
|
INBOX_WATCH_DEBOUNCE_MS?: number | undefined;
|
|
137
140
|
IDLE_RESTART_ENABLED?: string | undefined;
|
|
138
141
|
IDLE_RESTART_IDLE_MS?: number | undefined;
|
|
@@ -178,6 +181,7 @@ export declare const config: {
|
|
|
178
181
|
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
179
182
|
AUTO_UPDATE_SCHEDULE: string;
|
|
180
183
|
INBOX_WATCH_FALLBACK_INTERVAL_MS: number;
|
|
184
|
+
INBOX_ROUTING_TTL_DAYS: number;
|
|
181
185
|
INBOX_WATCH_DEBOUNCE_MS: number;
|
|
182
186
|
IDLE_RESTART_ENABLED: boolean;
|
|
183
187
|
IDLE_RESTART_IDLE_MS: number;
|
package/dist/config.js
CHANGED
|
@@ -80,6 +80,7 @@ const configSchema = z.object({
|
|
|
80
80
|
// catches missed/coalesced events, picks up brains whose .inbox/ didn't
|
|
81
81
|
// exist at watcher startup, and prunes empty thread dirs.
|
|
82
82
|
INBOX_WATCH_FALLBACK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 minutes
|
|
83
|
+
INBOX_ROUTING_TTL_DAYS: z.coerce.number().positive().default(14),
|
|
83
84
|
INBOX_WATCH_DEBOUNCE_MS: z.coerce.number().default(300), // coalesce bursty fs events into one scan
|
|
84
85
|
// Idle restart (tv#317): Railway bills on cgroup memory usage, which includes page
|
|
85
86
|
// cache that Linux only reclaims under real memory pressure — a large Railway memory
|
package/dist/event-queue.js
CHANGED
|
@@ -34,7 +34,7 @@ function enqueue(event) {
|
|
|
34
34
|
}
|
|
35
35
|
async function flushOne(event) {
|
|
36
36
|
try {
|
|
37
|
-
const
|
|
37
|
+
const post = () => fetch(`${config.TEAMVIBE_API_URL}/events`, {
|
|
38
38
|
method: 'POST',
|
|
39
39
|
headers: {
|
|
40
40
|
'Content-Type': 'application/json',
|
|
@@ -47,6 +47,19 @@ async function flushOne(event) {
|
|
|
47
47
|
error: event.error,
|
|
48
48
|
}),
|
|
49
49
|
});
|
|
50
|
+
let response = await post();
|
|
51
|
+
if (response.status === 400 &&
|
|
52
|
+
event.eventType === 'pool.probe_failed' &&
|
|
53
|
+
event.channelId === 'usage_probe' &&
|
|
54
|
+
event.metadata?.['source'] === 'probe' &&
|
|
55
|
+
event.metadata['requestId'] &&
|
|
56
|
+
event.metadata['reason'] !== 'unknown') {
|
|
57
|
+
// Pre-#480 platforms accept unknown but reject the new account reasons.
|
|
58
|
+
// Keep the downgrade on queued retries if the second post is transiently unavailable.
|
|
59
|
+
event.metadata = { ...event.metadata, reason: 'unknown' };
|
|
60
|
+
logger.info(`[Pool] usage probe request=${event.metadata['requestId']} completion downgraded for legacy platform`);
|
|
61
|
+
response = await post();
|
|
62
|
+
}
|
|
50
63
|
return response.ok;
|
|
51
64
|
}
|
|
52
65
|
catch {
|
package/dist/inbox-manager.d.ts
CHANGED
|
@@ -53,6 +53,11 @@ export declare function requeueMessage(brainPath: string, threadId: string, file
|
|
|
53
53
|
*/
|
|
54
54
|
export declare function hasUnprocessed(brainPath: string, threadId: string): boolean;
|
|
55
55
|
export declare function saveInboxContext(brainPath: string, msg: TeamVibeQueueMessage): void;
|
|
56
|
+
/** Even partial envelopes carrying channel context must never use a singleton guess. */
|
|
57
|
+
export declare function readInboxChannelContext(brainPath: string, threadId: string): {
|
|
58
|
+
workspaceId: unknown;
|
|
59
|
+
channelId: unknown;
|
|
60
|
+
} | undefined;
|
|
56
61
|
export declare function readInboxContext(brainPath: string, threadId: string): TeamVibeQueueMessage | undefined;
|
|
57
62
|
export interface InboxRuntime {
|
|
58
63
|
sessionId: string;
|
|
@@ -70,3 +75,9 @@ export interface InboxRuntime {
|
|
|
70
75
|
export declare function saveInboxRuntime(brainPath: string, threadId: string, state: InboxRuntime): void;
|
|
71
76
|
export declare function readInboxRuntime(brainPath: string, threadId: string): InboxRuntime | undefined;
|
|
72
77
|
export declare function clearInboxRuntime(brainPath: string, threadId: string): void;
|
|
78
|
+
/** Remove transfer remnants and completed copies before discovery or replay. */
|
|
79
|
+
export declare function discardHandledInboxDrops(brainPath: string, threadId: string): void;
|
|
80
|
+
/** Move pending content only; a different brain must never inherit continuation state. */
|
|
81
|
+
export declare function restartInboxInDirectory(source: string, target: string, threadId: string): void;
|
|
82
|
+
/** Preserve unroutable work for operator review and remove it from replay queues. */
|
|
83
|
+
export declare function quarantineInbox(brainPath: string, threadId: string, includeFailed?: boolean): void;
|