@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.
- package/dist/classify-failed-iteration.d.ts +7 -0
- package/dist/classify-failed-iteration.js +8 -0
- package/dist/claude-quota.d.ts +15 -0
- package/dist/claude-quota.js +188 -0
- package/dist/claude-rate-limit.d.ts +19 -0
- package/dist/claude-rate-limit.js +44 -0
- package/dist/claude-spawner.d.ts +15 -0
- package/dist/claude-spawner.js +307 -26
- package/dist/config-client.d.ts +2 -0
- package/dist/config-client.js +3 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/inbox-manager.d.ts +22 -1
- package/dist/inbox-manager.js +57 -3
- package/dist/inbox-watcher.d.ts +1 -1
- package/dist/inbox-watcher.js +42 -20
- package/dist/poller.js +262 -51
- package/dist/pool-defer.d.ts +13 -0
- package/dist/pool-defer.js +81 -0
- package/dist/pool-telemetry.d.ts +44 -0
- package/dist/pool-telemetry.js +107 -0
- package/dist/quota-pause.d.ts +16 -0
- package/dist/quota-pause.js +96 -0
- package/dist/session-store.d.ts +2 -2
- package/dist/session-store.js +6 -3
- package/dist/slack-client.d.ts +4 -1
- package/dist/slack-client.js +100 -11
- package/dist/subscription-pool-client.d.ts +32 -0
- package/dist/subscription-pool-client.js +139 -0
- package/dist/subscription-pool.d.ts +18 -0
- package/dist/subscription-pool.js +24 -0
- package/dist/subscription-runtime.d.ts +53 -0
- package/dist/subscription-runtime.js +74 -0
- package/dist/subscription-selection.d.ts +75 -0
- package/dist/subscription-selection.js +142 -0
- package/dist/subscription-usage-probe.d.ts +16 -0
- package/dist/subscription-usage-probe.js +106 -0
- package/dist/subscription-usage-store.d.ts +47 -0
- package/dist/subscription-usage-store.js +206 -0
- package/dist/types.d.ts +25 -9
- package/dist/worktree-manager.js +4 -0
- package/npm-shrinkwrap.json +78 -77
- 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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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',
|
|
338
|
-
threadId:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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:
|
|
374
|
-
context: { threadId:
|
|
509
|
+
channelId: deliveryMsg.teamvibe.channelId,
|
|
510
|
+
context: { threadId: deliveryMsg.thread_id },
|
|
375
511
|
});
|
|
376
512
|
}
|
|
377
|
-
if (
|
|
378
|
-
await pushBrainChanges(kbPath,
|
|
513
|
+
if (deliveryMsg.teamvibe.brain?.brainId) {
|
|
514
|
+
await pushBrainChanges(kbPath, deliveryMsg.teamvibe.brain.brainId, deliveryMsg.teamvibe.workspaceId);
|
|
379
515
|
}
|
|
380
|
-
await postMaintenanceReports(kbPath,
|
|
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:
|
|
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
|
-
|
|
408
|
-
|
|
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(
|
|
559
|
+
await sendSlackError(deliveryMsg, decision.text);
|
|
413
560
|
}
|
|
414
|
-
await addReaction(
|
|
561
|
+
await addReaction(deliveryMsg, decision.reaction);
|
|
415
562
|
}
|
|
416
563
|
}
|
|
417
564
|
}
|
|
418
565
|
catch (error) {
|
|
419
566
|
typing?.stop();
|
|
420
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
623
|
-
|
|
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
|
+
};
|