@teamvibe/poller 0.8.2-rc.1 → 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 (43) 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 +307 -26
  9. package/dist/config-client.d.ts +2 -0
  10. package/dist/config-client.js +3 -0
  11. package/dist/config.d.ts +4 -0
  12. package/dist/config.js +1 -0
  13. package/dist/inbox-manager.d.ts +22 -1
  14. package/dist/inbox-manager.js +57 -3
  15. package/dist/inbox-watcher.d.ts +1 -1
  16. package/dist/inbox-watcher.js +42 -20
  17. package/dist/poller.js +262 -51
  18. package/dist/pool-defer.d.ts +13 -0
  19. package/dist/pool-defer.js +81 -0
  20. package/dist/pool-telemetry.d.ts +44 -0
  21. package/dist/pool-telemetry.js +107 -0
  22. package/dist/quota-pause.d.ts +16 -0
  23. package/dist/quota-pause.js +96 -0
  24. package/dist/session-store.d.ts +2 -2
  25. package/dist/session-store.js +6 -3
  26. package/dist/slack-client.d.ts +4 -1
  27. package/dist/slack-client.js +100 -11
  28. package/dist/subscription-pool-client.d.ts +32 -0
  29. package/dist/subscription-pool-client.js +139 -0
  30. package/dist/subscription-pool.d.ts +18 -0
  31. package/dist/subscription-pool.js +24 -0
  32. package/dist/subscription-runtime.d.ts +53 -0
  33. package/dist/subscription-runtime.js +74 -0
  34. package/dist/subscription-selection.d.ts +75 -0
  35. package/dist/subscription-selection.js +142 -0
  36. package/dist/subscription-usage-probe.d.ts +16 -0
  37. package/dist/subscription-usage-probe.js +106 -0
  38. package/dist/subscription-usage-store.d.ts +47 -0
  39. package/dist/subscription-usage-store.js +206 -0
  40. package/dist/types.d.ts +25 -9
  41. package/dist/worktree-manager.js +4 -0
  42. package/npm-shrinkwrap.json +78 -77
  43. package/package.json +1 -1
package/dist/poller.js CHANGED
@@ -1,9 +1,15 @@
1
+ import { poolRunTelemetry } from './pool-telemetry.js';
1
2
  import { config, POLLER_VERSION } from './config.js';
2
3
  import { logger, setLogContext } from './logger.js';
3
4
  import { pollMessages, deleteMessage, } from './sqs-poller.js';
4
- import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
5
+ import { spawnClaudeCode, spawnPoolClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
5
6
  import { sendSlackError, sendSlackMessage, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
6
7
  import { classifyFailedIteration } from './classify-failed-iteration.js';
8
+ import { fetchSecretsForSpawn } from './secrets-client.js';
9
+ import { PoolDeferredError, fetchRuntimePool, subscriptionUsageStore } from './subscription-runtime.js';
10
+ import { POOL_RECHECK_MS } from './subscription-selection.js';
11
+ import { isPoolDeferred, readPoolDefer, deferPool, setPoolNotice, clearPoolDefer } from './pool-defer.js';
12
+ import { isQuotaPaused, pauseForQuota, clearQuotaPauseAfterSuccess, QuotaPausedError } from './quota-pause.js';
7
13
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
8
14
  import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
9
15
  import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
@@ -14,7 +20,7 @@ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './main
14
20
  import { bootstrapTrackedBrains } from './brains-bootstrap.js';
15
21
  import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
16
22
  import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
17
- import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage } from './inbox-manager.js';
23
+ import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage, saveInboxContext, saveInboxRuntime, readInboxRuntime, clearInboxRuntime } from './inbox-manager.js';
18
24
  import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
19
25
  import { Cron } from 'croner';
20
26
  import { isControlMessage, handleControlMessage } from './control-handler.js';
