@teamvibe/poller 0.8.2-rc.2 → 0.8.3-rc.2

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.
Files changed (41) hide show
  1. package/dist/classify-failed-iteration.d.ts +7 -0
  2. package/dist/classify-failed-iteration.js +8 -0
  3. package/dist/claude-quota.d.ts +15 -0
  4. package/dist/claude-quota.js +188 -0
  5. package/dist/claude-rate-limit.d.ts +19 -0
  6. package/dist/claude-rate-limit.js +44 -0
  7. package/dist/claude-spawner.d.ts +15 -0
  8. package/dist/claude-spawner.js +306 -25
  9. package/dist/config-client.d.ts +2 -0
  10. package/dist/config-client.js +3 -0
  11. package/dist/inbox-manager.d.ts +22 -1
  12. package/dist/inbox-manager.js +57 -3
  13. package/dist/inbox-watcher.d.ts +1 -1
  14. package/dist/inbox-watcher.js +42 -20
  15. package/dist/poller.js +262 -51
  16. package/dist/pool-defer.d.ts +13 -0
  17. package/dist/pool-defer.js +81 -0
  18. package/dist/pool-telemetry.d.ts +44 -0
  19. package/dist/pool-telemetry.js +107 -0
  20. package/dist/quota-pause.d.ts +16 -0
  21. package/dist/quota-pause.js +96 -0
  22. package/dist/session-store.d.ts +2 -2
  23. package/dist/session-store.js +6 -3
  24. package/dist/slack-client.d.ts +4 -1
  25. package/dist/slack-client.js +19 -1
  26. package/dist/subscription-pool-client.d.ts +32 -0
  27. package/dist/subscription-pool-client.js +139 -0
  28. package/dist/subscription-pool.d.ts +18 -0
  29. package/dist/subscription-pool.js +24 -0
  30. package/dist/subscription-runtime.d.ts +53 -0
  31. package/dist/subscription-runtime.js +74 -0
  32. package/dist/subscription-selection.d.ts +75 -0
  33. package/dist/subscription-selection.js +142 -0
  34. package/dist/subscription-usage-probe.d.ts +16 -0
  35. package/dist/subscription-usage-probe.js +106 -0
  36. package/dist/subscription-usage-store.d.ts +47 -0
  37. package/dist/subscription-usage-store.js +206 -0
  38. package/dist/types.d.ts +25 -9
  39. package/dist/worktree-manager.js +4 -0
  40. package/npm-shrinkwrap.json +78 -77
  41. package/package.json +1 -1
