@teamvibe/poller 0.8.7-rc.1 → 0.8.7

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.
@@ -4,6 +4,8 @@ import { logger } from './logger.js';
4
4
  const MAX_QUEUE_SIZE = 200;
5
5
  const FLUSH_INTERVAL_MS = 30_000;
6
6
  const DEDUP_WINDOW_MS = 60_000;
7
+ const rejectedWarnings = new Map();
8
+ const REJECTION_WARN_INTERVAL_MS = 3600_000;
7
9
  let queue = [];
8
10
  let flushTimer = null;
9
11
  let isFlushing = false;
@@ -60,6 +62,15 @@ async function flushOne(event) {
60
62
  logger.info(`[Pool] usage probe request=${event.metadata['requestId']} completion downgraded for legacy platform`);
61
63
  response = await post();
62
64
  }
65
+ if ([400, 404, 422].includes(response.status)) {
66
+ const now = Date.now();
67
+ const previous = rejectedWarnings.get(event.eventType);
68
+ if (previous === undefined || now - previous >= REJECTION_WARN_INTERVAL_MS) {
69
+ rejectedWarnings.set(event.eventType, now);
70
+ logger.warn(`[EventQueue] ${event.eventType} rejected with HTTP ${response.status}; dropping event`);
71
+ }
72
+ return true;
73
+ }
63
74
  return response.ok;
64
75
  }
