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

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 (49) hide show
  1. package/dist/classify-failed-iteration.d.ts +7 -0
  2. package/dist/classify-failed-iteration.js +12 -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 +323 -25
  9. package/dist/config-client.d.ts +2 -0
  10. package/dist/config-client.js +3 -0
  11. package/dist/event-queue.js +2 -2
  12. package/dist/inbox-manager.d.ts +22 -1
  13. package/dist/inbox-manager.js +57 -3
  14. package/dist/inbox-watcher.d.ts +1 -1
  15. package/dist/inbox-watcher.js +42 -20
  16. package/dist/poller.d.ts +1 -0
  17. package/dist/poller.js +316 -54
  18. package/dist/pool-defer.d.ts +13 -0
  19. package/dist/pool-defer.js +81 -0
  20. package/dist/pool-logging.d.ts +18 -0
  21. package/dist/pool-logging.js +79 -0
  22. package/dist/pool-telemetry.d.ts +44 -0
  23. package/dist/pool-telemetry.js +107 -0
  24. package/dist/quota-pause.d.ts +16 -0
  25. package/dist/quota-pause.js +96 -0
  26. package/dist/session-store.d.ts +2 -2
  27. package/dist/session-store.js +6 -3
  28. package/dist/slack-client.d.ts +4 -1
  29. package/dist/slack-client.js +19 -1
  30. package/dist/subscription-pool-client.d.ts +35 -0
  31. package/dist/subscription-pool-client.js +143 -0
  32. package/dist/subscription-pool.d.ts +21 -0
  33. package/dist/subscription-pool.js +30 -0
  34. package/dist/subscription-runtime.d.ts +57 -0
  35. package/dist/subscription-runtime.js +126 -0
  36. package/dist/subscription-selection.d.ts +77 -0
  37. package/dist/subscription-selection.js +157 -0
  38. package/dist/subscription-usage-probe.d.ts +16 -0
  39. package/dist/subscription-usage-probe.js +106 -0
  40. package/dist/subscription-usage-store.d.ts +50 -0
  41. package/dist/subscription-usage-store.js +223 -0
  42. package/dist/types.d.ts +35 -10
  43. package/dist/usage-limit-messages.d.ts +22 -0
  44. package/dist/usage-limit-messages.js +44 -0
  45. package/dist/usage-probe-job.d.ts +17 -0
  46. package/dist/usage-probe-job.js +186 -0
  47. package/dist/worktree-manager.js +4 -0
  48. package/npm-shrinkwrap.json +78 -77
  49. package/package.json +1 -1