@@ -239,6 +245,15 @@ export function endOfTurnFallbackText(hasSlackContext, result) {
239
245
  return text.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join(END_OF_TURN_SENTINEL_DISPLAY);
240
246
  }
241
247
  export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
248
+ if (isQuotaPaused() && !getPollerId())
249
+ return;
250
+ const initialPool = await fetchRuntimePool(queueMessage);
251
+ if (initialPool.kind === 'no_accounts_configured') {
252
+ if (isQuotaPaused())
253
+ return;
254
+ }
255
+ else if (isPoolDeferred(threadId))
256
+ return;
242
257
  if (activeLoops.has(threadId)) {
243
258
  sessionLog.info(`Loop already running for thread ${threadId}, skipping`);
244
259
  return;
@@ -266,6 +281,34 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
266
281
  activeLocks.set(threadId, lockToken);
267
282
  }
268
283
  let currentSessionId = session.session_id || undefined;
284
+ let currentPin = session.subscription_pin;
285
+ let deferred = false;
286
+ let poolIteration = false;
287
+ const retryCompletedBookkeeping = async (state, liveCompletion = false) => {
288
+ if (!state.completed)
289
+ return true;
290
+ const writes = [];
291
+ // A live run may expire evidence after clock rollback; replaying an old
292
+ // completion must preserve observations produced by newer concurrent runs.
293
+ if (state.completed.rateLimitInfo)
294
+ writes.push(subscriptionUsageStore().recordPassive(state.pin, state.completed.rateLimitInfo, state.completed.observedAt, liveCompletion ? { expireFutureObservations: true } : undefined));
295
+ writes.push(lockToken ? updateSessionId(threadId, lockToken, state.sessionId, state.pin) : Promise.reject(new Error('Missing lock')));
296
+ const outcomes = await Promise.allSettled(writes);
297
+ if (outcomes.some(outcome => outcome.status === 'rejected' || outcome.value === false)) {
298
+ sessionLog.warn('[Loop] Inference completed; pool bookkeeping failed and will retry without running the agent');
299
+ try {
300
+ saveInboxRuntime(kbPath, threadId, { ...state, completed: { ...state.completed, retryAt: Date.now() + POOL_RECHECK_MS } });
301
+ }
302
+ catch {
303
+ sessionLog.warn('[Loop] Could not save bookkeeping retry; completion remains recorded');
304
+ }
305
+ return false;
306
+ }
307
+ currentSessionId = state.sessionId;
308
+ currentPin = state.pin;
309
+ clearInboxRuntime(kbPath, threadId);
310
+ return true;
311
+ };
269
312
  // PR#308 round-3 review (teamvibe.ai#73): `shuttingDown` (declared below,
270
313
  // referenced here via the same forward-reference pattern already used by
271
314
  // pollLoop()'s `while (!shuttingDown)`) gates every new iteration of this
@@ -285,6 +328,24 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
285
328
  // that the existing `if (!message) break` / `if (!hasUnprocessed(...))
286
329
  // break` exits already use, so lock release is unaffected.