65
76
  catch {
@@ -1,3 +1,4 @@
1
+ import type { PoolDefer } from './pool-defer.js';
1
2
  import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
2
3
  import type { PoolAttempt } from './subscription-runtime.js';
3
4
  import type { TeamVibeQueueMessage, SubscriptionPin } from './types.js';
@@ -81,3 +82,6 @@ export declare function discardHandledInboxDrops(brainPath: string, threadId: st
81
82
  export declare function restartInboxInDirectory(source: string, target: string, threadId: string): void;
82
83
  /** Preserve unroutable work for operator review and remove it from replay queues. */
83
84
  export declare function quarantineInbox(brainPath: string, threadId: string, includeFailed?: boolean): void;
85
+ /** Only original queue deliveries carry inbound identity; synthetic drops never wake holds. */
86
+ export declare function inboxQueueMessageIds(brainPath: string, threadId: string): string[];
87
+ export declare function hasInboxMessageAfter(brainPath: string, threadId: string, pause: Pick<PoolDefer, 'handledInboundIds'>): boolean;
@@ -305,3 +305,32 @@ export function quarantineInbox(brainPath, threadId, includeFailed = false) {
305
305
  renameSync(join(root, name), join(quarantine, `${Date.now()}-${name}`));
306
306
  }
307
307
  }
308
+ /** Only original queue deliveries carry inbound identity; synthetic drops never wake holds. */
309
+ export function inboxQueueMessageIds(brainPath, threadId) {
310
+ const ids = new Set();
311
+ for (const subdir of [PROCESSING_DIR, NEW_DIR]) {
312
+ const directory = join(getInboxPath(brainPath, threadId), subdir);
313
+ let names;
314
+ try {
315
+ names = readdirSync(directory);
316
+ }
317
+ catch {
318
+ continue;
319
+ }
320
+ for (const name of names) {
321
+ if (!name.endsWith('.txt'))
322
+ continue;
323
+ try {
324
+ const message = JSON.parse(readFileSync(join(directory, name), 'utf8'));
325
+ if (message.executionOrigin === 'queue' && message.thread_id === threadId && typeof message.id === 'string' && message.id)
326
+ ids.add(message.id);
327
+ }
328
+ catch { /* Plain background drops and concurrent moves have no inbound identity. */ }
329
+ }
330
+ }
331
+ return [...ids];
332
+ }
333
+ export function hasInboxMessageAfter(brainPath, threadId, pause) {
334
+ const handled = new Set(pause.handledInboundIds ?? []);
335
+ return inboxQueueMessageIds(brainPath, threadId).some(id => !handled.has(id));
336
+ }
@@ -1,7 +1,7 @@
1
1
  import { executionDirectory } from './brain-directory.js';
2
- import { discardHandledInboxDrops, markProcessed, quarantineInbox, readInboxChannelContext, readInboxContext, readInboxRuntime, restartInboxInDirectory } from './inbox-manager.js';
2
+ import { hasInboxMessageAfter, discardHandledInboxDrops, markProcessed, quarantineInbox, readInboxChannelContext, readInboxContext, readInboxRuntime, restartInboxInDirectory } from './inbox-manager.js';
3
3
  import { listThreadRoutings, readThreadRouting, removeThreadRouting, threadRoutingKey } from './thread-routing.js';
4
- import { isPoolDeferred, readPoolDefer } from './pool-defer.js';
4
+ import { observePoolAccounts, isPoolDeferred, readPoolDefer } from './pool-defer.js';
5
5
  import { existsSync, readdirSync, rmSync, watch as fsWatch } from 'fs';
6
6
  import { join } from 'path';
7
7
  import { config } from './config.js';
@@ -11,6 +11,7 @@ import { logger } from './logger.js';
11
11
  import { isAtCapacity } from './claude-spawner.js';
12
12
  import { getTrackedBrains } from './maintenance-cron.js';
13
13
  import { runInboxLoop, isInboxLoopActive } from './poller.js';
14
+ import { fetchRuntimePool } from './subscription-runtime.js';
14
15
  import { isQuotaPaused } from './quota-pause.js';
15
16
  import { emitError } from './event-queue.js';
16
17
  import { sendSlackMessage } from './slack-client.js';
@@ -151,6 +152,7 @@ export async function checkInboxForNewSessions(deps = defaultDeps()) {
151
152
  const expired = await sweepThreadRouting(deps, brains);
152
153
  const started = new Set();
153
154
  const recoveries = [];
155
+ const checkedPools = new Set();
154
156
  scan: for (const brainPath of brains) {
155
157
  if (deps.isAtCapacity())
156
158
  break;
@@ -160,7 +162,7 @@ export async function checkInboxForNewSessions(deps = defaultDeps()) {
160
162
  break scan;
161
163
  if (expired.has(threadRoutingKey(threadId)))
162
164
  continue;
163
- if (started.has(threadId) || recoveringThreads.has(threadId) || deps.isInboxLoopActive(threadId) || isPoolDeferred(threadId))
165
+ if (started.has(threadId) || recoveringThreads.has(threadId) || deps.isInboxLoopActive(threadId))
164
166
  continue;
165
167
  discardHandledInboxDrops(brainPath, threadId);
166
168
  const runtime = readInboxRuntime(brainPath, threadId);
@@ -193,6 +195,17 @@ export async function checkInboxForNewSessions(deps = defaultDeps()) {
193
195
  quarantineRetainedThread(deps, brainPath, threadId, durable);
194
196
  continue;
195
197
  }
198
+ if (isPoolDeferred(threadId)) {
199
+ // Held work still observes pool membership on every watcher tick.
200
+ const key = JSON.stringify([info.workspaceId, info.channelId]);
201
+ if (!checkedPools.has(key)) {
202
+ checkedPools.add(key);
203
+ observePoolAccounts(info.workspaceId, await fetchRuntimePool(message));
204
+ }
205
+ const pause = readPoolDefer(threadId);
206
+ if (isPoolDeferred(threadId) && pause && !hasInboxMessageAfter(brainPath, threadId, pause))
207
+ continue;
208
+ }
196
209
  started.add(threadId);
197
210
  recoveringThreads.add(threadId);
198
211
  recoveries.push((async () => {
package/dist/poller.js CHANGED
@@ -14,7 +14,7 @@ import { classifyFailedIteration } from './classify-failed-iteration.js';
14
14
  import { fetchSecretsForSpawn } from './secrets-client.js';
15
15
  import { PoolDeferredError, fetchRuntimePool, subscriptionUsageStore, HELD_PROBE_INTERVAL_MS } from './subscription-runtime.js';
16
16
  import { POOL_RECHECK_MS } from './subscription-selection.js';
17
- import { isPoolDeferred, readPoolDefer, deferPool, setPoolNotice, clearPoolDefer } from './pool-defer.js';
17
+ import { invalidatePoolDefer, invalidateWorkspacePoolDefers, observePoolAccounts, isPoolDeferred, readPoolDefer, deferPool, setPoolNotice, clearPoolDefer } from './pool-defer.js';
18
18
  import { isQuotaPaused, pauseForQuota, clearQuotaPauseAfterSuccess, QuotaPausedError } from './quota-pause.js';
19
19
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
20
20
  import { ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
@@ -26,7 +26,7 @@ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './main
26
26
  import { bootstrapTrackedBrains } from './brains-bootstrap.js';
27
27
  import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
28
28
  import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
29
- import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage, saveInboxContext, saveInboxRuntime, readInboxRuntime, clearInboxRuntime } from './inbox-manager.js';
29
+ import { inboxQueueMessageIds, hasInboxMessageAfter, writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage, saveInboxContext, saveInboxRuntime, readInboxRuntime, clearInboxRuntime } from './inbox-manager.js';
30
30
  import { saveThreadRouting } from './thread-routing.js';
31
31
  import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
32
32
  import { Cron } from 'croner';
@@ -257,12 +257,17 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
257
257
  if (isQuotaPaused() && !getPollerId())
258
258
  return;
259
259
  const initialPool = await fetchRuntimePool(queueMessage);
260
+ observePoolAccounts(queueMessage.teamvibe.workspaceId, initialPool, { log: line => sessionLog.info(line) });
260
261
  if (initialPool.kind === 'no_accounts_configured') {
261
262
  if (isQuotaPaused())
262
263
  return;
263
264
  }
264
- else if (isPoolDeferred(threadId))
265
- return;
265
+ else if (isPoolDeferred(threadId)) {
266
+ const pause = readPoolDefer(threadId);
267
+ if (!hasInboxMessageAfter(kbPath, threadId, pause))
268
+ return;
269
+ invalidatePoolDefer(threadId, queueMessage.teamvibe.workspaceId, 'new_message', { log: line => sessionLog.info(line) }, inboxQueueMessageIds(kbPath, threadId));
270
+ }
266
271
  if (activeLoops.has(threadId)) {
267
272
  sessionLog.info(`Loop already running for thread ${threadId}, skipping`);
268
273
  return;
@@ -370,6 +375,7 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
370
375
  // Dispatch before ANY pool state/context/model preparation. The explicit
371
376
  // no-pool outcome runs the original spawn and #408 pause gates.
372
377
  const pool = iteration === 0 ? initialPool : await fetchRuntimePool(queueMessage);
378
+ observePoolAccounts(queueMessage.teamvibe.workspaceId, pool, { log: line => sessionLog.info(line) });
373
379
  poolIteration = pool.kind !== 'no_accounts_configured';
374
380
  if (!poolIteration && isQuotaPaused())
375
381
  break;
@@ -621,7 +627,7 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
621
627
  // Revisit held work hourly so background probes can refresh the account.
622
628
  // Selection still enforces each window's real reset before inference.
623
629
  const recheckAt = error.cause === 'usage' ? Math.min(error.eligibleAt, Date.now() + HELD_PROBE_INTERVAL_MS) : error.eligibleAt;
624
- const pause = deferPool(threadId, recheckAt, error.resetAt);
630
+ const pause = deferPool(threadId, recheckAt, error.resetAt, inboxQueueMessageIds(kbPath, threadId));
625
631
  const retained = readInboxRuntime(kbPath, threadId);
626
632
  // Rechecks of one exhaustion episode do not inflate the episode count.
627
633
  quotaDeferEpisodes = Math.max(1, quotaDeferEpisodes);
@@ -901,7 +907,7 @@ async function processMessage(received) {
901
907
  const kbPath = directory.path;
902
908
  // Write raw message to inbox (spawnClaudeCode handles prompt formatting)
903
909
  const { botToken: _inboxToken, ...inboxTeamvibe } = queueMessage.teamvibe;
904
- writeMessage(kbPath, threadId, JSON.stringify({ ...queueMessage, teamvibe: inboxTeamvibe }));
910
+ writeMessage(kbPath, threadId, JSON.stringify({ ...queueMessage, executionOrigin: receiptHandle ? 'queue' : 'maintenance', teamvibe: inboxTeamvibe }));
905
911
  sessionLog.info(`Wrote message to inbox`);
906
912
  // Delete SQS message immediately — it's now persisted in the inbox
907
913
  if (receiptHandle) {
@@ -1029,6 +1035,7 @@ export async function pollLoop() {
1029
1035
  apiUrl: config.TEAMVIBE_API_URL,
1030
1036
  pollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
1031
1037
  }),
1038
+ onHeadroom: workspaceId => invalidateWorkspacePoolDefers(workspaceId, 'usage_update'),
1032
1039
  store: subscriptionUsageStore(),
1033
1040
  cliPath: config.CLAUDE_CLI_PATH,
1034
1041
  isAtCapacity,
@@ -1,4 +1,16 @@
1
+ import { emitEvent } from './event-queue.js';
2
+ import type { PoolFetchResult } from './subscription-selection.js';
1
3
  export interface PoolDefer {
4
+ /** Additive ownership index; older markers use the existing hashed routing file. */
5
+ scope?: {
6
+ workspaceId: string;
7
+ threadId: string;
8
+ channelId: string;
9
+ };
10
+ /** Enabled capacity known when this hold was last evaluated. */
11
+ poolAccountIds?: string[];
12
+ /** Queue arrivals already considered in this exhaustion episode. */
13
+ handledInboundIds?: string[];
2
14
  createdAt: number;
3
15
  resumeAt: number;
4
16
  resetAt?: string;
@@ -7,7 +19,18 @@ export interface PoolDefer {
7
19
  }
8
20
  export declare function readPoolDefer(threadId: string): PoolDefer | undefined;
9
21
  export declare function isPoolDeferred(threadId: string): boolean;
10
- export declare function deferPool(threadId: string, eligibleAt: number, resetAt?: string): PoolDefer;
22
+ export declare function deferPool(threadId: string, eligibleAt: number, resetAt?: string, handledInboundIds?: string[]): PoolDefer;
11
23
  /** Claim durably before Slack I/O. Ambiguous sends are never blindly replayed. */
12
24
  export declare function setPoolNotice(threadId: string, episodeId: string, notice: PoolDefer['notice']): boolean;
13
25
  export declare function clearPoolDefer(threadId: string): void;
26
+ export type PoolDeferInvalidationReason = 'usage_update' | 'pool_changed' | 'new_message';
27
+ interface InvalidationObserver {
28
+ emit?: typeof emitEvent;
29
+ log?: (line: string) => void;
30
+ }
31
+ /** Expire atomically, retaining the episode and notice claim until a successful spawn. */
32
+ export declare function invalidatePoolDefer(threadId: string, workspaceId: string, reason: PoolDeferInvalidationReason, observer?: InvalidationObserver, handledInboundIds?: string[]): void;
33
+ export declare function invalidateWorkspacePoolDefers(workspaceId: string, reason: Exclude<PoolDeferInvalidationReason, 'new_message'>, observer?: InvalidationObserver): void;
34
+ /** Only authoritative enabled account sets advance the baseline; never cache credentials. */
35
+ export declare function observePoolAccounts(workspaceId: string, pool: PoolFetchResult, observer?: InvalidationObserver): void;
36
+ export {};
@@ -1,19 +1,27 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { config } from './config.js';
5
5
  import { logger } from './logger.js';
6
+ import { emitEvent } from './event-queue.js';
7
+ import { readThreadRouting, threadRoutingKey } from './thread-routing.js';
6
8
  import { POOL_RECHECK_MS } from './subscription-selection.js';
7
9
  function pathFor(threadId) {
8
10
  return join(config.TEAMVIBE_DATA_DIR, 'pool-defer', `${createHash('sha256').update(threadId).digest('hex')}.json`);
9
11
  }
10
12
  const warned = new Set();
11
13
  export function readPoolDefer(threadId) {
12
- const path = pathFor(threadId);
14
+ return readMarker(pathFor(threadId));
15
+ }
16
+ function readMarker(path) {
13
17
  try {
14
18
  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))
19
+ 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
20
  throw new Error('Invalid defer');
21
+ for (const ids of [state.poolAccountIds, state.handledInboundIds]) {
22
+ if (ids !== undefined && (!Array.isArray(ids) || ids.some(id => typeof id !== 'string')))
23
+ throw new Error('Invalid defer baseline');
24
+ }
17
25
  warned.delete(path);
18
26
  return state;
19
27
  }
@@ -56,10 +64,14 @@ function write(threadId, state) {
56
64
  catch { }
57
65
  }
58
66
  }
59
- export function deferPool(threadId, eligibleAt, resetAt) {
67
+ export function deferPool(threadId, eligibleAt, resetAt, handledInboundIds = []) {
60
68
  const previous = readPoolDefer(threadId);
61
69
  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 } : {}) };
70
+ const routing = readThreadRouting(threadId);
71
+ const scope = routing ? { workspaceId: routing.workspaceId, channelId: routing.channelId, threadId } : previous?.scope;
72
+ const fingerprint = scope ? poolFingerprints.get(poolKey(scope.workspaceId)) : undefined;
73
+ const poolAccountIds = fingerprint === undefined ? previous?.poolAccountIds : JSON.parse(fingerprint);
74
+ const state = { ...(scope ? { scope } : {}), ...(poolAccountIds ? { poolAccountIds } : {}), handledInboundIds: [...new Set([...(previous?.handledInboundIds ?? []), ...handledInboundIds])], createdAt: now, resumeAt: Number.isFinite(eligibleAt) && eligibleAt > now ? eligibleAt : now + POOL_RECHECK_MS, episodeId: previous?.episodeId ?? randomUUID(), notice: previous?.notice ?? 'pending', ...(resetAt ? { resetAt } : {}) };
63
75
  write(threadId, state);
64
76
  return state;
65
77
  }
@@ -79,3 +91,87 @@ export function clearPoolDefer(threadId) {
79
91
  logger.warn('[PoolDefer] Completed episode cleanup will be retried');
80
92
  }
81
93
  }
94
+ /** Expire atomically, retaining the episode and notice claim until a successful spawn. */
95
+ export function invalidatePoolDefer(threadId, workspaceId, reason, observer = {}, handledInboundIds = []) {
96
+ const state = readPoolDefer(threadId);
97
+ if (!state || !isPoolDeferred(threadId))
98
+ return;
99
+ const routing = readThreadRouting(threadId);
100
+ const scope = state.scope ?? (routing ? { ...routing, threadId } : undefined);
101
+ if (!scope || scope.workspaceId !== workspaceId)
102
+ return;
103
+ write(threadId, { ...state, resumeAt: state.createdAt, handledInboundIds: [...new Set([...(state.handledInboundIds ?? []), ...handledInboundIds])] });
104
+ const metadata = { workspaceId, threadId, reason, episodeId: state.episodeId };
105
+ // Observability cannot undo the durable wake-up.
106
+ try {
107
+ (observer.log ?? (line => logger.info(line)))(`[Pool] defer invalidated workspace=${JSON.stringify(workspaceId)} thread=${JSON.stringify(threadId)} reason=${reason} episode=${JSON.stringify(state.episodeId)}`);
108
+ }
109
+ catch { }
110
+ try {
111
+ (observer.emit ?? emitEvent)('pool.defer_invalidated', scope.channelId, metadata);
112
+ }
113
+ catch { }
114
+ }
115
+ /** One directory scan, one bounded routing lookup per legacy marker; no inbox scan. */
116
+ function* workspacePoolDefers(workspaceId) {
117
+ const root = join(config.TEAMVIBE_DATA_DIR, 'pool-defer');
118
+ let names;
119
+ try {
120
+ names = readdirSync(root);
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ for (const name of names) {
126
+ if (!/^[a-f0-9]{64}\.json$/.test(name))
127
+ continue;
128
+ try {
129
+ const state = readMarker(join(root, name));
130
+ if (!state)
131
+ continue;
132
+ let scope = state.scope;
133
+ if (!scope) {
134
+ // Pre-#572 markers share their hash with durable thread routing.
135
+ scope = JSON.parse(readFileSync(join(config.TEAMVIBE_DATA_DIR, 'thread-routing', name), 'utf8'));
136
+ }
137
+ if (scope?.workspaceId === workspaceId && typeof scope.threadId === 'string' && threadRoutingKey(scope.threadId) === name)
138
+ yield { state, scope };
139
+ }
140
+ catch {
141
+ logger.warn('[PoolDefer] Workspace invalidation will be retried');
142
+ }
143
+ }
144
+ }
145
+ export function invalidateWorkspacePoolDefers(workspaceId, reason, observer = {}) {
146
+ for (const { scope } of workspacePoolDefers(workspaceId))
147
+ invalidatePoolDefer(scope.threadId, workspaceId, reason, observer);
148
+ }
149
+ const poolFingerprints = new Map();
150
+ function poolKey(workspaceId) {
151
+ return JSON.stringify([config.TEAMVIBE_DATA_DIR, workspaceId]);
152
+ }
153
+ /** Only authoritative enabled account sets advance the baseline; never cache credentials. */
154
+ export function observePoolAccounts(workspaceId, pool, observer = {}) {
155
+ if (pool.kind !== 'ready' && pool.kind !== 'no_accounts_configured' && !(pool.kind === 'fetch_unavailable' && pool.reason === 'no_usable_accounts'))
156
+ return;
157
+ const poolAccountIds = pool.kind === 'ready' ? [...new Set(pool.accounts.filter(a => a.enabled && a.workspaceId === workspaceId).map(a => a.accountId))].sort() : [];
158
+ const fingerprint = JSON.stringify(poolAccountIds);
159
+ const key = poolKey(workspaceId);
160
+ if (poolFingerprints.get(key) === fingerprint)
161
+ return;
162
+ // The first fetch after boot also compares the durable per-marker baselines.
163
+ for (const { state, scope } of workspacePoolDefers(workspaceId)) {
164
+ const now = Date.now();
165
+ if (now < state.createdAt || now >= state.resumeAt)
166
+ continue;
167
+ if (state.poolAccountIds && poolAccountIds.some(id => !state.poolAccountIds.includes(id))) {
168
+ invalidatePoolDefer(scope.threadId, workspaceId, 'pool_changed', observer);
169
+ }
170
+ else {
171
+ // Reductions never add capacity. Adopt them so a later re-enable wakes work.
172
+ // A legacy marker adopts its first observed baseline without waking work.
173
+ write(scope.threadId, { ...state, poolAccountIds });
174
+ }
175
+ }
176
+ poolFingerprints.set(key, fingerprint);
177
+ }
@@ -1,4 +1,5 @@
1
1
  import { isAtCapacity, isSubscriptionAccountRunning } from './claude-spawner.js';
2
+ import { invalidateWorkspacePoolDefers } from './pool-defer.js';
2
3
  import { emitEvent } from './event-queue.js';
3
4
  import { logger } from './logger.js';
4
5
  import { runUsageProbe } from './usage-probe-job.js';
@@ -149,6 +150,7 @@ export async function preparePool(msg, resolved, pin, fetched, log, probeOptions
149
150
  if (pool.kind === 'ready')
150
151
  probeUnknownAccounts(pool.accounts, selected.kind === 'selected' ? selected.account.accountId : undefined, resolved, store, {
151
152
  cliPath: config.CLAUDE_CLI_PATH, isAtCapacity, emit: emitEvent,
153
+ onHeadroom: workspaceId => invalidateWorkspacePoolDefers(workspaceId, 'usage_update'),
152
154
  log: line => (log ?? logger).info(line), ...probeOptions, store,
153
155
  }, msg.teamvibe.channelId);
154
156
  if (selected.kind === 'selected') {
@@ -6,6 +6,8 @@ import { type SubscriptionUsageStore } from './subscription-usage-store.js';
6
6
  import type { UsageProbeFailureReason as Reason } from './usage-probe-contract.js';
7
7
  type ProbeResult = Reason | 'ok' | 'cancelled';
8
8
  export interface UsageProbeOptions {
9
+ /** Poller-owned wake-up; keeps shared probe imports independent of poller configuration. */
10
+ onHeadroom?: (workspaceId: string) => void;
9
11
  store: SubscriptionUsageStore;
10
12
  cliPath: string;
11
13
  /** Use the session's resolved model for local probes; platform jobs default to CLI behavior. */
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdtemp, rm, mkdir } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { normalizePassiveUsage } from './subscription-selection.js';
5
+ import { selectSubscriptionAccount, normalizePassiveUsage } from './subscription-selection.js';
6
6
  import { safeRateLimitInfo, usageStateForSelection, } from './subscription-usage-store.js';
7
7
  import { parseClaudeRateLimitInfo, } from './claude-rate-limit.js';
8
8
  import { formatPoolUsageWindows, poolInfo, poolLabel } from './pool-logging.js';
@@ -221,6 +221,12 @@ export async function runUsageProbe(account, options, context = {}) {
221
221
  usage,
222
222
  });
223
223
  const stored = await options.store.read(account);
224
+ if (selectSubscriptionAccount({ pool: { kind: 'ready', accounts: [account] }, states: { [account.accountId]: usageStateForSelection(stored, now) }, model: undefined, now }).kind === 'selected') {
225
+ try {
226
+ options.onHeadroom?.(account.workspaceId);
227
+ }
228
+ catch { /* Wake-up failures must not discard a measured observation. */ }
229
+ }
224
230
  // Requestless local observations use real channel authorization; the
225
231
  // workspace-job namespace is reserved for platform request IDs.
226
232
  options.emit('pool.usage_observed', context.channelId ?? 'usage_probe', {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.7-rc.1",
3
+ "version": "0.8.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@teamvibe/poller",
9
- "version": "0.8.7-rc.1",
9
+ "version": "0.8.7",
10
10
  "dependencies": {
11
11
  "@aws-sdk/client-dynamodb": "3.1108.0",
12
12
  "@aws-sdk/client-sqs": "3.1108.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.7-rc.1",
3
+ "version": "0.8.7",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {