@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.
- package/dist/classify-failed-iteration.d.ts +7 -0
- package/dist/classify-failed-iteration.js +12 -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 +323 -25
- package/dist/config-client.d.ts +2 -0
- package/dist/config-client.js +3 -0
- package/dist/event-queue.js +2 -2
- 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.d.ts +1 -0
- package/dist/poller.js +316 -54
- package/dist/pool-defer.d.ts +13 -0
- package/dist/pool-defer.js +81 -0
- package/dist/pool-logging.d.ts +18 -0
- package/dist/pool-logging.js +79 -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 +19 -1
- package/dist/subscription-pool-client.d.ts +35 -0
- package/dist/subscription-pool-client.js +143 -0
- package/dist/subscription-pool.d.ts +21 -0
- package/dist/subscription-pool.js +30 -0
- package/dist/subscription-runtime.d.ts +57 -0
- package/dist/subscription-runtime.js +126 -0
- package/dist/subscription-selection.d.ts +77 -0
- package/dist/subscription-selection.js +157 -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 +50 -0
- package/dist/subscription-usage-store.js +223 -0
- package/dist/types.d.ts +35 -10
- package/dist/usage-limit-messages.d.ts +22 -0
- package/dist/usage-limit-messages.js +44 -0
- package/dist/usage-probe-job.d.ts +17 -0
- package/dist/usage-probe-job.js +186 -0
- 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,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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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',
|
|
338
|
-
threadId:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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:
|
|
374
|
-
context: { threadId:
|
|
527
|
+
channelId: deliveryMsg.teamvibe.channelId,
|
|
528
|
+
context: { threadId: deliveryMsg.thread_id },
|
|
375
529
|
});
|
|
376
530
|
}
|
|
377
|
-
if (
|
|
378
|
-
await pushBrainChanges(kbPath,
|
|
531
|
+
if (deliveryMsg.teamvibe.brain?.brainId) {
|
|
532
|
+
await pushBrainChanges(kbPath, deliveryMsg.teamvibe.brain.brainId, deliveryMsg.teamvibe.workspaceId);
|
|
379
533
|
}
|
|
380
|
-
await postMaintenanceReports(kbPath,
|
|
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:
|
|
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
|
-
|
|
408
|
-
|
|
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(
|
|
577
|
+
await sendSlackError(deliveryMsg, decision.text);
|
|
413
578
|
}
|
|
414
|
-
await addReaction(
|
|
579
|
+
await addReaction(deliveryMsg, decision.reaction);
|
|
415
580
|
}
|
|
416
581
|
}
|
|
417
582
|
}
|
|
418
583
|
catch (error) {
|
|
419
584
|
typing?.stop();
|
|
420
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
}));
|
|
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
|
-
//
|
|
744
|
-
const
|
|
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
|
+
}
|