287
330
  while (!shuttingDown) {
331
+ // Dispatch before ANY pool state/context/model preparation. The explicit
332
+ // no-pool outcome runs the original spawn and #408 pause gates.
333
+ const pool = iteration === 0 ? initialPool : await fetchRuntimePool(queueMessage);
334
+ poolIteration = pool.kind !== 'no_accounts_configured';
335
+ if (!poolIteration && isQuotaPaused())
336
+ break;
337
+ if (poolIteration) {
338
+ const completion = readInboxRuntime(kbPath, threadId);
339
+ if (completion?.completed) {
340
+ // Completed prompts never enter pickNextMessage/spawn again, even
341
+ // when the usage store or the session pin needs a later repair.
342
+ markProcessed(kbPath, threadId, completion.filename);
343
+ if (Date.now() >= completion.completed.observedAt && Date.now() < completion.completed.retryAt || !await retryCompletedBookkeeping(completion)) {
344
+ deferred = hasUnprocessed(kbPath, threadId);
345
+ break;
346
+ }
347
+ }
348
+ }
288
349
  const message = pickNextMessage(kbPath, threadId);
289
350
  if (!message) {
290
351
  sessionLog.info(`[Loop] No more messages for thread ${threadId}`);
@@ -294,22 +355,91 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
294
355
  const isFirstMessage = session.message_count === 1 && iteration === 1;
295
356
  sessionLog.info(`[Loop] Iteration #${iteration} — processing: ${message.filename}`);
296
357
  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;
358
+ let inboxMsg = queueMessage;
359
+ let deliveryMsg = queueMessage;
360
+ let hasSlackContext = Boolean(queueMessage.response_context.slack?.channel && queueMessage.response_context.slack?.message_ts);
361
+ let typing = !poolIteration && hasSlackContext ? startTypingIndicator(queueMessage) : undefined;
362
+ const retainedRuntime = readInboxRuntime(kbPath, threadId);
363
+ let quotaDeferEpisodes = Math.max(retainedRuntime?.filename === message.filename ? retainedRuntime.quotaDeferEpisodes ?? 0 : 0, readPoolDefer(threadId) ? 1 : 0);
364
+ let quotaPaused = false;
302
365
  let succeeded = false;
303
366
  let lastExitCode = null;
304
367
  let lastKilledByPoller;
305
368
  try {
306
369
  // Reconstruct queue message from stored inbox data
307
370
  const storedData = JSON.parse(message.content);
308
- const inboxMsg = {
371
+ inboxMsg = {
309
372
  ...queueMessage,
310
373
  ...storedData,
374
+ // Durable inbox routing omits credentials. Preserve live metadata
375
+ // missing from older payloads, and always use the live bot token.
376
+ teamvibe: { ...queueMessage.teamvibe, ...storedData.teamvibe, botToken: queueMessage.teamvibe.botToken },
311
377
  };
312
- const result = await spawnClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
378
+ // Legacy delivery uses the original routing, just as on main.
379
+ let result;
380
+ if (!poolIteration) {
381
+ result = await spawnClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
382
+ }
383
+ else {
384
+ if (storedData.teamvibe && (storedData.teamvibe.workspaceId !== queueMessage.teamvibe.workspaceId || storedData.teamvibe.channelId !== queueMessage.teamvibe.channelId))
385
+ throw new PoolDeferredError();
386
+ if (!queueMessage.teamvibe.botToken && queueMessage.response_context.slack) {
387
+ const secrets = await fetchSecretsForSpawn({ pollerId: getPollerId() || '', workspaceId: queueMessage.teamvibe.workspaceId, channelId: queueMessage.teamvibe.channelId });
388
+ const token = secrets.env.unwrap()['SLACK_BOT_TOKEN'];
389
+ queueMessage = { ...queueMessage, teamvibe: { ...queueMessage.teamvibe, botToken: typeof token === 'string' ? token : '' } };
390
+ inboxMsg = { ...inboxMsg, teamvibe: { ...inboxMsg.teamvibe, botToken: queueMessage.teamvibe.botToken } };
391
+ }
392
+ deliveryMsg = inboxMsg;
393
+ hasSlackContext = Boolean(inboxMsg.response_context.slack?.channel && inboxMsg.response_context.slack?.message_ts);
394
+ typing = hasSlackContext ? startTypingIndicator(inboxMsg) : undefined;
395
+ try {
396
+ saveInboxContext(kbPath, inboxMsg);
397
+ }
398
+ catch {
399
+ throw new PoolDeferredError();
400
+ }
401
+ const savedRuntime = readInboxRuntime(kbPath, threadId);
402
+ if (savedRuntime?.filename === message.filename && savedRuntime.pin.workspaceId === queueMessage.teamvibe.workspaceId && (!currentPin || currentPin.accountId === savedRuntime.pin.accountId)) {
403
+ currentSessionId = savedRuntime.sessionId;
404
+ currentPin = savedRuntime.pin;
405
+ }
406
+ result = await spawnPoolClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing, {
407
+ pool,
408
+ quotaDeferEpisodes,
409
+ attempts: savedRuntime?.filename === message.filename ? savedRuntime.attempts : undefined,
410
+ pin: currentPin ? {
411
+ ...currentPin,
412
+ resumePending: (savedRuntime?.filename === message.filename || !savedRuntime && currentPin.inboxFilename === message.filename) && currentPin.resumePending === true,
413
+ sawSendMessageCall: (savedRuntime?.filename === message.filename || !savedRuntime && currentPin.inboxFilename === message.filename) && currentPin.sawSendMessageCall === true,
414
+ } : undefined,
415
+ shouldStop: () => shuttingDown,
416
+ complete: async (id, pin, attempts, rateLimitInfo) => {
417
+ const state = { sessionId: id, pin, filename: message.filename, attempts, quotaDeferEpisodes, completed: { observedAt: Date.now(), retryAt: Date.now(), rateLimitInfo } };
418
+ // This completion record precedes usage/DDB bookkeeping. Inbox
419
+ // deletion also happens before those writes; no defer on failure.
420
+ try {
421
+ saveInboxRuntime(kbPath, threadId, state);
422
+ }
423
+ catch {
424
+ sessionLog.warn('[Loop] Could not save completion record; removing completed inbox before bookkeeping');
425
+ }
426
+ markProcessed(kbPath, threadId, message.filename);
427
+ currentSessionId = id;
428
+ currentPin = pin;
429
+ succeeded = true;
430
+ clearPoolDefer(threadId);
431
+ await retryCompletedBookkeeping(state, true);
432
+ },
433
+ persist: async (id, pin, attempts) => {
434
+ pin = { ...pin, inboxFilename: message.filename };
435
+ if (!lockToken || await updateSessionId(threadId, lockToken, id, pin) === false)
436
+ throw new PoolDeferredError();
437
+ currentSessionId = id;
438
+ currentPin = pin;
439
+ saveInboxRuntime(kbPath, threadId, { sessionId: id, pin, filename: message.filename, attempts, quotaDeferEpisodes });
440
+ },
441
+ });
442
+ }
313
443
  typing?.stop();
314
444
  if (result.newSessionId) {
315
445
  // Update local state first — DynamoDB writes may fail (expired token,
@@ -318,7 +448,9 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
318
448
  currentSessionId = result.newSessionId;
319
449
  if (lockToken) {
320
450
  try {
321
- await updateSessionId(threadId, lockToken, result.newSessionId);
451
+ await (poolIteration
452
+ ? updateSessionId(threadId, lockToken, result.newSessionId, currentPin)
453
+ : updateSessionId(threadId, lockToken, result.newSessionId));
322
454
  }
323
455
  catch (err) {
324
456
  sessionLog.error(`[Loop] Failed to persist new session ID to DynamoDB: ${err instanceof Error ? err.message : err}`);
@@ -327,29 +459,33 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
327
459
  }
328
460
  if (result.metrics) {
329
461
  sessionLog.info(`[Loop] Iteration #${iteration} complete — ` +
330
- `cost: $${result.metrics.total_cost_usd.toFixed(4)}, ` +
462
+ `cost: $${result.metrics.total_cost_usd?.toFixed(4) ?? 'unknown'}, ` +
331
463
  `duration: ${result.metrics.duration_ms}ms`);
332
464
  }
333
465
  if (result.success) {
334
466
  succeeded = true;
467
+ clearQuotaPauseAfterSuccess();
335
468
  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,
469
+ if (result.metrics || result.poolAttempts) {
470
+ emitEvent('session.completed', deliveryMsg.teamvibe.channelId, {
471
+ threadId: deliveryMsg.thread_id,
472
+ ...(result.metrics?.context_tokens != null ? { context_tokens: result.metrics.context_tokens } : {}),
473
+ ...(result.metrics?.context_window != null ? { context_window: result.metrics.context_window } : {}),
474
+ total_cost_usd: result.metrics?.total_cost_usd,
475
+ input_tokens: result.metrics?.input_tokens,
476
+ output_tokens: result.metrics?.output_tokens,
477
+ cache_read_input_tokens: result.metrics?.cache_read_input_tokens,
478
+ cache_creation_input_tokens: result.metrics?.cache_creation_input_tokens,
479
+ duration_ms: result.metrics?.duration_ms,
480
+ duration_api_ms: result.metrics?.duration_api_ms,
481
+ num_turns: result.metrics?.num_turns,
482
+ model: result.metrics?.model,
348
483
  // Which config layer actually chose the `--model` flag
349
484
  // (settings.json / poller / workspace / channel / env-default)
350
485
  // — teamvibe.ai#235 PR2. Distinct from `model` above, which is
351
486
  // Claude CLI's own report of the model it used.
352
487
  modelSource: result.modelSource,
488
+ ...(result.poolAttempts ? { workspaceId: deliveryMsg.teamvibe.workspaceId, ...poolRunTelemetry(result.poolAttempts, quotaDeferEpisodes) } : {}),
353
489
  });
354
490
  }
355
491
  // teamvibe.ai#284 (poller-brain#235): end-of-turn guard. A
@@ -363,23 +499,35 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
363
499
  if (fallbackText) {
364
500
  sessionLog.warn(`[Loop] Iteration #${iteration}: no send_message call detected, delivering trailing assistant text via fallback`);
365
501
  try {
366
- await sendSlackMessage(queueMessage, fallbackText);
502
+ await sendSlackMessage(deliveryMsg, fallbackText);
367
503
  }
368
504
  catch (err) {
369
505
  sessionLog.error(`[Loop] End-of-turn fallback delivery failed: ${err instanceof Error ? err.message : err}`);
370
506
  }
371
507
  emitError('session', 'End-of-turn guard: delivered assistant text via fallback (no send_message tool call detected)', {
372
508
  severity: 'warn',
373
- channelId: queueMessage.teamvibe.channelId,
374
- context: { threadId: queueMessage.thread_id },
509
+ channelId: deliveryMsg.teamvibe.channelId,
510
+ context: { threadId: deliveryMsg.thread_id },
375
511
  });
376
512
  }
377
- if (queueMessage.teamvibe.brain?.brainId) {
378
- await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId, queueMessage.teamvibe.workspaceId);
513
+ if (deliveryMsg.teamvibe.brain?.brainId) {
514
+ await pushBrainChanges(kbPath, deliveryMsg.teamvibe.brain.brainId, deliveryMsg.teamvibe.workspaceId);
379
515
  }
380
- await postMaintenanceReports(kbPath, queueMessage.teamvibe.channelId);
516
+ await postMaintenanceReports(kbPath, deliveryMsg.teamvibe.channelId);
381
517
  }
382
518
  else {
519
+ // Set before Slack I/O: a delivery failure must not retry or discard
520
+ // the saved request. Kill intent still wins over incidental quota.
521
+ const decision = classifyFailedIteration(result);
522
+ quotaPaused = decision.kind === 'quota';
523
+ const notifyQuota = decision.kind === 'quota'
524
+ ? pauseForQuota(decision.quota, kbPath, threadId)
525
+ : false;
526
+ if (quotaPaused && !notifyQuota) {
527
+ // Another in-flight request already owns the retained marker.
528
+ // Queue this one so the post-pause sweep can find both requests.
529
+ requeueMessage(kbPath, threadId, message.filename);
530
+ }
383
531
  lastExitCode = result.exitCode;
384
532
  lastKilledByPoller = result.killedByPoller;
385
533
  sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
@@ -399,27 +547,70 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
399
547
  if (lastKilledByPoller !== 'shutdown') {
400
548
  emitError('session', `Iteration #${iteration} failed for ${message.filename}`, {
401
549
  severity: 'warn',
402
- channelId: queueMessage.teamvibe.channelId,
550
+ channelId: deliveryMsg.teamvibe.channelId,
403
551
  context: { threadId, filename: message.filename, iteration, reason: describeFailureReason(lastKilledByPoller, lastExitCode) },
404
552
  });
405
553
  }
406
- if (hasSlackContext) {
407
- const decision = classifyFailedIteration(result);
408
- if (decision.kind === 'neutral') {
409
- await sendSlackMessage(queueMessage, decision.text);
554
+ if (hasSlackContext && (decision.kind !== 'quota' || notifyQuota)) {
555
+ if (decision.kind === 'neutral' || decision.kind === 'quota') {
556
+ await sendSlackMessage(deliveryMsg, decision.text);
410
557
  }
411
558
  else {
412
- await sendSlackError(queueMessage, decision.text);
559
+ await sendSlackError(deliveryMsg, decision.text);
413
560
  }
414
- await addReaction(queueMessage, decision.reaction);
561
+ await addReaction(deliveryMsg, decision.reaction);
415
562
  }
416
563
  }
417
564
  }
418
565
  catch (error) {
419
566
  typing?.stop();
420
- sessionLog.error(`[Loop] Error in iteration #${iteration}: ${error instanceof Error ? error.message : error}`);
567
+ if (poolIteration && error instanceof PoolDeferredError) {
568
+ quotaPaused = true;
569
+ deferred = true;
570
+ const pause = deferPool(threadId, error.eligibleAt, error.resetAt);
571
+ const retained = readInboxRuntime(kbPath, threadId);
572
+ // Rechecks of one exhaustion episode do not inflate the episode count.
573
+ quotaDeferEpisodes = Math.max(1, quotaDeferEpisodes);
574
+ if (retained)
575
+ saveInboxRuntime(kbPath, threadId, { ...retained, quotaDeferEpisodes });
576
+ 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 });
577
+ if (hasSlackContext && pause.notice !== 'delivered') {
578
+ const reconcile = pause.notice === 'claimed' || pause.notice === 'ambiguous';
579
+ if (!reconcile && !setPoolNotice(threadId, pause.episodeId, 'claimed'))
580
+ throw new PoolDeferredError(error.eligibleAt);
581
+ try {
582
+ await sendSlackMessage(deliveryMsg, `Claude subscription pool unavailable. ${pause.resetAt ? `Earliest reset: ${pause.resetAt}.` : 'Reset time unavailable.'} Your message is saved. The next inbox sweep at or after ${new Date(pause.resumeAt).toISOString()} will resume pending work.`, { clientMessageId: pause.episodeId, ...(reconcile ? { reconcile: true } : {}) });
583
+ setPoolNotice(threadId, pause.episodeId, 'delivered');
584
+ }
585
+ catch (deliveryError) {
586
+ const rejected = deliveryError?.data?.ok === false;
587
+ setPoolNotice(threadId, pause.episodeId, rejected && !reconcile ? 'pending' : 'ambiguous');
588
+ sessionLog.warn('[Loop] Pool notice delivery uncertain; pending work retained');
589
+ }
590
+ }
591
+ }
592
+ else if (error instanceof QuotaPausedError) {
593
+ // No CLI ran: restore queued work without notices or retry counting.
594
+ requeueMessage(kbPath, threadId, message.filename);
595
+ quotaPaused = true;
596
+ }
597
+ else {
598
+ sessionLog.error(`[Loop] Error in iteration #${iteration}: ${error instanceof Error ? error.message : error}`);
599
+ }
600
+ }
601
+ if (quotaPaused) {
602
+ // The durable account pause gates incoming traffic and watcher sweeps.
603
+ // Keep the original in processing/ so it resumes before queued work.
604
+ deferred = true;
605
+ sessionLog.info(`[Loop] Quota pause active; leaving ${message.filename} pending`);
606
+ break;
421
607
  }
422
608
  if (succeeded) {
609
+ if (poolIteration) {
610
+ clearPoolDefer(threadId);
611
+ if (!readInboxRuntime(kbPath, threadId)?.completed)
612
+ clearInboxRuntime(kbPath, threadId);
613
+ }
423
614
  markProcessed(kbPath, threadId, message.filename);
424
615
  }
425
616
  else {
@@ -470,6 +661,21 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
470
661
  const reasonLabel = describeFailureReason(lastKilledByPoller, lastExitCode);
471
662
  sessionLog.error(`[Loop] Message ${message.filename} failed ${failures} times (last reason: ${reasonLabel}), giving up`);
472
663
  markFailed(kbPath, threadId, message.filename);
664
+ if (poolIteration) {
665
+ clearInboxRuntime(kbPath, threadId);
666
+ clearPoolDefer(threadId);
667
+ if (currentPin) {
668
+ const { inboxFilename: _filename, ...pin } = currentPin;
669
+ currentPin = { ...pin, resumePending: false, sawSendMessageCall: false };
670
+ try {
671
+ if (lockToken && currentSessionId)
672
+ await updateSessionId(threadId, lockToken, currentSessionId, currentPin);
673
+ }
674
+ catch {
675
+ sessionLog.warn('[Loop] Terminal failure cleared local continuation; stale pin is fenced by inbox filename');
676
+ }
677
+ }
678
+ }
473
679
  if (hasSlackContext) {
474
680
  // tv#320 DevGuru round-2 review (BLOCKING): this whole else-if
475
681
  // chain sits structurally after the per-iteration try/catch
@@ -484,16 +690,16 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
484
690
  // rate-limit/auth/network error here needs the same explicit
485
691
  // try/catch the end-of-turn-guard fallback above already uses.
486
692
  try {
487
- await sendSlackError(queueMessage, `Giving up after ${failures} attempts (last reason: ${reasonLabel}) — this message was not processed. Please send it again.`);
693
+ await sendSlackError(deliveryMsg, `Giving up after ${failures} attempts (last reason: ${reasonLabel}) — this message was not processed. Please send it again.`);
488
694
  }
489
695
  catch (err) {
490
696
  sessionLog.error(`[Loop] Give-up notice delivery failed: ${err instanceof Error ? err.message : err}`);
491
697
  }
492
- await addReaction(queueMessage, 'x');
698
+ await addReaction(deliveryMsg, 'x');
493
699
  }
494
700
  emitError('session', `Message ${message.filename} failed ${failures} times, giving up`, {
495
701
  severity: 'error',
496
- channelId: queueMessage.teamvibe.channelId,
702
+ channelId: deliveryMsg.teamvibe.channelId,
497
703
  context: { threadId, filename: message.filename, reason: reasonLabel },
498
704
  });
499
705
  }
@@ -514,6 +720,8 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
514
720
  }
515
721
  }
516
722
  else {
723
+ if (poolIteration && currentPin?.resumePending)
724
+ deferred = true;
517
725
  // PR#308 round-5 review (teamvibe.ai#73): pickNextMessage() moves a
518
726
  // message's file to processing/ as soon as it's picked up, not once
519
727
  // it completes — so a message killed here by shutdown is left
@@ -562,14 +770,25 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
562
770
  }
563
771
  sessionLog.info(`[Loop] More messages found, continuing...`);
564
772
  }
773
+ if (poolIteration && currentPin?.resumePending && hasUnprocessed(kbPath, threadId))
774
+ deferred = true;
565
775
  if (lockToken) {
566
776
  const lastMessageTs = queueMessage.response_context.slack?.message_ts;
567
- await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
777
+ await releaseSessionLock(threadId, lockToken, 'idle', poolIteration && deferred ? undefined : lastMessageTs);
568
778
  activeLocks.delete(threadId);
569
779
  }
570
780
  sessionLog.info(`[Loop] Complete. Iterations: ${iteration}`);
571
781
  }