@@ -1,16 +1,23 @@
1
+ import { accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
1
2
  import { spawn } from 'child_process';
2
3
  import { join } from 'path';
3
4
  import { readFileSync, existsSync } from 'fs';
5
+ import { mkdir } from 'fs/promises';
4
6
  import { WebClient } from '@slack/web-api';
5
7
  import { config } from './config.js';
6
8
  import { logger } from './logger.js';
7
9
  import { getBaseBrainPath } from './brain-manager.js';
8
10
  import { getPollerId } from './auth-provider.js';
9
11
  import { sendSlackMessage } from './slack-client.js';
10
- import { emitError } from './event-queue.js';
12
+ import { emitError, emitEvent } from './event-queue.js';
11
13
  import { createWorktree, removeWorktree, isWorktreeIsolationEnabled } from './worktree-manager.js';
12
14
  import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js';
13
15
  import { fetchConfigForSpawn } from './config-client.js';
16
+ import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
17
+ import { parseClaudeQuotaFailure } from './claude-quota.js';
18
+ import { accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
19
+ import { selectPoolForResolvedModel } from './subscription-pool.js';
20
+ import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
14
21
  /**
15
22
  * Maps tool names (or prefixes) to human-readable status messages.
16
23
  * More specific patterns are checked first.
@@ -462,6 +469,17 @@ export function buildSpawnEnv(params) {
462
469
  // drops against this var so they land somewhere still being
463
470
  // watched after the session (and worktree) is gone. See tv#265.
464
471
  POLLER_BRAIN_PATH: params.brainPath,
472
+ ...(params.subscriptionAccount ? {
473
+ CLAUDE_CODE_OAUTH_TOKEN: params.subscriptionAccount.credential.unwrap(),
474
+ ANTHROPIC_API_KEY: undefined,
475
+ ANTHROPIC_AUTH_TOKEN: undefined,
476
+ ANTHROPIC_BASE_URL: undefined,
477
+ ANTHROPIC_CUSTOM_HEADERS: undefined,
478
+ CLAUDE_CODE_OAUTH_TOKEN_FILE: undefined,
479
+ CLAUDE_CODE_USE_BEDROCK: undefined,
480
+ CLAUDE_CODE_USE_VERTEX: undefined,
481
+ CLAUDE_CODE_USE_FOUNDRY: undefined,
482
+ } : {}),
465
483
  };
466
484
  }
467
485
  export function buildPrompt(msg, contextHint) {
@@ -737,7 +755,51 @@ function resolveTypingStatusDetail(event, assistantText) {
737
755
  export function resolveTypingStatus(event, assistantText) {
738
756
  return resolveTypingStatusDetail(event, assistantText)?.text ?? null;
739
757
  }
740
- async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
758
+ function existingResultMetrics(event, pooled) {
759
+ // Preserve the legacy extractor exactly for non-pool spawns.
760
+ if (!pooled)
761
+ return {
762
+ duration_ms: event.duration_ms || 0,
763
+ duration_api_ms: event.duration_api_ms || 0,
764
+ num_turns: event.num_turns || 0,
765
+ total_cost_usd: event.total_cost_usd || 0,
766
+ input_tokens: event.usage?.input_tokens || 0,
767
+ output_tokens: event.usage?.output_tokens || 0,
768
+ cache_read_input_tokens: event.usage?.cache_read_input_tokens || 0,
769
+ cache_creation_input_tokens: event.usage?.cache_creation_input_tokens || 0,
770
+ model: Object.keys(event.modelUsage || {})[0] || 'unknown',
771
+ };
772
+ const result = {};
773
+ for (const key of ['duration_ms', 'duration_api_ms', 'num_turns', 'total_cost_usd']) {
774
+ if (typeof event[key] === 'number' && Number.isFinite(event[key]) && event[key] >= 0)
775
+ result[key] = event[key];
776
+ }
777
+ for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
778
+ if (typeof event.usage?.[key] === 'number' && Number.isFinite(event.usage[key]) && event.usage[key] >= 0)
779
+ result[key] = event.usage[key];
780
+ }
781
+ const model = Object.keys(event.modelUsage || {})[0];
782
+ if (model)
783
+ result.model = model;
784
+ return result;
785
+ }
786
+ // Context occupancy is one model call's input, never the accumulated result usage.
787
+ function resultMetrics(event, pooled, lastAssistant) {
788
+ const metrics = existingResultMetrics(event, pooled);
789
+ const usage = lastAssistant?.usage;
790
+ const inputs = [usage?.input_tokens, usage?.cache_read_input_tokens, usage?.cache_creation_input_tokens];
791
+ if (inputs.every(value => typeof value === 'number' && Number.isFinite(value) && value >= 0)) {
792
+ const tokens = inputs.reduce((sum, value) => sum + value, 0);
793
+ if (Number.isFinite(tokens))
794
+ metrics.context_tokens = tokens;
795
+ }
796
+ const model = lastAssistant?.model ?? Object.keys(event.modelUsage || {})[0];
797
+ const window = event.modelUsage?.[model]?.contextWindow;
798
+ if (typeof window === 'number' && Number.isFinite(window) && window > 0)
799
+ metrics.context_window = window;
800
+ return metrics;
801
+ }
802
+ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
741
803
  const slackContext = msg.response_context.slack;
742
804
  const isCronMessage = msg.source === 'cron';
743
805
  const isMaintenance = msg.source === 'maintenance';
@@ -757,7 +819,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
757
819
  // the more informative signal. Logic extracted into buildContextHint for
758
820
  // unit testability — the counters are injected so tests don't need to mock
759
821
  // the Slack WebClient module.
760
- const contextHint = await buildContextHint({
822
+ const contextHint = runtime?.resumePending ? undefined : await buildContextHint({
761
823
  isFirstMessage,
762
824
  lastMessageTs,
763
825
  slack: slackContext,
@@ -779,17 +841,25 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
779
841
  // Fetch scoped model config for this spawn — same hoisting rationale as
780
842
  // secrets above. Fail-soft: returns {} on any error, which resolveModel
781
843
  // treats as "nothing at this tier" and falls through to the env default.
782
- const ddbModelConfig = await fetchConfigForSpawn({
844
+ const ddbModelConfig = runtime ? {} : await fetchConfigForSpawn({
783
845
  pollerId: getPollerId() || '',
784
846
  workspaceId: msg.teamvibe.workspaceId,
785
847
  channelId: msg.teamvibe.channelId,
786
848
  });
849
+ if (runtime?.account)
850
+ await validateAccount(runtime.account, runtime.resolved);
787
851
  return new Promise((resolve) => {
788
- const prompt = buildPrompt(msg, contextHint);
852
+ // Preparation above awaits remote config/context. Another thread may
853
+ // have exhausted the shared account meanwhile. No awaits before spawn.
854
+ if (!runtime?.account && isQuotaPaused())
855
+ throw new QuotaPausedError();
856
+ if (runtime?.shouldStop())
857
+ throw new PoolDeferredError();
858
+ const prompt = runtime?.resumePending ? 'Continue the pending work in this session from the last completed action. Do not repeat completed actions or messages.' : buildPrompt(msg, contextHint);
789
859
  // teamvibe.ai#293 bug 2: decides plain-text vs. stream-json stdin. Empty
790
860
  // `extraArgs`/single-chunk `stdinChunks` (the overwhelming majority of
791
861
  // sessions — no inline images) reproduces the exact prior behavior.
792
- const spawnStdinPlan = buildSpawnStdinPlan(prompt, msg.inlineImages);
862
+ const spawnStdinPlan = buildSpawnStdinPlan(prompt, runtime?.resumePending ? undefined : msg.inlineImages);
793
863
  // MCP server config passed via --mcp-config flag (no temp files needed)
794
864
  const mcpServerPath = join(getBaseBrainPath(), 'mcp', 'slack.mjs');
795
865
  const teamvibeApiServerPath = join(getBaseBrainPath(), 'mcp', 'teamvibe-api.mjs');
@@ -819,10 +889,14 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
819
889
  // > CLAUDE_MODEL env default. See resolveModel's docstring for why
820
890
  // settings.json stays on top (known, accepted blind spot).
821
891
  const brainSettingsModel = typeof brainSettings['model'] === 'string' ? brainSettings['model'] : undefined;
822
- const { model, modelSource } = resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
892
+ const { model, modelSource } = runtime?.resolved ?? resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
823
893
  if (model) {
824
894
  args.push('--model', model);
825
- sessionLog.info(`Using model: ${model} (source: ${modelSource ?? 'unknown'})`);
895
+ // The service normalized the scoped config; only describe that mapping
896
+ // when this tier actually supplied the flag (brain settings stay verbatim).
897
+ const normalizedFrom = runtime?.modelNormalizedFrom ?? (model === ddbModelConfig.model && modelSource === ddbModelConfig.modelSource
898
+ ? ddbModelConfig.modelNormalizedFrom : undefined);
899
+ sessionLog.info(`Using model: ${model} (source: ${modelSource ?? 'unknown'})${normalizedFrom ? ` (normalized from ${normalizedFrom})` : ''}`);
826
900
  }
827
901
  // Inbox protocol: instruct Claude to check for mid-task messages (thread-scoped)
828
902
  const inboxNewDir = `.inbox/${msg.thread_id}/new`;
@@ -870,7 +944,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
870
944
  spawnSecretsEnv: spawnSecrets.env.unwrap(),
871
945
  processEnv: process.env,
872
946
  envPath,
873
- claudeConfigDir: getBaseBrainPath(),
947
+ claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
874
948
  persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
875
949
  slackContext,
876
950
  teamvibeApiUrl: config.TEAMVIBE_API_URL,
@@ -881,6 +955,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
881
955
  teamvibePollerId: getPollerId() || '',
882
956
  inboxThreadId: msg.thread_id,
883
957
  brainPath,
958
+ subscriptionAccount: runtime?.account,
884
959
  }),
885
960
  stdio: ['pipe', 'pipe', 'pipe'],
886
961
  // teamvibeai/teamvibe.ai#73: run `claude` (via setsid) as the leader of
@@ -903,7 +978,11 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
903
978
  let stdout = '';
904
979
  let stderr = '';
905
980
  let stdoutBuffer = '';
981
+ // Retain CLI error/result evidence, not the full parsed assistant/tool stream.
982
+ const failureEvents = [];
983
+ let rateLimitInfo;
906
984
  let metrics;
985
+ let lastAssistant;
907
986
  // teamvibe.ai#284 (poller-brain#235): end-of-turn guard state — tracked
908
987
  // across the whole run so the `close` handler can tell poller.ts whether
909
988
  // a successful turn actually delivered anything to Slack.
@@ -958,6 +1037,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
958
1037
  for (const line of lines) {
959
1038
  try {
960
1039
  const event = JSON.parse(line);
1040
+ if (event?.type === 'rate_limit_event') {
1041
+ rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
1042
+ }
1043
+ if (event?.type === 'result' || event?.type === 'error' || (event?.type === 'assistant' && event.error)) {
1044
+ failureEvents.push(event);
1045
+ }
961
1046
  // Capture the pre-event activity timestamp BEFORE the unconditional
962
1047
  // overwrite below — needed to compute the "original 10min deadline
963
1048
  // from silence start" for a compact_boundary line, since that line
@@ -993,20 +1078,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
993
1078
  sessionLog.info('[Watchdog] Activity resumed after compaction, reverting to normal idle watchdog');
994
1079
  compactionKillDeadline = null;
995
1080
  }
1081
+ if (event.type === 'assistant')
1082
+ lastAssistant = { usage: event.message?.usage, model: event.message?.model };
996
1083
  // Extract metrics from result event
997
1084
  if (event.type === 'result') {
998
- metrics = {
999
- duration_ms: event.duration_ms || 0,
1000
- duration_api_ms: event.duration_api_ms || 0,
1001
- num_turns: event.num_turns || 0,
1002
- total_cost_usd: event.total_cost_usd || 0,
1003
- input_tokens: event.usage?.input_tokens || 0,
1004
- output_tokens: event.usage?.output_tokens || 0,
1005
- cache_read_input_tokens: event.usage?.cache_read_input_tokens || 0,
1006
- cache_creation_input_tokens: event.usage?.cache_creation_input_tokens || 0,
1007
- model: Object.keys(event.modelUsage || {})[0] || 'unknown',
1008
- };
1009
- sessionLog.info(`[Metrics] Cost: $${metrics.total_cost_usd.toFixed(4)}, ` +
1085
+ metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
1086
+ sessionLog.info(`[Metrics] Cost: $${metrics.total_cost_usd?.toFixed(4) ?? 'unknown'}, ` +
1010
1087
  `Duration: ${metrics.duration_ms}ms, ` +
1011
1088
  `Turns: ${metrics.num_turns}, ` +
1012
1089
  `Tokens: ${metrics.input_tokens}in/${metrics.output_tokens}out`);
@@ -1169,8 +1246,29 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1169
1246
  // not something shutdown() awaits.
1170
1247
  void sweepProcessGroup(proc, CLOSE_SWEEP_GRACE_MS, { unref: true });
1171
1248
  const killReason = killReasonByProc.get(proc);
1249
+ // Preserve a final unterminated stream-json error/result and its metrics.
1250
+ if (stdoutBuffer.trim()) {
1251
+ try {
1252
+ const event = JSON.parse(stdoutBuffer);
1253
+ if (event?.type === 'rate_limit_event') {
1254
+ rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
1255
+ }
1256
+ failureEvents.push(event);
1257
+ if (event?.type === 'assistant')
1258
+ lastAssistant = { usage: event.message?.usage, model: event.message?.model };
1259
+ if (event?.type === 'result')
1260
+ metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
1261
+ }
1262
+ catch {
1263
+ // Non-JSON stdout is not trusted CLI error evidence.
1264
+ }
1265
+ }
1266
+ const quota = parseClaudeQuotaFailure({ exitCode: code, stderr, events: failureEvents, rateLimitInfo }, new Date());
1172
1267
  const result = {
1173
- success: code === 0,
1268
+ success: code === 0 && !quota,
1269
+ attemptedSessionId: sessionId,
1270
+ ...(quota ? { quota } : {}),
1271
+ ...(rateLimitInfo ? { rateLimitInfo } : {}),
1174
1272
  output: stdout,
1175
1273
  exitCode: code,
1176
1274
  metrics,
@@ -1191,6 +1289,9 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1191
1289
  else if (killReason === 'zombie') {
1192
1290
  result.error = 'Killed as zombie process (stale session lock)';
1193
1291
  }
1292
+ else if (quota) {
1293
+ result.error = 'Claude subscription usage limit reached.';
1294
+ }
1194
1295
  else if (code !== 0) {
1195
1296
  // `signal` matters here specifically: a process terminated by a
1196
1297
  // signal reports `code: null`, not a numeric exit code — e.g. a
@@ -1220,6 +1321,8 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1220
1321
  output: stdout,
1221
1322
  exitCode: null,
1222
1323
  error: error.message,
1324
+ ...(rateLimitInfo ? { rateLimitInfo } : {}),
1325
+ attemptedSessionId: sessionId,
1223
1326
  sawSendMessageCall,
1224
1327
  lastPlainTextContent,
1225
1328
  });
@@ -1246,9 +1349,11 @@ function shouldIsolateInWorktree(msg) {
1246
1349
  * createSession() placeholder forever and the thread's next message does
1247
1350
  * `--resume <id-claude-code-never-used>`, which fails 100% of the time.
1248
1351
  * (teamvibe.ai#77, DevGuru review round)
1352
+ * Quota failures also report the attempted ID, even on the first turn, so
1353
+ * later pool orchestration can continue that same session (#401).
1249
1354
  */
1250
1355
  export function finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessionId) {
1251
- if (isFirstMessage && retryResult.success && effectiveSessionId) {
1356
+ if (isFirstMessage && (retryResult.success || retryResult.quota) && effectiveSessionId) {
1252
1357
  return { ...retryResult, newSessionId: effectiveSessionId };
1253
1358
  }
1254
1359
  return retryResult;
@@ -1282,14 +1387,14 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
1282
1387
  const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
1283
1388
  // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
1284
1389
  let retryResult = result;
1285
- if (!result.success && effectiveSessionId && !isFirstMessage) {
1390
+ if (!result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
1286
1391
  sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
1287
1392
  retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
1288
1393
  }
1289
1394
  // If session ID is "already in use" (stale lock file), retry immediately with a
1290
1395
  // fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
1291
1396
  // retry's (teamvibe.ai#77 acceptance criteria: "no zombie kill needed first").
1292
- if (!retryResult.success && effectiveSessionId && retryResult.error?.includes('already in use')) {
1397
+ if (!retryResult.success && !retryResult.quota && effectiveSessionId && retryResult.error?.includes('already in use')) {
1293
1398
  sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
1294
1399
  const { randomUUID } = await import('crypto');
1295
1400
  const freshId = randomUUID();
@@ -1306,6 +1411,182 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
1306
1411
  }
1307
1412
  }
1308
1413
  }
1414
+ // Configured-pool orchestration is deliberately separate from legacy spawns.
1415
+ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, poolRuntime) {
1416
+ // Worktree isolation: spawned (non-foreground) sessions run in
1417
+ // <brain>/.worktrees/<thread_id>/ so the sync-watchdog's reset --hard on
1418
+ // the main working dir can't trample in-flight writes or pre-push commits.
1419
+ // See teamvibe.ai#120. cwd remains the main brain path on fallback / failure.
1420
+ let effectiveCwd = cwd;
1421
+ let retainWorktree = poolRuntime?.pin?.resumePending === true;
1422
+ const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
1423
+ let worktreeOwner;
1424
+ if (poolRuntime?.pin?.resumePending && poolRuntime.pin.effectiveCwd) {
1425
+ const pinnedCwd = poolRuntime.pin.effectiveCwd;
1426
+ if (pinnedCwd !== cwd && pinnedCwd !== join(cwd, '.worktrees', msg.thread_id))
1427
+ throw new PoolDeferredError();
1428
+ if (!existsSync(pinnedCwd)) {
1429
+ sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${pinnedCwd} for pending session ${sessionId ?? 'unknown'}`);
1430
+ try {
1431
+ if (pinnedCwd === cwd)
1432
+ await mkdir(cwd, { recursive: true });
1433
+ else
1434
+ await createWorktree(cwd, msg.thread_id);
1435
+ }
1436
+ catch {
1437
+ sessionLog.warn('[Worktree] Pinned worktree recreation failed; pending work retained for the next recheck');
1438
+ throw new PoolDeferredError();
1439
+ }
1440
+ }
1441
+ effectiveCwd = pinnedCwd;
1442
+ if (effectiveCwd !== cwd)
1443
+ worktreeOwner = { brainPath: cwd, id: msg.thread_id };
1444
+ }
1445
+ else if (shouldIsolateInWorktree(msg)) {
1446
+ try {
1447
+ effectiveCwd = await createWorktree(cwd, msg.thread_id);
1448
+ worktreeOwner = { brainPath: cwd, id: msg.thread_id };
1449
+ sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
1450
+ }
1451
+ catch (err) {
1452
+ sessionLog.warn(`[Worktree] Failed to create worktree, falling back to main brain dir: ${err instanceof Error ? err.message : err}`);
1453
+ }
1454
+ }
1455
+ try {
1456
+ // First-message spawns always mint a fresh session ID rather than
1457
+ // recycling whatever placeholder DynamoDB currently holds for this
1458
+ // thread (teamvibe.ai#77) — --resume on non-first messages is unchanged.
1459
+ let effectiveSessionId = sessionId;
1460
+ if (isFirstMessage && !poolRuntime?.pin?.resumePending) {
1461
+ const { randomUUID } = await import('crypto');
1462
+ effectiveSessionId = randomUUID();
1463
+ }
1464
+ const fetchedPool = poolRuntime ? poolRuntime.pool ?? await fetchRuntimePool(msg) : undefined;
1465
+ const scoped = poolRuntime ? await fetchConfigForSpawn({ pollerId: getPollerId() || '', workspaceId: msg.teamvibe.workspaceId, channelId: msg.teamvibe.channelId }) : undefined;
1466
+ const settings = readBrainSettings(effectiveCwd);
1467
+ const resolved = fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime?.pin?.workspaceId === msg.teamvibe.workspaceId
1468
+ ? { model: poolRuntime.pin.model, modelSource: poolRuntime.pin.modelSource }
1469
+ : resolveModel(typeof settings['model'] === 'string' ? settings['model'] : undefined, scoped ?? {}, config.CLAUDE_MODEL);
1470
+ const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool) : undefined;
1471
+ const claudeConfigDir = poolRuntime?.pin?.resumePending ? poolRuntime.pin.claudeConfigDir ?? getBaseBrainPath() : getBaseBrainPath();
1472
+ const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
1473
+ ? scoped?.modelNormalizedFrom : undefined;
1474
+ const runtime = poolRuntime ? { resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, shouldStop: poolRuntime.shouldStop, resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true } : undefined;
1475
+ let sawSendMessageCall = poolRuntime?.pin?.resumePending === true && poolRuntime.pin.sawSendMessageCall === true;
1476
+ const attempt = async (id, first) => {
1477
+ if (prepared?.account && id && poolRuntime) {
1478
+ try {
1479
+ await poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts);
1480
+ }
1481
+ catch {
1482
+ throw new PoolDeferredError();
1483
+ }
1484
+ }
1485
+ // Only a quota continuation shares delivery history. A fresh-session
1486
+ // fallback must report the fresh attempt's delivery flags independently.
1487
+ if (!runtime?.resumePending)
1488
+ sawSendMessageCall = false;
1489
+ const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
1490
+ sawSendMessageCall ||= result.sawSendMessageCall === true;
1491
+ if (prepared?.account) {
1492
+ if (poolAttempts.length >= 1000)
1493
+ poolAttempts.shift();
1494
+ poolAttempts.push({ workspaceId: prepared.account.workspaceId, accountId: prepared.account.accountId, credentialVersion: prepared.account.credentialVersion, accountLabel: prepared.account.label ?? prepared.account.accountId, observedAt: Date.now(), rateLimitInfo: result.rateLimitInfo, sessionId: id, model: resolved.model ?? result.metrics?.model, success: result.success, exitCode: result.exitCode, killedByPoller: result.killedByPoller, quota: result.quota, metrics: result.metrics });
1495
+ emitEvent('pool.account_attempted', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime?.quotaDeferEpisodes) });
1496
+ if (result.success && id && poolRuntime) {
1497
+ // Complete the inbox before any fallible post-inference store writes.
1498
+ // The loop retains bookkeeping independently of the original prompt.
1499
+ await poolRuntime.complete(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: false }, poolAttempts, result.rateLimitInfo);
1500
+ await emitCandidateUsage(msg, [prepared.account], prepared.store);
1501
+ retainWorktree = false;
1502
+ return result;
1503
+ }
1504
+ if (result.quota && !result.killedByPoller) {
1505
+ runtime.resumePending = true;
1506
+ retainWorktree = true;
1507
+ }
1508
+ // A failure in one store must not prevent the other stores from
1509
+ // retaining the attempt. Selection waits for all writes before switching.
1510
+ const writes = [];
1511
+ if (result.quota && !result.killedByPoller)
1512
+ writes.push(prepared.store.recordQuota(prepared.account, result.quota, Date.now(), { expireFutureObservations: true }));
1513
+ if (result.rateLimitInfo)
1514
+ writes.push(prepared.store.recordPassive(prepared.account, result.rateLimitInfo, Date.now(), { expireFutureObservations: true }));
1515
+ if (id && poolRuntime)
1516
+ writes.push(poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts));
1517
+ const outcomes = await Promise.allSettled(writes);
1518
+ if (outcomes.some(outcome => outcome.status === 'rejected'))
1519
+ throw new PoolDeferredError();
1520
+ }
1521
+ return result;
1522
+ };
1523
+ const attemptedAccounts = new Set();
1524
+ let switched = false;
1525
+ const finishQuota = async (initial) => {
1526
+ let result = initial;
1527
+ while (prepared?.account && result.quota && !result.killedByPoller && poolRuntime && runtime) {
1528
+ attemptedAccounts.add(prepared.account.accountId);
1529
+ if (poolRuntime.shouldStop() || attemptedAccounts.size >= 1000)
1530
+ throw new PoolDeferredError();
1531
+ prepared.pool = await fetchRuntimePool(msg);
1532
+ if (prepared.pool.kind === 'ready')
1533
+ await emitCandidateUsage(msg, prepared.pool.accounts, prepared.store);
1534
+ // Reactive evidence was persisted by attempt() before choosing another account.
1535
+ const remaining = prepared.pool.kind === 'ready' ? prepared.pool.accounts.filter(a => !attemptedAccounts.has(a.accountId)) : [];
1536
+ const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: remaining.length ? { kind: 'ready', accounts: remaining } : prepared.pool, store: prepared.store, now: Date.now() });
1537
+ if (selected.kind !== 'selected' || attemptedAccounts.has(selected.account.accountId))
1538
+ throw await poolDeferredError(prepared.pool, resolved, prepared.store);
1539
+ effectiveSessionId = result.attemptedSessionId ?? effectiveSessionId;
1540
+ if (!effectiveSessionId)
1541
+ throw new PoolDeferredError();
1542
+ const fromAccount = prepared.account;
1543
+ prepared.account = selected.account;
1544
+ runtime.account = selected.account;
1545
+ runtime.resumePending = true;
1546
+ switched = true;
1547
+ const selectedUsage = await prepared.store.read(selected.account).catch(() => ({ reactive: [], passive: undefined }));
1548
+ emitEvent('pool.account_switched', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime.quotaDeferEpisodes), ...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 });
1549
+ result = await attempt(effectiveSessionId, false);
1550
+ sawSendMessageCall ||= result.sawSendMessageCall === true;
1551
+ }
1552
+ return { ...result, sawSendMessageCall, ...(prepared?.account ? { poolAttempts } : {}) };
1553
+ };
1554
+ const result = await finishQuota(await attempt(effectiveSessionId, isFirstMessage));
1555
+ let replacedSessionId = false;
1556
+ // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
1557
+ let retryResult = result;
1558
+ if (!runtime?.resumePending && !switched && !result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
1559
+ sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
1560
+ retryResult = await finishQuota(await attempt(effectiveSessionId, true));
1561
+ }
1562
+ // If session ID is "already in use" (stale lock file), retry immediately with a
1563
+ // fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
1564
+ // retry's (teamvibe.ai#77 acceptance criteria: "no zombie kill needed first").
1565
+ if (!runtime?.resumePending && !switched && !retryResult.success && !retryResult.quota && effectiveSessionId && retryResult.error?.includes('already in use')) {
1566
+ sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
1567
+ const { randomUUID } = await import('crypto');
1568
+ const freshId = randomUUID();
1569
+ effectiveSessionId = freshId;
1570
+ replacedSessionId = true;
1571
+ retryResult = await finishQuota(await attempt(freshId, true));
1572
+ }
1573
+ return finalizeSpawnResult(retryResult, isFirstMessage || replacedSessionId, effectiveSessionId);
1574
+ }
1575
+ catch (error) {
1576
+ if (error instanceof PoolDeferredError) {
1577
+ retainWorktree = true;
1578
+ error.attempts = poolAttempts;
1579
+ }
1580
+ throw error;
1581
+ }
1582
+ finally {
1583
+ if (worktreeOwner && !retainWorktree) {
1584
+ removeWorktree(worktreeOwner.brainPath, worktreeOwner.id).catch((err) => {
1585
+ sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
1586
+ });
1587
+ }
1588
+ }
1589
+ }
1309
1590
  const activeProcesses = new Map();
1310
1591
  const activeProcessesByThread = new Map();
1311
1592
  // tv#317: when the last `claude` process ended, so the idle-restart gate can require
@@ -25,6 +25,8 @@
25
25
  */
26
26
  export type ModelConfigScope = 'poller' | 'workspace' | 'channel';
27
27
  export interface ResolvedModelConfig {
28
+ /** Original stored id supplied by the service after read normalization. */
29
+ modelNormalizedFrom?: string;
28
30
  model?: string;
29
31
  reasoningEffort?: string;
30
32
  modelSource?: ModelConfigScope;
@@ -104,6 +104,9 @@ export async function fetchConfigForSpawn(input) {
104
104
  if (typeof env.REASONING_EFFORT === 'string' && env.REASONING_EFFORT) {
105
105
  result.reasoningEffort = env.REASONING_EFFORT;
106
106
  }
107
+ if (result.model && typeof parsed.modelNormalizedFrom === 'string' && parsed.modelNormalizedFrom) {
108
+ result.modelNormalizedFrom = parsed.modelNormalizedFrom;
109
+ }
107
110
  if (isModelConfigScope(sources.MODEL))
108
111
  result.modelSource = sources.MODEL;
109
112
  if (isModelConfigScope(sources.REASONING_EFFORT))
@@ -1,3 +1,6 @@
1
+ import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
2
+ import type { PoolAttempt } from './subscription-runtime.js';
3
+ import type { TeamVibeQueueMessage, SubscriptionPin } from './types.js';
1
4
  /**
2
5
  * Write a new message to the inbox.
3
6
  * Returns the filename of the created message file.
@@ -23,7 +26,7 @@ export declare function markProcessed(brainPath: string, threadId: string, filen
23
26
  /**
24
27
  * Move a message from processing/ back to new/.
25
28
  *
26
- * Used specifically for a poller-initiated *shutdown* kill (teamvibe.ai#73,
29
+ * Used for quota-paused spawn preparation and a poller-initiated *shutdown* kill (teamvibe.ai#73,
27
30
  * PR#308 round-5): pickNextMessage() above moves a file to processing/ as
28
31
  * soon as it's picked up, not once it completes, so a message killed
29
32
  * mid-flight by shutdown() is left sitting in processing/. The inbox
@@ -49,3 +52,21 @@ export declare function requeueMessage(brainPath: string, threadId: string, file
49
52
  * Check if there are unprocessed messages in new/ or processing/.
50
53
  */
51
54
  export declare function hasUnprocessed(brainPath: string, threadId: string): boolean;
55
+ export declare function saveInboxContext(brainPath: string, msg: TeamVibeQueueMessage): void;
56
+ export declare function readInboxContext(brainPath: string, threadId: string): TeamVibeQueueMessage | undefined;
57
+ export interface InboxRuntime {
58
+ sessionId: string;
59
+ pin: SubscriptionPin;
60
+ filename: string;
61
+ quotaDeferEpisodes?: number;
62
+ attempts?: readonly PoolAttempt[];
63
+ /** Inference finished; only bookkeeping may be retried, never the prompt. */
64
+ completed?: {
65
+ observedAt: number;
66
+ retryAt: number;
67
+ rateLimitInfo?: ClaudeRateLimitInfo;
68
+ };
69
+ }
70
+ export declare function saveInboxRuntime(brainPath: string, threadId: string, state: InboxRuntime): void;
71
+ export declare function readInboxRuntime(brainPath: string, threadId: string): InboxRuntime | undefined;
72
+ export declare function clearInboxRuntime(brainPath: string, threadId: string): void;
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync } from 'fs';
1
+ import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync, rmSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { logger } from './logger.js';
4
4
  const INBOX_DIR = '.inbox';
@@ -96,7 +96,7 @@ export function markProcessed(brainPath, threadId, filename) {
96
96
  /**
97
97
  * Move a message from processing/ back to new/.
98
98
  *
99
- * Used specifically for a poller-initiated *shutdown* kill (teamvibe.ai#73,
99
+ * Used for quota-paused spawn preparation and a poller-initiated *shutdown* kill (teamvibe.ai#73,
100
100
  * PR#308 round-5): pickNextMessage() above moves a file to processing/ as
101
101
  * soon as it's picked up, not once it completes, so a message killed
102
102
  * mid-flight by shutdown() is left sitting in processing/. The inbox
@@ -126,7 +126,7 @@ export function requeueMessage(brainPath, threadId, filename) {
126
126
  try {
127
127
  if (existsSync(srcPath)) {
128
128
  renameSync(srcPath, dstPath);
129
- logger.info(`[Inbox] Requeued shutdown-killed message to new/: ${filename}`);
129
+ logger.info(`[Inbox] Requeued pending message to new/: ${filename}`);
130
130
  }
131
131
  else {
132
132
  logger.info(`[Inbox] Requeue skipped, ${filename} already gone from processing/`);
@@ -156,3 +156,57 @@ export function hasUnprocessed(brainPath, threadId) {
156
156
  return false;
157
157
  }
158
158
  }
159
+ // Context and continuation state live beside inbox files, independently of the pause marker.
160
+ function writeInboxState(brainPath, threadId, name, value) {
161
+ ensureInboxDirs(brainPath, threadId);
162
+ const path = join(getInboxPath(brainPath, threadId), name);
163
+ writeFileSync(`${path}.tmp`, JSON.stringify(value), { mode: 0o600 });
164
+ renameSync(`${path}.tmp`, path);
165
+ }
166
+ export function saveInboxContext(brainPath, msg) {
167
+ const { botToken: _token, ...teamvibe } = msg.teamvibe;
168
+ writeInboxState(brainPath, msg.thread_id, 'context.json', { ...msg, teamvibe });
169
+ }
170
+ export function readInboxContext(brainPath, threadId) {
171
+ const root = getInboxPath(brainPath, threadId);
172
+ const candidates = [];
173
+ for (const subdir of [PROCESSING_DIR, NEW_DIR]) {
174
+ try {
175
+ const first = readdirSync(join(root, subdir)).filter(name => name.endsWith('.txt')).sort()[0];
176
+ if (first)
177
+ candidates.push(join(root, subdir, first));
178
+ }
179
+ catch { }
180
+ }
181
+ candidates.push(join(root, 'context.json'));
182
+ for (const path of candidates) {
183
+ try {
184
+ const msg = JSON.parse(readFileSync(path, 'utf8'));
185
+ if (msg.thread_id !== threadId || !['slack', 'email', 'cron', 'maintenance', 'webhook'].includes(msg.source) || typeof msg.teamvibe?.workspaceId !== 'string' || typeof msg.teamvibe?.channelId !== 'string' || !msg.response_context || typeof msg.text !== 'string' || !msg.sender || !Array.isArray(msg.attachments))
186
+ continue;
187
+ return { ...msg, teamvibe: { ...msg.teamvibe, botToken: '' } };
188
+ }
189
+ catch { }
190
+ }
191
+ return undefined;
192
+ }
193
+ export function saveInboxRuntime(brainPath, threadId, state) {
194
+ writeInboxState(brainPath, threadId, 'pool-session.json', state);
195
+ }
196
+ export function readInboxRuntime(brainPath, threadId) {
197
+ try {
198
+ const state = JSON.parse(readFileSync(join(getInboxPath(brainPath, threadId), 'pool-session.json'), 'utf8'));
199
+ if (typeof state.sessionId !== 'string' || typeof state.filename !== 'string' || !state.pin?.workspaceId || !state.pin?.accountId || !state.pin?.credentialVersion)
200
+ return undefined;
201
+ return state;
202
+ }
203
+ catch {
204
+ return undefined;
205
+ }
206
+ }
207
+ export function clearInboxRuntime(brainPath, threadId) {
208
+ try {
209
+ rmSync(join(getInboxPath(brainPath, threadId), 'pool-session.json'), { force: true });
210
+ }
211
+ catch { }
212
+ }
@@ -36,7 +36,7 @@ export declare function parseSlackThreadId(threadId: string): {
36
36
  export declare function listThreadDirs(inboxRoot: string): string[];
37
37
  export declare function hasNewMessage(inboxRoot: string, threadId: string): boolean;
38
38
  /**
39
- * Scan every tracked brain's .inbox/ for a thread that has a new message but
39
+ * Scan every tracked brain's .inbox/ for a thread that has queued or retained work but
40
40
  * no active runInboxLoop, and start one. This is the only config-only path
41
41
  * that can wake a fully idle session (teamvibe.ai#242) — a file dropped into
42
42
  * .inbox/ by e.g. a background task otherwise sits inert until the next SQS