@@ -1,16 +1,24 @@
1
+ import { logPoolFetch, logPoolUsageWrite, poolInfo, poolLabel } from './pool-logging.js';
2
+ import { accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
1
3
  import { spawn } from 'child_process';
2
4
  import { join } from 'path';
3
5
  import { readFileSync, existsSync } from 'fs';
6
+ import { mkdir } from 'fs/promises';
4
7
  import { WebClient } from '@slack/web-api';
5
8
  import { config } from './config.js';
6
9
  import { logger } from './logger.js';
7
10
  import { getBaseBrainPath } from './brain-manager.js';
8
11
  import { getPollerId } from './auth-provider.js';
9
12
  import { sendSlackMessage } from './slack-client.js';
10
- import { emitError } from './event-queue.js';
13
+ import { emitError, emitEvent } from './event-queue.js';
11
14
  import { createWorktree, removeWorktree, isWorktreeIsolationEnabled } from './worktree-manager.js';
12
15
  import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js';
13
16
  import { fetchConfigForSpawn } from './config-client.js';
17
+ import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
18
+ import { parseClaudeQuotaFailure } from './claude-quota.js';
19
+ import { accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
20
+ import { selectPoolForResolvedModel } from './subscription-pool.js';
21
+ import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
14
22
  /**
15
23
  * Maps tool names (or prefixes) to human-readable status messages.
16
24
  * More specific patterns are checked first.
@@ -462,6 +470,17 @@ export function buildSpawnEnv(params) {
462
470
  // drops against this var so they land somewhere still being
463
471
  // watched after the session (and worktree) is gone. See tv#265.
464
472
  POLLER_BRAIN_PATH: params.brainPath,
473
+ ...(params.subscriptionAccount ? {
474
+ CLAUDE_CODE_OAUTH_TOKEN: params.subscriptionAccount.credential.unwrap(),
475
+ ANTHROPIC_API_KEY: undefined,
476
+ ANTHROPIC_AUTH_TOKEN: undefined,
477
+ ANTHROPIC_BASE_URL: undefined,
478
+ ANTHROPIC_CUSTOM_HEADERS: undefined,
479
+ CLAUDE_CODE_OAUTH_TOKEN_FILE: undefined,
480
+ CLAUDE_CODE_USE_BEDROCK: undefined,
481
+ CLAUDE_CODE_USE_VERTEX: undefined,
482
+ CLAUDE_CODE_USE_FOUNDRY: undefined,
483
+ } : {}),
465
484
  };
466
485
  }
467
486
  export function buildPrompt(msg, contextHint) {
@@ -737,7 +756,51 @@ function resolveTypingStatusDetail(event, assistantText) {
737
756
  export function resolveTypingStatus(event, assistantText) {
738
757
  return resolveTypingStatusDetail(event, assistantText)?.text ?? null;
739
758
  }
740
- async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
759
+ function existingResultMetrics(event, pooled) {
760
+ // Preserve the legacy extractor exactly for non-pool spawns.
761
+ if (!pooled)
762
+ return {
763
+ duration_ms: event.duration_ms || 0,
764
+ duration_api_ms: event.duration_api_ms || 0,
765
+ num_turns: event.num_turns || 0,
766
+ total_cost_usd: event.total_cost_usd || 0,
767
+ input_tokens: event.usage?.input_tokens || 0,
768
+ output_tokens: event.usage?.output_tokens || 0,
769
+ cache_read_input_tokens: event.usage?.cache_read_input_tokens || 0,
770
+ cache_creation_input_tokens: event.usage?.cache_creation_input_tokens || 0,
771
+ model: Object.keys(event.modelUsage || {})[0] || 'unknown',
772
+ };
773
+ const result = {};
774
+ for (const key of ['duration_ms', 'duration_api_ms', 'num_turns', 'total_cost_usd']) {
775
+ if (typeof event[key] === 'number' && Number.isFinite(event[key]) && event[key] >= 0)
776
+ result[key] = event[key];
777
+ }
778
+ for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
779
+ if (typeof event.usage?.[key] === 'number' && Number.isFinite(event.usage[key]) && event.usage[key] >= 0)
780
+ result[key] = event.usage[key];
781
+ }
782
+ const model = Object.keys(event.modelUsage || {})[0];
783
+ if (model)
784
+ result.model = model;
785
+ return result;
786
+ }
787
+ // Context occupancy is one model call's input, never the accumulated result usage.
788
+ function resultMetrics(event, pooled, lastAssistant) {
789
+ const metrics = existingResultMetrics(event, pooled);
790
+ const usage = lastAssistant?.usage;
791
+ const inputs = [usage?.input_tokens, usage?.cache_read_input_tokens, usage?.cache_creation_input_tokens];
792
+ if (inputs.every(value => typeof value === 'number' && Number.isFinite(value) && value >= 0)) {
793
+ const tokens = inputs.reduce((sum, value) => sum + value, 0);
794
+ if (Number.isFinite(tokens))
795
+ metrics.context_tokens = tokens;
796
+ }
797
+ const model = lastAssistant?.model ?? Object.keys(event.modelUsage || {})[0];
798
+ const window = event.modelUsage?.[model]?.contextWindow;
799
+ if (typeof window === 'number' && Number.isFinite(window) && window > 0)
800
+ metrics.context_window = window;
801
+ return metrics;
802
+ }
803
+ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
741
804
  const slackContext = msg.response_context.slack;
742
805
  const isCronMessage = msg.source === 'cron';
743
806
  const isMaintenance = msg.source === 'maintenance';
@@ -757,7 +820,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
757
820
  // the more informative signal. Logic extracted into buildContextHint for
758
821
  // unit testability — the counters are injected so tests don't need to mock
759
822
  // the Slack WebClient module.
760
- const contextHint = await buildContextHint({
823
+ const contextHint = runtime?.resumePending ? undefined : await buildContextHint({
761
824
  isFirstMessage,
762
825
  lastMessageTs,
763
826
  slack: slackContext,
@@ -779,17 +842,25 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
779
842
  // Fetch scoped model config for this spawn — same hoisting rationale as
780
843
  // secrets above. Fail-soft: returns {} on any error, which resolveModel
781
844
  // treats as "nothing at this tier" and falls through to the env default.
782
- const ddbModelConfig = await fetchConfigForSpawn({
845
+ const ddbModelConfig = runtime ? {} : await fetchConfigForSpawn({
783
846
  pollerId: getPollerId() || '',
784
847
  workspaceId: msg.teamvibe.workspaceId,
785
848
  channelId: msg.teamvibe.channelId,
786
849
  });
850
+ if (runtime?.account)
851
+ await validateAccount(runtime.account, runtime.resolved);
787
852
  return new Promise((resolve) => {
788
- const prompt = buildPrompt(msg, contextHint);
853
+ // Preparation above awaits remote config/context. Another thread may
854
+ // have exhausted the shared account meanwhile. No awaits before spawn.
855
+ if (!runtime?.account && isQuotaPaused())
856
+ throw new QuotaPausedError();
857
+ if (runtime?.shouldStop())
858
+ throw new PoolDeferredError();
859
+ 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
860
  // teamvibe.ai#293 bug 2: decides plain-text vs. stream-json stdin. Empty
790
861
  // `extraArgs`/single-chunk `stdinChunks` (the overwhelming majority of
791
862
  // sessions — no inline images) reproduces the exact prior behavior.
792
- const spawnStdinPlan = buildSpawnStdinPlan(prompt, msg.inlineImages);
863
+ const spawnStdinPlan = buildSpawnStdinPlan(prompt, runtime?.resumePending ? undefined : msg.inlineImages);
793
864
  // MCP server config passed via --mcp-config flag (no temp files needed)
794
865
  const mcpServerPath = join(getBaseBrainPath(), 'mcp', 'slack.mjs');
795
866
  const teamvibeApiServerPath = join(getBaseBrainPath(), 'mcp', 'teamvibe-api.mjs');
@@ -819,10 +890,14 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
819
890
  // > CLAUDE_MODEL env default. See resolveModel's docstring for why
820
891
  // settings.json stays on top (known, accepted blind spot).
821
892
  const brainSettingsModel = typeof brainSettings['model'] === 'string' ? brainSettings['model'] : undefined;
822
- const { model, modelSource } = resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
893
+ const { model, modelSource } = runtime?.resolved ?? resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
823
894
  if (model) {
824
895
  args.push('--model', model);
825
- sessionLog.info(`Using model: ${model} (source: ${modelSource ?? 'unknown'})`);
896
+ // The service normalized the scoped config; only describe that mapping
897
+ // when this tier actually supplied the flag (brain settings stay verbatim).
898
+ const normalizedFrom = runtime?.modelNormalizedFrom ?? (model === ddbModelConfig.model && modelSource === ddbModelConfig.modelSource
899
+ ? ddbModelConfig.modelNormalizedFrom : undefined);
900
+ sessionLog.info(`Using model: ${model} (source: ${modelSource ?? 'unknown'})${normalizedFrom ? ` (normalized from ${normalizedFrom})` : ''}`);
826
901
  }
827
902
  // Inbox protocol: instruct Claude to check for mid-task messages (thread-scoped)
828
903
  const inboxNewDir = `.inbox/${msg.thread_id}/new`;
@@ -864,13 +939,15 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
864
939
  const envPath = config.PERSISTENT_STORAGE_PATH
865
940
  ? `${config.PERSISTENT_STORAGE_PATH}/bin:${process.env['PATH'] || ''}`
866
941
  : process.env['PATH'];
942
+ if (runtime?.account)
943
+ poolInfo(sessionLog, `token target account=${poolLabel(runtime.account)} session=${JSON.stringify(sessionId ?? 'new')} credential=selected account token`);
867
944
  const proc = spawn(config.CLAUDE_CLI_PATH, args, {
868
945
  cwd,
869
946
  env: buildSpawnEnv({
870
947
  spawnSecretsEnv: spawnSecrets.env.unwrap(),
871
948
  processEnv: process.env,
872
949
  envPath,
873
- claudeConfigDir: getBaseBrainPath(),
950
+ claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
874
951
  persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
875
952
  slackContext,
876
953
  teamvibeApiUrl: config.TEAMVIBE_API_URL,
@@ -881,6 +958,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
881
958
  teamvibePollerId: getPollerId() || '',
882
959
  inboxThreadId: msg.thread_id,
883
960
  brainPath,
961
+ subscriptionAccount: runtime?.account,
884
962
  }),
885
963
  stdio: ['pipe', 'pipe', 'pipe'],
886
964
  // teamvibeai/teamvibe.ai#73: run `claude` (via setsid) as the leader of
@@ -903,7 +981,11 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
903
981
  let stdout = '';
904
982
  let stderr = '';
905
983
  let stdoutBuffer = '';
984
+ // Retain CLI error/result evidence, not the full parsed assistant/tool stream.
985
+ const failureEvents = [];
986
+ let rateLimitInfo;
906
987
  let metrics;
988
+ let lastAssistant;
907
989
  // teamvibe.ai#284 (poller-brain#235): end-of-turn guard state — tracked
908
990
  // across the whole run so the `close` handler can tell poller.ts whether
909
991
  // a successful turn actually delivered anything to Slack.
@@ -958,6 +1040,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
958
1040
  for (const line of lines) {
959
1041
  try {
960
1042
  const event = JSON.parse(line);
1043
+ if (event?.type === 'rate_limit_event') {
1044
+ rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
1045
+ }
1046
+ if (event?.type === 'result' || event?.type === 'error' || (event?.type === 'assistant' && event.error)) {
1047
+ failureEvents.push(event);
1048
+ }
961
1049
  // Capture the pre-event activity timestamp BEFORE the unconditional
962
1050
  // overwrite below — needed to compute the "original 10min deadline
963
1051
  // from silence start" for a compact_boundary line, since that line
@@ -993,20 +1081,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
993
1081
  sessionLog.info('[Watchdog] Activity resumed after compaction, reverting to normal idle watchdog');
994
1082
  compactionKillDeadline = null;
995
1083
  }
1084
+ if (event.type === 'assistant')
1085
+ lastAssistant = { usage: event.message?.usage, model: event.message?.model };
996
1086
  // Extract metrics from result event
997
1087
  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)}, ` +
1088
+ metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
1089
+ sessionLog.info(`[Metrics] Cost: $${metrics.total_cost_usd?.toFixed(4) ?? 'unknown'}, ` +
1010
1090
  `Duration: ${metrics.duration_ms}ms, ` +
1011
1091
  `Turns: ${metrics.num_turns}, ` +
1012
1092
  `Tokens: ${metrics.input_tokens}in/${metrics.output_tokens}out`);
@@ -1169,8 +1249,29 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1169
1249
  // not something shutdown() awaits.
1170
1250
  void sweepProcessGroup(proc, CLOSE_SWEEP_GRACE_MS, { unref: true });
1171
1251
  const killReason = killReasonByProc.get(proc);
1252
+ // Preserve a final unterminated stream-json error/result and its metrics.
1253
+ if (stdoutBuffer.trim()) {
1254
+ try {
1255
+ const event = JSON.parse(stdoutBuffer);
1256
+ if (event?.type === 'rate_limit_event') {
1257
+ rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
1258
+ }
1259
+ failureEvents.push(event);
1260
+ if (event?.type === 'assistant')
1261
+ lastAssistant = { usage: event.message?.usage, model: event.message?.model };
1262
+ if (event?.type === 'result')
1263
+ metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
1264
+ }
1265
+ catch {
1266
+ // Non-JSON stdout is not trusted CLI error evidence.
1267
+ }
1268
+ }
1269
+ const quota = parseClaudeQuotaFailure({ exitCode: code, stderr, events: failureEvents, rateLimitInfo }, new Date());
1172
1270
  const result = {
1173
- success: code === 0,
1271
+ success: code === 0 && !quota,
1272
+ attemptedSessionId: sessionId,
1273
+ ...(quota ? { quota } : {}),
1274
+ ...(rateLimitInfo ? { rateLimitInfo } : {}),
1174
1275
  output: stdout,
1175
1276
  exitCode: code,
1176
1277
  metrics,
@@ -1191,6 +1292,9 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1191
1292
  else if (killReason === 'zombie') {
1192
1293
  result.error = 'Killed as zombie process (stale session lock)';
1193
1294
  }
1295
+ else if (quota) {
1296
+ result.error = 'Claude subscription usage limit reached.';
1297
+ }
1194
1298
  else if (code !== 0) {
1195
1299
  // `signal` matters here specifically: a process terminated by a
1196
1300
  // signal reports `code: null`, not a numeric exit code — e.g. a
@@ -1220,6 +1324,8 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
1220
1324
  output: stdout,
1221
1325
  exitCode: null,
1222
1326
  error: error.message,
1327
+ ...(rateLimitInfo ? { rateLimitInfo } : {}),
1328
+ attemptedSessionId: sessionId,
1223
1329
  sawSendMessageCall,
1224
1330
  lastPlainTextContent,
1225
1331
  });
@@ -1246,9 +1352,11 @@ function shouldIsolateInWorktree(msg) {
1246
1352
  * createSession() placeholder forever and the thread's next message does
1247
1353
  * `--resume <id-claude-code-never-used>`, which fails 100% of the time.
1248
1354
  * (teamvibe.ai#77, DevGuru review round)
1355
+ * Quota failures also report the attempted ID, even on the first turn, so
1356
+ * later pool orchestration can continue that same session (#401).
1249
1357
  */
1250
1358
  export function finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessionId) {
1251
- if (isFirstMessage && retryResult.success && effectiveSessionId) {
1359
+ if (isFirstMessage && (retryResult.success || retryResult.quota) && effectiveSessionId) {
1252
1360
  return { ...retryResult, newSessionId: effectiveSessionId };
1253
1361
  }
1254
1362
  return retryResult;
@@ -1282,14 +1390,14 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
1282
1390
  const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
1283
1391
  // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
1284
1392
  let retryResult = result;
1285
- if (!result.success && effectiveSessionId && !isFirstMessage) {
1393
+ if (!result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
1286
1394
  sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
1287
1395
  retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
1288
1396
  }
1289
1397
  // If session ID is "already in use" (stale lock file), retry immediately with a
1290
1398
  // fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
1291
1399
  // retry's (teamvibe.ai#77 acceptance criteria: "no zombie kill needed first").
1292
- if (!retryResult.success && effectiveSessionId && retryResult.error?.includes('already in use')) {
1400
+ if (!retryResult.success && !retryResult.quota && effectiveSessionId && retryResult.error?.includes('already in use')) {
1293
1401
  sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
1294
1402
  const { randomUUID } = await import('crypto');
1295
1403
  const freshId = randomUUID();
@@ -1306,6 +1414,196 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
1306
1414
  }
1307
1415
  }
1308
1416
  }
1417
+ // Configured-pool orchestration is deliberately separate from legacy spawns.
1418
+ export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, poolRuntime) {
1419
+ // Worktree isolation: spawned (non-foreground) sessions run in
1420
+ // <brain>/.worktrees/<thread_id>/ so the sync-watchdog's reset --hard on
1421
+ // the main working dir can't trample in-flight writes or pre-push commits.
1422
+ // See teamvibe.ai#120. cwd remains the main brain path on fallback / failure.
1423
+ let effectiveCwd = cwd;
1424
+ let loggingPool = false;
1425
+ let retainWorktree = poolRuntime?.pin?.resumePending === true;
1426
+ const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
1427
+ let worktreeOwner;
1428
+ if (poolRuntime?.pin?.resumePending && poolRuntime.pin.effectiveCwd) {
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)) {
1450
+ try {
1451
+ effectiveCwd = await createWorktree(cwd, msg.thread_id);
1452
+ worktreeOwner = { brainPath: cwd, id: msg.thread_id };
1453
+ sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
1454
+ }
1455
+ catch (err) {
1456
+ sessionLog.warn(`[Worktree] Failed to create worktree, falling back to main brain dir: ${err instanceof Error ? err.message : err}`);
1457
+ }
1458
+ }
1459
+ try {
1460
+ // First-message spawns always mint a fresh session ID rather than
1461
+ // recycling whatever placeholder DynamoDB currently holds for this
1462
+ // thread (teamvibe.ai#77) — --resume on non-first messages is unchanged.
1463
+ let effectiveSessionId = sessionId;
1464
+ if (isFirstMessage && !poolRuntime?.pin?.resumePending) {
1465
+ const { randomUUID } = await import('crypto');
1466
+ effectiveSessionId = randomUUID();
1467
+ }
1468
+ const fetchedPool = poolRuntime ? poolRuntime.pool ?? await fetchRuntimePool(msg) : undefined;
1469
+ loggingPool = fetchedPool !== undefined && fetchedPool.kind !== 'no_accounts_configured';
1470
+ if (fetchedPool)
1471
+ logPoolFetch(sessionLog, fetchedPool);
1472
+ const scoped = poolRuntime ? await fetchConfigForSpawn({ pollerId: getPollerId() || '', workspaceId: msg.teamvibe.workspaceId, channelId: msg.teamvibe.channelId }) : undefined;
1473
+ const settings = readBrainSettings(effectiveCwd);
1474
+ const resolved = fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime?.pin?.workspaceId === msg.teamvibe.workspaceId
1475
+ ? { model: poolRuntime.pin.model, modelSource: poolRuntime.pin.modelSource }
1476
+ : resolveModel(typeof settings['model'] === 'string' ? settings['model'] : undefined, scoped ?? {}, config.CLAUDE_MODEL);
1477
+ const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool, sessionLog) : undefined;
1478
+ const claudeConfigDir = poolRuntime?.pin?.resumePending ? poolRuntime.pin.claudeConfigDir ?? getBaseBrainPath() : getBaseBrainPath();
1479
+ const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
1480
+ ? scoped?.modelNormalizedFrom : undefined;
1481
+ const runtime = poolRuntime ? { resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, shouldStop: poolRuntime.shouldStop, resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true } : undefined;
1482
+ let sawSendMessageCall = poolRuntime?.pin?.resumePending === true && poolRuntime.pin.sawSendMessageCall === true;
1483
+ const attempt = async (id, first) => {
1484
+ if (prepared?.account && id && poolRuntime) {
1485
+ try {
1486
+ await poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts);
1487
+ }
1488
+ catch {
1489
+ throw new PoolDeferredError();
1490
+ }
1491
+ }
1492
+ // Only a quota continuation shares delivery history. A fresh-session
1493
+ // fallback must report the fresh attempt's delivery flags independently.
1494
+ if (!runtime?.resumePending)
1495
+ sawSendMessageCall = false;
1496
+ const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
1497
+ sawSendMessageCall ||= result.sawSendMessageCall === true;
1498
+ if (prepared?.account) {
1499
+ if (poolAttempts.length >= 1000)
1500
+ poolAttempts.shift();
1501
+ 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 });
1502
+ emitEvent('pool.account_attempted', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime?.quotaDeferEpisodes) });
1503
+ if (result.success && id && poolRuntime) {
1504
+ // Complete the inbox before any fallible post-inference store writes.
1505
+ // The loop retains bookkeeping independently of the original prompt.
1506
+ const bookkeepingComplete = await poolRuntime.complete(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: false }, poolAttempts, result.rateLimitInfo);
1507
+ if (bookkeepingComplete !== false)
1508
+ await emitCandidateUsage(msg, [prepared.account], prepared.store);
1509
+ retainWorktree = false;
1510
+ return result;
1511
+ }
1512
+ if (result.quota && !result.killedByPoller) {
1513
+ runtime.resumePending = true;
1514
+ retainWorktree = true;
1515
+ }
1516
+ // A failure in one store must not prevent the other stores from
1517
+ // retaining the attempt. Selection waits for all writes before switching.
1518
+ const writes = [];
1519
+ if (result.quota && !result.killedByPoller)
1520
+ writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.quota.window ? { rateLimitType: result.quota.window } : undefined, () => prepared.store.recordQuota(prepared.account, result.quota, Date.now(), { expireFutureObservations: true }), 'quota'));
1521
+ if (result.rateLimitInfo)
1522
+ writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.rateLimitInfo, () => prepared.store.recordPassive(prepared.account, result.rateLimitInfo, Date.now(), { expireFutureObservations: true })));
1523
+ if (id && poolRuntime)
1524
+ writes.push(poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts));
1525
+ const outcomes = await Promise.allSettled(writes);
1526
+ if (outcomes.some(outcome => outcome.status === 'rejected'))
1527
+ throw new PoolDeferredError();
1528
+ }
1529
+ return result;
1530
+ };
1531
+ const attemptedAccounts = new Set();
1532
+ let switched = false;
1533
+ const finishQuota = async (initial) => {
1534
+ let result = initial;
1535
+ while (prepared?.account && result.quota && !result.killedByPoller && poolRuntime && runtime) {
1536
+ attemptedAccounts.add(prepared.account.accountId);
1537
+ if (poolRuntime.shouldStop() || attemptedAccounts.size >= 1000)
1538
+ throw new PoolDeferredError();
1539
+ prepared.pool = await fetchRuntimePool(msg);
1540
+ logPoolFetch(sessionLog, prepared.pool);
1541
+ if (prepared.pool.kind === 'ready')
1542
+ await emitCandidateUsage(msg, prepared.pool.accounts, prepared.store);
1543
+ // Reactive evidence was persisted by attempt() before choosing another account.
1544
+ if (prepared.pool.kind === 'ready')
1545
+ for (const account of prepared.pool.accounts) {
1546
+ if (attemptedAccounts.has(account.accountId))
1547
+ poolInfo(sessionLog, `skipped account=${poolLabel(account)} reason=already exhausted in this session`);
1548
+ }
1549
+ const remaining = prepared.pool.kind === 'ready' ? prepared.pool.accounts.filter(a => !attemptedAccounts.has(a.accountId)) : [];
1550
+ const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: remaining.length && prepared.pool.kind === 'ready' ? { ...prepared.pool, accounts: remaining } : prepared.pool, store: prepared.store, now: Date.now(), log: sessionLog, logAllExhausted: remaining.length === 0 });
1551
+ if (selected.kind !== 'selected' || attemptedAccounts.has(selected.account.accountId))
1552
+ throw await poolDeferredError(prepared.pool, resolved, prepared.store, undefined, remaining.length ? sessionLog : undefined);
1553
+ effectiveSessionId = result.attemptedSessionId ?? effectiveSessionId;
1554
+ if (!effectiveSessionId)
1555
+ throw new PoolDeferredError();
1556
+ const fromAccount = prepared.account;
1557
+ poolInfo(sessionLog, `switch from=${poolLabel(fromAccount)} to=${poolLabel(selected.account)} session=${JSON.stringify(effectiveSessionId)}`);
1558
+ prepared.account = selected.account;
1559
+ runtime.account = selected.account;
1560
+ runtime.resumePending = true;
1561
+ switched = true;
1562
+ const selectedUsage = await prepared.store.read(selected.account).catch(() => ({ reactive: [], passive: undefined }));
1563
+ 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 });
1564
+ result = await attempt(effectiveSessionId, false);
1565
+ sawSendMessageCall ||= result.sawSendMessageCall === true;
1566
+ }
1567
+ return { ...result, sawSendMessageCall, ...(prepared?.account ? { poolAttempts } : {}) };
1568
+ };
1569
+ const result = await finishQuota(await attempt(effectiveSessionId, isFirstMessage));
1570
+ let replacedSessionId = false;
1571
+ // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
1572
+ let retryResult = result;
1573
+ if (!runtime?.resumePending && !switched && !result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
1574
+ sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
1575
+ retryResult = await finishQuota(await attempt(effectiveSessionId, true));
1576
+ }
1577
+ // If session ID is "already in use" (stale lock file), retry immediately with a
1578
+ // fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
1579
+ // retry's (teamvibe.ai#77 acceptance criteria: "no zombie kill needed first").
1580
+ if (!runtime?.resumePending && !switched && !retryResult.success && !retryResult.quota && effectiveSessionId && retryResult.error?.includes('already in use')) {
1581
+ sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
1582
+ const { randomUUID } = await import('crypto');
1583
+ const freshId = randomUUID();
1584
+ effectiveSessionId = freshId;
1585
+ replacedSessionId = true;
1586
+ retryResult = await finishQuota(await attempt(freshId, true));
1587
+ }
1588
+ return finalizeSpawnResult(retryResult, isFirstMessage || replacedSessionId, effectiveSessionId);
1589
+ }
1590
+ catch (error) {
1591
+ if (error instanceof PoolDeferredError) {
1592
+ if (loggingPool)
1593
+ poolInfo(sessionLog, `work deferred eligibleAt=${new Date(error.eligibleAt).toISOString()} providerResetAt=${error.resetAt ?? 'unknown'}`);
1594
+ retainWorktree = true;
1595
+ error.attempts = poolAttempts;
1596
+ }
1597
+ throw error;
1598
+ }
1599
+ finally {
1600
+ if (worktreeOwner && !retainWorktree) {
1601
+ removeWorktree(worktreeOwner.brainPath, worktreeOwner.id).catch((err) => {
1602
+ sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
1603
+ });
1604
+ }
1605
+ }
1606
+ }
1309
1607
  const activeProcesses = new Map();
1310
1608
  const activeProcessesByThread = new Map();
1311
1609
  // 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))
@@ -69,7 +69,7 @@ export async function flush() {
69
69
  return;
70
70
  isFlushing = true;
71
71
  try {
72
- const batch = [...queue];
72
+ const batch = queue.splice(0);
73
73
  const failed = [];
74
74
  for (const event of batch) {
75
75
  const ok = await flushOne(event);
@@ -77,7 +77,7 @@ export async function flush() {
77
77
  failed.push(event);
78
78
  }
79
79
  }
80
- queue = failed;
80
+ queue = [...failed, ...queue].slice(-MAX_QUEUE_SIZE);
81
81
  if (failed.length > 0) {
82
82
  logger.debug(`[EventQueue] ${failed.length} event(s) failed to flush, will retry`);
83
83
  }
@@ -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;