@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
package/dist/poller.js CHANGED
@@ -1,9 +1,19 @@
1
+ import { isUsageProbe, handleUsageProbe } from './usage-probe-job.js';
2
+ import { fetchSubscriptionPool } from './subscription-pool-client.js';
3
+ import { formatPoolUsageLimitMessage } from './usage-limit-messages.js';
4
+ import { logPoolUsageWrite } from './pool-logging.js';
5
+ import { emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
1
6
  import { config, POLLER_VERSION } from './config.js';
2
7
  import { logger, setLogContext } from './logger.js';
3
8
  import { pollMessages, deleteMessage, } from './sqs-poller.js';
4
- import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
9
+ import { spawnClaudeCode, spawnPoolClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
5
10
  import { sendSlackError, sendSlackMessage, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
6
11
  import { classifyFailedIteration } from './classify-failed-iteration.js';
12
+ import { fetchSecretsForSpawn } from './secrets-client.js';
13
+ import { PoolDeferredError, fetchRuntimePool, subscriptionUsageStore } from './subscription-runtime.js';
14
+ import { POOL_RECHECK_MS } from './subscription-selection.js';
15
+ import { isPoolDeferred, readPoolDefer, deferPool, setPoolNotice, clearPoolDefer } from './pool-defer.js';
16
+ import { isQuotaPaused, pauseForQuota, clearQuotaPauseAfterSuccess, QuotaPausedError } from './quota-pause.js';
7
17
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
8
18
  import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
9
19
  import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
@@ -14,7 +24,7 @@ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './main
14
24
  import { bootstrapTrackedBrains } from './brains-bootstrap.js';
15
25
  import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
16
26
  import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
17
- import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage } from './inbox-manager.js';
27
+ import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage, saveInboxContext, saveInboxRuntime, readInboxRuntime, clearInboxRuntime } from './inbox-manager.js';
18
28
  import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
19
29
  import { Cron } from 'croner';
20
30
  import { isControlMessage, handleControlMessage } from './control-handler.js';
@@ -239,6 +249,15 @@ export function endOfTurnFallbackText(hasSlackContext, result) {
239
249
  return text.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join(END_OF_TURN_SENTINEL_DISPLAY);
240
250
  }
241
251
  export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
252
+ if (isQuotaPaused() && !getPollerId())
253
+ return;
254
+ const initialPool = await fetchRuntimePool(queueMessage);
255
+ if (initialPool.kind === 'no_accounts_configured') {
256
+ if (isQuotaPaused())
257
+ return;
258
+ }
259
+ else if (isPoolDeferred(threadId))
260
+ return;
242
261
  if (activeLoops.has(threadId)) {
243
262
  sessionLog.info(`Loop already running for thread ${threadId}, skipping`);
244
263
  return;
@@ -266,6 +285,48 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
266
285
  activeLocks.set(threadId, lockToken);
267
286
  }
268
287
  let currentSessionId = session.session_id || undefined;
288
+ let currentPin = session.subscription_pin;
289
+ let deferred = false;
290
+ let poolIteration = false;
291
+ const retryCompletedBookkeeping = async (state, pool, liveCompletion = false) => {
292
+ if (!state.completed)
293
+ return true;
294
+ const writes = [];
295
+ // A live run may expire evidence after clock rollback; replaying an old
296
+ // completion must preserve observations produced by newer concurrent runs.
297
+ const lastAttempt = state.attempts?.at(-1);
298
+ // Existing telemetry falls back to account IDs. Local pool logs use labels only.
299
+ const accountLabel = lastAttempt?.accountLabel !== state.pin.accountId ? lastAttempt?.accountLabel : undefined;
300
+ if (state.completed.rateLimitInfo)
301
+ writes.push(logPoolUsageWrite(sessionLog, { label: accountLabel }, state.completed.rateLimitInfo, () => subscriptionUsageStore().recordPassive(state.pin, state.completed.rateLimitInfo, state.completed.observedAt, liveCompletion ? { expireFutureObservations: true } : undefined)));
302
+ writes.push(lockToken ? updateSessionId(threadId, lockToken, state.sessionId, state.pin) : Promise.reject(new Error('Missing lock')));
303
+ const outcomes = await Promise.allSettled(writes);
304
+ if (outcomes.some(outcome => outcome.status === 'rejected' || outcome.value === false)) {
305
+ sessionLog.warn('[Loop] Inference completed; pool bookkeeping failed and will retry without running the agent');
306
+ try {
307
+ saveInboxRuntime(kbPath, threadId, { ...state, completed: { ...state.completed, retryAt: Date.now() + POOL_RECHECK_MS } });
308
+ }
309
+ catch {
310
+ sessionLog.warn('[Loop] Could not save bookkeeping retry; completion remains recorded');
311
+ }
312
+ return false;
313
+ }
314
+ currentSessionId = state.sessionId;
315
+ currentPin = state.pin;
316
+ // The spawner publishes live completions only after bookkeeping succeeds.
317
+ // Recovery uses a validated current account, without attributing an old
318
+ // credential's observation to its replacement.
319
+ if (!liveCompletion && pool.kind === 'ready') {
320
+ const account = pool.accounts.find(account => account.workspaceId === state.pin.workspaceId &&
321
+ account.workspaceId === queueMessage.teamvibe.workspaceId &&
322
+ account.accountId === state.pin.accountId &&
323
+ account.credentialVersion === state.pin.credentialVersion);
324
+ if (account)
325
+ await emitCandidateUsage(queueMessage, [account], subscriptionUsageStore());
326
+ }
327
+ clearInboxRuntime(kbPath, threadId);
328
+ return true;
329
+ };
269
330
  // PR#308 round-3 review (teamvibe.ai#73): `shuttingDown` (declared below,
270
331
  // referenced here via the same forward-reference pattern already used by
271
332
  // pollLoop()'s `while (!shuttingDown)`) gates every new iteration of this
@@ -285,6 +346,24 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
285
346
  // that the existing `if (!message) break` / `if (!hasUnprocessed(...))
286
347
  // break` exits already use, so lock release is unaffected.
287
348
  while (!shuttingDown) {
349
+ // Dispatch before ANY pool state/context/model preparation. The explicit
350
+ // no-pool outcome runs the original spawn and #408 pause gates.
351
+ const pool = iteration === 0 ? initialPool : await fetchRuntimePool(queueMessage);
352
+ poolIteration = pool.kind !== 'no_accounts_configured';
353
+ if (!poolIteration && isQuotaPaused())
354
+ break;
355
+ if (poolIteration) {
356
+ const completion = readInboxRuntime(kbPath, threadId);
357
+ if (completion?.completed) {
358
+ // Completed prompts never enter pickNextMessage/spawn again, even
359
+ // when the usage store or the session pin needs a later repair.
360
+ markProcessed(kbPath, threadId, completion.filename);
361
+ if (Date.now() >= completion.completed.observedAt && Date.now() < completion.completed.retryAt || !await retryCompletedBookkeeping(completion, pool)) {
362
+ deferred = hasUnprocessed(kbPath, threadId);
363
+ break;
364
+ }
365
+ }
366
+ }
288
367
  const message = pickNextMessage(kbPath, threadId);
289
368
  if (!message) {
290
369
  sessionLog.info(`[Loop] No more messages for thread ${threadId}`);
@@ -294,22 +373,91 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
294
373
  const isFirstMessage = session.message_count === 1 && iteration === 1;
295
374
  sessionLog.info(`[Loop] Iteration #${iteration} — processing: ${message.filename}`);
296
375
  await ensureBaseBrain();
297
- const hasSlackContext = Boolean(queueMessage.response_context.slack?.channel &&
298
- queueMessage.response_context.slack?.message_ts);
299
- const typing = hasSlackContext
300
- ? startTypingIndicator(queueMessage)
301
- : undefined;
376
+ let inboxMsg = queueMessage;
377
+ let deliveryMsg = queueMessage;
378
+ let hasSlackContext = Boolean(queueMessage.response_context.slack?.channel && queueMessage.response_context.slack?.message_ts);
379
+ let typing = !poolIteration && hasSlackContext ? startTypingIndicator(queueMessage) : undefined;
380
+ const retainedRuntime = readInboxRuntime(kbPath, threadId);
381
+ let quotaDeferEpisodes = Math.max(retainedRuntime?.filename === message.filename ? retainedRuntime.quotaDeferEpisodes ?? 0 : 0, readPoolDefer(threadId) ? 1 : 0);
382
+ let quotaPaused = false;
302
383
  let succeeded = false;
303
384
  let lastExitCode = null;
304
385
  let lastKilledByPoller;
305
386
  try {
306
387
  // Reconstruct queue message from stored inbox data
307
388
  const storedData = JSON.parse(message.content);
308
- const inboxMsg = {
389
+ inboxMsg = {
309
390
  ...queueMessage,
310
391
  ...storedData,
392
+ // Durable inbox routing omits credentials. Preserve live metadata
393
+ // missing from older payloads, and always use the live bot token.
394
+ teamvibe: { ...queueMessage.teamvibe, ...storedData.teamvibe, botToken: queueMessage.teamvibe.botToken },
311
395
  };
312
- const result = await spawnClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
396
+ // Legacy delivery uses the original routing, just as on main.
397
+ let result;
398
+ if (!poolIteration) {
399
+ result = await spawnClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
400
+ }
401
+ else {
402
+ if (storedData.teamvibe && (storedData.teamvibe.workspaceId !== queueMessage.teamvibe.workspaceId || storedData.teamvibe.channelId !== queueMessage.teamvibe.channelId))
403
+ throw new PoolDeferredError();
404
+ if (!queueMessage.teamvibe.botToken && queueMessage.response_context.slack) {
405
+ const secrets = await fetchSecretsForSpawn({ pollerId: getPollerId() || '', workspaceId: queueMessage.teamvibe.workspaceId, channelId: queueMessage.teamvibe.channelId });
406
+ const token = secrets.env.unwrap()['SLACK_BOT_TOKEN'];
407
+ queueMessage = { ...queueMessage, teamvibe: { ...queueMessage.teamvibe, botToken: typeof token === 'string' ? token : '' } };
408
+ inboxMsg = { ...inboxMsg, teamvibe: { ...inboxMsg.teamvibe, botToken: queueMessage.teamvibe.botToken } };
409
+ }
410
+ deliveryMsg = inboxMsg;
411
+ hasSlackContext = Boolean(inboxMsg.response_context.slack?.channel && inboxMsg.response_context.slack?.message_ts);
412
+ typing = hasSlackContext ? startTypingIndicator(inboxMsg) : undefined;
413
+ try {
414
+ saveInboxContext(kbPath, inboxMsg);
415
+ }
416
+ catch {
417
+ throw new PoolDeferredError();
418
+ }
419
+ const savedRuntime = readInboxRuntime(kbPath, threadId);
420
+ if (savedRuntime?.filename === message.filename && savedRuntime.pin.workspaceId === queueMessage.teamvibe.workspaceId && (!currentPin || currentPin.accountId === savedRuntime.pin.accountId)) {
421
+ currentSessionId = savedRuntime.sessionId;
422
+ currentPin = savedRuntime.pin;
423
+ }
424
+ result = await spawnPoolClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing, {
425
+ pool,
426
+ quotaDeferEpisodes,
427
+ attempts: savedRuntime?.filename === message.filename ? savedRuntime.attempts : undefined,
428
+ pin: currentPin ? {
429
+ ...currentPin,
430
+ resumePending: (savedRuntime?.filename === message.filename || !savedRuntime && currentPin.inboxFilename === message.filename) && currentPin.resumePending === true,
431
+ sawSendMessageCall: (savedRuntime?.filename === message.filename || !savedRuntime && currentPin.inboxFilename === message.filename) && currentPin.sawSendMessageCall === true,
432
+ } : undefined,
433
+ shouldStop: () => shuttingDown,
434
+ complete: async (id, pin, attempts, rateLimitInfo) => {
435
+ const state = { sessionId: id, pin, filename: message.filename, attempts, quotaDeferEpisodes, completed: { observedAt: Date.now(), retryAt: Date.now(), rateLimitInfo } };
436
+ // This completion record precedes usage/DDB bookkeeping. Inbox
437
+ // deletion also happens before those writes; no defer on failure.
438
+ try {
439
+ saveInboxRuntime(kbPath, threadId, state);
440
+ }
441
+ catch {
442
+ sessionLog.warn('[Loop] Could not save completion record; removing completed inbox before bookkeeping');
443
+ }
444
+ markProcessed(kbPath, threadId, message.filename);
445
+ currentSessionId = id;
446
+ currentPin = pin;
447
+ succeeded = true;
448
+ clearPoolDefer(threadId);
449
+ return retryCompletedBookkeeping(state, pool, true);
450
+ },
451
+ persist: async (id, pin, attempts) => {
452
+ pin = { ...pin, inboxFilename: message.filename };
453
+ if (!lockToken || await updateSessionId(threadId, lockToken, id, pin) === false)
454
+ throw new PoolDeferredError();
455
+ currentSessionId = id;
456
+ currentPin = pin;
457
+ saveInboxRuntime(kbPath, threadId, { sessionId: id, pin, filename: message.filename, attempts, quotaDeferEpisodes });
458
+ },
459
+ });
460
+ }
313
461
  typing?.stop();
314
462
  if (result.newSessionId) {
315
463
  // Update local state first — DynamoDB writes may fail (expired token,
@@ -318,7 +466,9 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
318
466
  currentSessionId = result.newSessionId;
319
467
  if (lockToken) {
320
468
  try {
321
- await updateSessionId(threadId, lockToken, result.newSessionId);
469
+ await (poolIteration
470
+ ? updateSessionId(threadId, lockToken, result.newSessionId, currentPin)
471
+ : updateSessionId(threadId, lockToken, result.newSessionId));
322
472
  }
323
473
  catch (err) {
324
474
  sessionLog.error(`[Loop] Failed to persist new session ID to DynamoDB: ${err instanceof Error ? err.message : err}`);
@@ -327,29 +477,33 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
327
477
  }
328
478
  if (result.metrics) {
329
479
  sessionLog.info(`[Loop] Iteration #${iteration} complete — ` +
330
- `cost: $${result.metrics.total_cost_usd.toFixed(4)}, ` +
480
+ `cost: $${result.metrics.total_cost_usd?.toFixed(4) ?? 'unknown'}, ` +
331
481
  `duration: ${result.metrics.duration_ms}ms`);
332
482
  }
333
483
  if (result.success) {
334
484
  succeeded = true;
485
+ clearQuotaPauseAfterSuccess();
335
486
  sessionLog.info(`[Loop] Claude completed iteration #${iteration}`);
336
- if (result.metrics) {
337
- emitEvent('session.completed', queueMessage.teamvibe.channelId, {
338
- threadId: queueMessage.thread_id,
339
- total_cost_usd: result.metrics.total_cost_usd,
340
- input_tokens: result.metrics.input_tokens,
341
- output_tokens: result.metrics.output_tokens,
342
- cache_read_input_tokens: result.metrics.cache_read_input_tokens,
343
- cache_creation_input_tokens: result.metrics.cache_creation_input_tokens,
344
- duration_ms: result.metrics.duration_ms,
345
- duration_api_ms: result.metrics.duration_api_ms,
346
- num_turns: result.metrics.num_turns,
347
- model: result.metrics.model,
487
+ if (result.metrics || result.poolAttempts) {
488
+ emitEvent('session.completed', deliveryMsg.teamvibe.channelId, {
489
+ threadId: deliveryMsg.thread_id,
490
+ ...(result.metrics?.context_tokens != null ? { context_tokens: result.metrics.context_tokens } : {}),
491
+ ...(result.metrics?.context_window != null ? { context_window: result.metrics.context_window } : {}),
492
+ total_cost_usd: result.metrics?.total_cost_usd,
493
+ input_tokens: result.metrics?.input_tokens,
494
+ output_tokens: result.metrics?.output_tokens,
495
+ cache_read_input_tokens: result.metrics?.cache_read_input_tokens,
496
+ cache_creation_input_tokens: result.metrics?.cache_creation_input_tokens,
497
+ duration_ms: result.metrics?.duration_ms,
498
+ duration_api_ms: result.metrics?.duration_api_ms,
499
+ num_turns: result.metrics?.num_turns,
500
+ model: result.metrics?.model,
348
501
  // Which config layer actually chose the `--model` flag
349
502
  // (settings.json / poller / workspace / channel / env-default)
350
503
  // — teamvibe.ai#235 PR2. Distinct from `model` above, which is
351
504
  // Claude CLI's own report of the model it used.
352
505
  modelSource: result.modelSource,
506
+ ...(result.poolAttempts ? { workspaceId: deliveryMsg.teamvibe.workspaceId, ...poolRunTelemetry(result.poolAttempts, quotaDeferEpisodes) } : {}),
353
507
  });
354
508
  }
355
509
  // teamvibe.ai#284 (poller-brain#235): end-of-turn guard. A
@@ -363,23 +517,35 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
363
517
  if (fallbackText) {
364
518
  sessionLog.warn(`[Loop] Iteration #${iteration}: no send_message call detected, delivering trailing assistant text via fallback`);
365
519
  try {
366
- await sendSlackMessage(queueMessage, fallbackText);
520
+ await sendSlackMessage(deliveryMsg, fallbackText);
367
521
  }
368
522
  catch (err) {
369
523
  sessionLog.error(`[Loop] End-of-turn fallback delivery failed: ${err instanceof Error ? err.message : err}`);
370
524
  }
371
525
  emitError('session', 'End-of-turn guard: delivered assistant text via fallback (no send_message tool call detected)', {
372
526
  severity: 'warn',
373
- channelId: queueMessage.teamvibe.channelId,
374
- context: { threadId: queueMessage.thread_id },
527
+ channelId: deliveryMsg.teamvibe.channelId,
528
+ context: { threadId: deliveryMsg.thread_id },
375
529
  });
376
530
  }
377
- if (queueMessage.teamvibe.brain?.brainId) {
378
- await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId, queueMessage.teamvibe.workspaceId);
531
+ if (deliveryMsg.teamvibe.brain?.brainId) {
532
+ await pushBrainChanges(kbPath, deliveryMsg.teamvibe.brain.brainId, deliveryMsg.teamvibe.workspaceId);
379
533
  }
380
- await postMaintenanceReports(kbPath, queueMessage.teamvibe.channelId);
534
+ await postMaintenanceReports(kbPath, deliveryMsg.teamvibe.channelId);
381
535
  }
382
536
  else {
537
+ // Set before Slack I/O: a delivery failure must not retry or discard
538
+ // the saved request. Kill intent still wins over incidental quota.
539
+ const decision = classifyFailedIteration(result);
540
+ quotaPaused = decision.kind === 'quota';
541
+ const notifyQuota = decision.kind === 'quota'
542
+ ? pauseForQuota(decision.quota, kbPath, threadId)
543
+ : false;
544
+ if (quotaPaused && !notifyQuota) {
545
+ // Another in-flight request already owns the retained marker.
546
+ // Queue this one so the post-pause sweep can find both requests.
547
+ requeueMessage(kbPath, threadId, message.filename);
548
+ }
383
549
  lastExitCode = result.exitCode;
384
550
  lastKilledByPoller = result.killedByPoller;
385
551
  sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
@@ -399,27 +565,76 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
399
565
  if (lastKilledByPoller !== 'shutdown') {
400
566
  emitError('session', `Iteration #${iteration} failed for ${message.filename}`, {
401
567
  severity: 'warn',
402
- channelId: queueMessage.teamvibe.channelId,
568
+ channelId: deliveryMsg.teamvibe.channelId,
403
569
  context: { threadId, filename: message.filename, iteration, reason: describeFailureReason(lastKilledByPoller, lastExitCode) },
404
570
  });
405
571
  }
406
- if (hasSlackContext) {
407
- const decision = classifyFailedIteration(result);
408
- if (decision.kind === 'neutral') {
409
- await sendSlackMessage(queueMessage, decision.text);
572
+ if (hasSlackContext && (decision.kind !== 'quota' || notifyQuota)) {
573
+ if (decision.kind === 'neutral' || decision.kind === 'quota') {
574
+ await sendSlackMessage(deliveryMsg, decision.text);
410
575
  }
411
576
  else {
412
- await sendSlackError(queueMessage, decision.text);
577
+ await sendSlackError(deliveryMsg, decision.text);
413
578
  }
414
- await addReaction(queueMessage, decision.reaction);
579
+ await addReaction(deliveryMsg, decision.reaction);
415
580
  }
416
581
  }
417
582
  }
418
583
  catch (error) {
419
584
  typing?.stop();
420
- sessionLog.error(`[Loop] Error in iteration #${iteration}: ${error instanceof Error ? error.message : error}`);
585
+ if (poolIteration && error instanceof PoolDeferredError) {
586
+ quotaPaused = true;
587
+ deferred = true;
588
+ const pause = deferPool(threadId, error.eligibleAt, error.resetAt);
589
+ const retained = readInboxRuntime(kbPath, threadId);
590
+ // Rechecks of one exhaustion episode do not inflate the episode count.
591
+ quotaDeferEpisodes = Math.max(1, quotaDeferEpisodes);
592
+ if (retained)
593
+ saveInboxRuntime(kbPath, threadId, { ...retained, quotaDeferEpisodes });
594
+ emitEvent('pool.deferred', deliveryMsg.teamvibe.channelId, { workspaceId: deliveryMsg.teamvibe.workspaceId, threadId: deliveryMsg.thread_id, ...poolRunTelemetry(error.attempts ?? [], quotaDeferEpisodes), episodeId: pause.episodeId, eligibleAt: pause.resumeAt, resetAt: pause.resetAt });
595
+ if (hasSlackContext && pause.notice !== 'delivered') {
596
+ const reconcile = pause.notice === 'claimed' || pause.notice === 'ambiguous';
597
+ if (!reconcile && !setPoolNotice(threadId, pause.episodeId, 'claimed'))
598
+ throw new PoolDeferredError(error.eligibleAt);
599
+ try {
600
+ const notice = formatPoolUsageLimitMessage({
601
+ ...error.notice,
602
+ thresholdDeferred: error.notice?.thresholdDeferred && !error.attempts?.some(a => a.quota),
603
+ resetAt: pause.resetAt ? Date.parse(pause.resetAt) : error.notice?.resetAt,
604
+ timeZone: config.POLLER_TZ,
605
+ });
606
+ await sendSlackMessage(deliveryMsg, notice, { clientMessageId: pause.episodeId, ...(reconcile ? { reconcile: true } : {}) });
607
+ setPoolNotice(threadId, pause.episodeId, 'delivered');
608
+ }
609
+ catch (deliveryError) {
610
+ const rejected = deliveryError?.data?.ok === false;
611
+ setPoolNotice(threadId, pause.episodeId, rejected && !reconcile ? 'pending' : 'ambiguous');
612
+ sessionLog.warn('[Loop] Pool notice delivery uncertain; pending work retained');
613
+ }
614
+ }
615
+ }
616
+ else if (error instanceof QuotaPausedError) {
617
+ // No CLI ran: restore queued work without notices or retry counting.
618
+ requeueMessage(kbPath, threadId, message.filename);
619
+ quotaPaused = true;
620
+ }
621
+ else {
622
+ sessionLog.error(`[Loop] Error in iteration #${iteration}: ${error instanceof Error ? error.message : error}`);
623
+ }
624
+ }
625
+ if (quotaPaused) {
626
+ // The durable account pause gates incoming traffic and watcher sweeps.
627
+ // Keep the original in processing/ so it resumes before queued work.
628
+ deferred = true;
629
+ sessionLog.info(`[Loop] Quota pause active; leaving ${message.filename} pending`);
630
+ break;
421
631
  }
422
632
  if (succeeded) {
633
+ if (poolIteration) {
634
+ clearPoolDefer(threadId);
635
+ if (!readInboxRuntime(kbPath, threadId)?.completed)
636
+ clearInboxRuntime(kbPath, threadId);
637
+ }
423
638
  markProcessed(kbPath, threadId, message.filename);
424
639
  }
425
640
  else {
@@ -470,6 +685,21 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
470
685
  const reasonLabel = describeFailureReason(lastKilledByPoller, lastExitCode);
471
686
  sessionLog.error(`[Loop] Message ${message.filename} failed ${failures} times (last reason: ${reasonLabel}), giving up`);
472
687
  markFailed(kbPath, threadId, message.filename);
688
+ if (poolIteration) {
689
+ clearInboxRuntime(kbPath, threadId);
690
+ clearPoolDefer(threadId);
691
+ if (currentPin) {
692
+ const { inboxFilename: _filename, ...pin } = currentPin;
693
+ currentPin = { ...pin, resumePending: false, sawSendMessageCall: false };
694
+ try {
695
+ if (lockToken && currentSessionId)
696
+ await updateSessionId(threadId, lockToken, currentSessionId, currentPin);
697
+ }
698
+ catch {
699
+ sessionLog.warn('[Loop] Terminal failure cleared local continuation; stale pin is fenced by inbox filename');
700
+ }
701
+ }
702
+ }
473
703
  if (hasSlackContext) {
474
704
  // tv#320 DevGuru round-2 review (BLOCKING): this whole else-if
475
705
  // chain sits structurally after the per-iteration try/catch
@@ -484,16 +714,16 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
484
714
  // rate-limit/auth/network error here needs the same explicit
485
715
  // try/catch the end-of-turn-guard fallback above already uses.
486
716
  try {
487
- await sendSlackError(queueMessage, `Giving up after ${failures} attempts (last reason: ${reasonLabel}) — this message was not processed. Please send it again.`);
717
+ await sendSlackError(deliveryMsg, `Giving up after ${failures} attempts (last reason: ${reasonLabel}) — this message was not processed. Please send it again.`);
488
718
  }
489
719
  catch (err) {
490
720
  sessionLog.error(`[Loop] Give-up notice delivery failed: ${err instanceof Error ? err.message : err}`);
491
721
  }
492
- await addReaction(queueMessage, 'x');
722
+ await addReaction(deliveryMsg, 'x');
493
723
  }
494
724
  emitError('session', `Message ${message.filename} failed ${failures} times, giving up`, {
495
725
  severity: 'error',
496
- channelId: queueMessage.teamvibe.channelId,
726
+ channelId: deliveryMsg.teamvibe.channelId,
497
727
  context: { threadId, filename: message.filename, reason: reasonLabel },
498
728
  });
499
729
  }
@@ -514,6 +744,8 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
514
744
  }
515
745
  }
516
746
  else {
747
+ if (poolIteration && currentPin?.resumePending)
748
+ deferred = true;
517
749
  // PR#308 round-5 review (teamvibe.ai#73): pickNextMessage() moves a
518
750
  // message's file to processing/ as soon as it's picked up, not once
519
751
  // it completes — so a message killed here by shutdown is left
@@ -562,14 +794,25 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
562
794
  }
563
795
  sessionLog.info(`[Loop] More messages found, continuing...`);
564
796
  }
797
+ if (poolIteration && currentPin?.resumePending && hasUnprocessed(kbPath, threadId))
798
+ deferred = true;
565
799
  if (lockToken) {
566
800
  const lastMessageTs = queueMessage.response_context.slack?.message_ts;
567
- await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
801
+ await releaseSessionLock(threadId, lockToken, 'idle', poolIteration && deferred ? undefined : lastMessageTs);
568
802
  activeLocks.delete(threadId);
569
803
  }
570
804
  sessionLog.info(`[Loop] Complete. Iterations: ${iteration}`);
571
805
  }
572
806
  finally {
807
+ const heldToken = activeLocks.get(threadId);
808
+ if (heldToken) {
809
+ try {
810
+ await releaseSessionLock(threadId, heldToken, 'idle');
811
+ }
812
+ catch {
813
+ sessionLog.warn('[Loop] Could not release session lock; stale-lock recovery will retry');
814
+ }
815
+ }
573
816
  activeLoops.delete(threadId);
574
817
  activeLocks.delete(threadId);
575
818
  }
@@ -619,16 +862,8 @@ async function processMessage(received) {
619
862
  // Register brain so the maintenance cron knows about it.
620
863
  registerBrain(queueMessage);
621
864
  // Write raw message to inbox (spawnClaudeCode handles prompt formatting)
622
- writeMessage(kbPath, threadId, JSON.stringify({
623
- text: queueMessage.text,
624
- sender: queueMessage.sender,
625
- type: queueMessage.type,
626
- source: queueMessage.source,
627
- attachments: queueMessage.attachments,
628
- approval: queueMessage.approval,
629
- button: queueMessage.button,
630
- modal: queueMessage.modal,
631
- }));
865
+ const { botToken: _inboxToken, ...inboxTeamvibe } = queueMessage.teamvibe;
866
+ writeMessage(kbPath, threadId, JSON.stringify({ ...queueMessage, teamvibe: inboxTeamvibe }));
632
867
  sessionLog.info(`Wrote message to inbox`);
633
868
  // Delete SQS message immediately — it's now persisted in the inbox
634
869
  if (receiptHandle) {
@@ -699,7 +934,7 @@ function isAuthError(error) {
699
934
  message.includes('security token') ||
700
935
  message.includes('Auth failed'));
701
936
  }
702
- async function pollLoop() {
937
+ export async function pollLoop() {
703
938
  logger.info('Poll loop started');
704
939
  while (!shuttingDown) {
705
940
  try {
@@ -740,8 +975,35 @@ async function pollLoop() {
740
975
  continue;
741
976
  }
742
977
  logger.info(`Received ${messages.length} message(s) from SQS`);
743
- // Deduplicate: if multiple messages for the same thread, keep only the latest
744
- const { unique, duplicates } = deduplicateMessages(messages);
978
+ // Workspace jobs have no thread and bypass every channel/session path.
979
+ const channelMessages = [];
980
+ for (const msg of messages) {
981
+ if (isUsageProbe(msg.queueMessage)) {
982
+ const job = msg.queueMessage;
983
+ await handleUsageProbe(job, {
984
+ fetchPool: () => fetchSubscriptionPool({
985
+ pollerId: getPollerId() || '',
986
+ workspaceId: job.workspaceId,
987
+ accountId: job.accountId,
988
+ credentialVersion: job.credentialVersion,
989
+ requestId: job.requestId,
990
+ }, {
991
+ apiUrl: config.TEAMVIBE_API_URL,
992
+ pollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
993
+ }),
994
+ store: subscriptionUsageStore(),
995
+ cliPath: config.CLAUDE_CLI_PATH,
996
+ isAtCapacity,
997
+ emit: emitEvent,
998
+ log: line => logger.info(line),
999
+ });
1000
+ await deleteMessage(msg.receiptHandle);
1001
+ continue;
1002
+ }
1003
+ channelMessages.push(msg);
1004
+ }
1005
+ // Deduplicate real messages for the same thread, keeping the latest.
1006
+ const { unique, duplicates } = deduplicateMessages(channelMessages);
745
1007
  for (const dup of duplicates) {
746
1008
  logger.info(`Deleting duplicate message for thread ${dup.queueMessage.thread_id} (messageId: ${dup.messageId})`);
747
1009
  await deleteMessage(dup.receiptHandle);
@@ -0,0 +1,13 @@
1
+ export interface PoolDefer {
2
+ createdAt: number;
3
+ resumeAt: number;
4
+ resetAt?: string;
5
+ episodeId: string;
6
+ notice: 'pending' | 'claimed' | 'delivered' | 'ambiguous';
7
+ }
8
+ export declare function readPoolDefer(threadId: string): PoolDefer | undefined;
9
+ export declare function isPoolDeferred(threadId: string): boolean;
10
+ export declare function deferPool(threadId: string, eligibleAt: number, resetAt?: string): PoolDefer;
11
+ /** Claim durably before Slack I/O. Ambiguous sends are never blindly replayed. */
12
+ export declare function setPoolNotice(threadId: string, episodeId: string, notice: PoolDefer['notice']): boolean;
13
+ export declare function clearPoolDefer(threadId: string): void;
@@ -0,0 +1,81 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { config } from './config.js';
5
+ import { logger } from './logger.js';
6
+ import { POOL_RECHECK_MS } from './subscription-selection.js';
7
+ function pathFor(threadId) {
8
+ return join(config.TEAMVIBE_DATA_DIR, 'pool-defer', `${createHash('sha256').update(threadId).digest('hex')}.json`);
9
+ }
10
+ const warned = new Set();
11
+ export function readPoolDefer(threadId) {
12
+ const path = pathFor(threadId);
13
+ try {
14
+ const state = JSON.parse(readFileSync(path, 'utf8'));
15
+ if (!Number.isFinite(state.createdAt) || !Number.isFinite(state.resumeAt) || state.resumeAt <= state.createdAt || state.resumeAt > 253402300799000 || typeof state.episodeId !== 'string' || !['pending', 'claimed', 'delivered', 'ambiguous'].includes(state.notice))
16
+ throw new Error('Invalid defer');
17
+ warned.delete(path);
18
+ return state;
19
+ }
20
+ catch (error) {
21
+ if (error.code !== 'ENOENT' && !warned.has(path)) {
22
+ warned.add(path);
23
+ logger.warn('[PoolDefer] Unreadable marker treated as expired');
24
+ }
25
+ try {
26
+ rmSync(path, { recursive: true, force: true });
27
+ }
28
+ catch {
29
+ try {
30
+ renameSync(path, `${path}.invalid`);
31
+ }
32
+ catch { }
33
+ }
34
+ return undefined;
35
+ }
36
+ }
37
+ export function isPoolDeferred(threadId) {
38
+ const state = readPoolDefer(threadId);
39
+ if (!state)
40
+ return false;
41
+ const now = Date.now();
42
+ return now >= state.createdAt && now < state.resumeAt && state.resumeAt - now <= state.resumeAt - state.createdAt;
43
+ }
44
+ function write(threadId, state) {
45
+ const path = pathFor(threadId);
46
+ mkdirSync(join(config.TEAMVIBE_DATA_DIR, 'pool-defer'), { recursive: true, mode: 0o700 });
47
+ const tmp = `${path}.${randomUUID()}.tmp`;
48
+ try {
49
+ writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
50
+ renameSync(tmp, path);
51
+ }
52
+ finally {
53
+ try {
54
+ rmSync(tmp, { force: true });
55
+ }
56
+ catch { }
57
+ }
58
+ }
59
+ export function deferPool(threadId, eligibleAt, resetAt) {
60
+ const previous = readPoolDefer(threadId);
61
+ const now = Date.now();
62
+ const state = { createdAt: now, resumeAt: Number.isFinite(eligibleAt) && eligibleAt > now ? eligibleAt : now + POOL_RECHECK_MS, episodeId: previous?.episodeId ?? randomUUID(), notice: previous?.notice ?? 'pending', ...(resetAt ? { resetAt } : {}) };
63
+ write(threadId, state);
64
+ return state;
65
+ }
66
+ /** Claim durably before Slack I/O. Ambiguous sends are never blindly replayed. */
67
+ export function setPoolNotice(threadId, episodeId, notice) {
68
+ const state = readPoolDefer(threadId);
69
+ if (!state || state.episodeId !== episodeId)
70
+ return false;
71
+ write(threadId, { ...state, notice });
72
+ return true;
73
+ }
74
+ export function clearPoolDefer(threadId) {
75
+ try {
76
+ rmSync(pathFor(threadId), { recursive: true, force: true });
77
+ }
78
+ catch {
79
+ logger.warn('[PoolDefer] Completed episode cleanup will be retried');
80
+ }
81
+ }