572
782
  finally {
783
+ const heldToken = activeLocks.get(threadId);
784
+ if (heldToken) {
785
+ try {
786
+ await releaseSessionLock(threadId, heldToken, 'idle');
787
+ }
788
+ catch {
789
+ sessionLog.warn('[Loop] Could not release session lock; stale-lock recovery will retry');
790
+ }
791
+ }
573
792
  activeLoops.delete(threadId);
574
793
  activeLocks.delete(threadId);
575
794
  }
@@ -619,16 +838,8 @@ async function processMessage(received) {
619
838
  // Register brain so the maintenance cron knows about it.
620
839
  registerBrain(queueMessage);
621
840
  // 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
- }));
841
+ const { botToken: _inboxToken, ...inboxTeamvibe } = queueMessage.teamvibe;
842
+ writeMessage(kbPath, threadId, JSON.stringify({ ...queueMessage, teamvibe: inboxTeamvibe }));
632
843
  sessionLog.info(`Wrote message to inbox`);
633
844
  // Delete SQS message immediately — it's now persisted in the inbox
634
845
  if (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
+ }
@@ -0,0 +1,44 @@
1
+ import type { SubscriptionAccount } from './subscription-pool-client.js';
2
+ import { type StoredAccountUsage, type SubscriptionUsageStore } from './subscription-usage-store.js';
3
+ import type { PoolAttempt } from './subscription-runtime.js';
4
+ import type { SessionMetrics, TeamVibeQueueMessage } from './types.js';
5
+ /** Explicit transport DTO: never serialize a pool account/credential envelope. */
6
+ export declare function accountUsageTelemetry(account: SubscriptionAccount, state: StoredAccountUsage): {
7
+ reactive: {
8
+ observedAt: number;
9
+ window: import("./claude-rate-limit.js").ClaudeQuotaWindow | undefined;
10
+ scope: string;
11
+ resetAt: number | undefined;
12
+ eligibleAt: number;
13
+ }[];
14
+ lastRateLimitInfo?: import("./claude-rate-limit.js").ClaudeRateLimitInfo | undefined;
15
+ probeStatus?: import("./subscription-usage-store.js").ProbeStatus | undefined;
16
+ probeObservedAt?: number | undefined;
17
+ usage?: import("./subscription-selection.js").UsageObservation | undefined;
18
+ accountId: string;
19
+ accountLabel: string;
20
+ reportedAt: number;
21
+ };
22
+ export declare function emitCandidateUsage(msg: TeamVibeQueueMessage, accounts: readonly SubscriptionAccount[], store: SubscriptionUsageStore): Promise<void>;
23
+ export declare function poolRunTelemetry(attempts: readonly PoolAttempt[], quotaDeferEpisodes?: number): {
24
+ chainLength: number;
25
+ switchedAccounts: boolean;
26
+ quotaDeferEpisodes: number;
27
+ attempts: {
28
+ accountId: string;
29
+ accountLabel: string;
30
+ sessionId: string | undefined;
31
+ model: string | undefined;
32
+ success: boolean;
33
+ exitCode: number | null;
34
+ observedAt: number | undefined;
35
+ quota: import("./claude-quota.js").ClaudeQuotaFailure | undefined;
36
+ metrics: SessionMetrics | undefined;
37
+ lastRateLimitInfo: import("./claude-rate-limit.js").ClaudeRateLimitInfo | undefined;
38
+ }[];
39
+ accountId?: string | undefined;
40
+ accountLabel?: string | undefined;
41
+ model?: string | undefined;
42
+ observedAt?: number | undefined;
43
+ lastRateLimitInfo?: import("./claude-rate-limit.js").ClaudeRateLimitInfo | undefined;
44
+ };