@teamvibe/poller 0.8.3 → 0.8.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +13 -0
  2. package/dist/brain-directory.d.ts +14 -0
  3. package/dist/brain-directory.js +16 -0
  4. package/dist/brain-manager.d.ts +2 -2
  5. package/dist/brain-manager.js +32 -19
  6. package/dist/brain-path.d.ts +9 -0
  7. package/dist/brain-path.js +65 -0
  8. package/dist/claude-spawner.d.ts +3 -2
  9. package/dist/claude-spawner.js +189 -71
  10. package/dist/config.d.ts +4 -0
  11. package/dist/config.js +1 -0
  12. package/dist/event-queue.js +14 -1
  13. package/dist/inbox-manager.d.ts +11 -0
  14. package/dist/inbox-manager.js +101 -6
  15. package/dist/inbox-watcher.d.ts +9 -4
  16. package/dist/inbox-watcher.js +219 -49
  17. package/dist/maintenance-cron.d.ts +4 -2
  18. package/dist/maintenance-cron.js +56 -14
  19. package/dist/metrics/scheduler.d.ts +4 -1
  20. package/dist/metrics/scheduler.js +8 -6
  21. package/dist/poller.d.ts +2 -1
  22. package/dist/poller.js +50 -24
  23. package/dist/pool-logging.js +2 -2
  24. package/dist/pool-telemetry.d.ts +20 -4
  25. package/dist/pool-telemetry.js +23 -0
  26. package/dist/session-store.d.ts +1 -1
  27. package/dist/session-store.js +6 -2
  28. package/dist/startup-auth.js +4 -1
  29. package/dist/subscription-pool-client.js +7 -0
  30. package/dist/subscription-pool.d.ts +1 -0
  31. package/dist/subscription-pool.js +1 -1
  32. package/dist/subscription-runtime.d.ts +20 -2
  33. package/dist/subscription-runtime.js +4 -4
  34. package/dist/subscription-selection.d.ts +13 -3
  35. package/dist/subscription-selection.js +18 -3
  36. package/dist/thread-routing.d.ts +23 -0
  37. package/dist/thread-routing.js +81 -0
  38. package/dist/types.d.ts +4 -10
  39. package/dist/usage-limit-messages.d.ts +1 -1
  40. package/dist/usage-limit-messages.js +1 -1
  41. package/dist/usage-probe-contract.d.ts +15 -0
  42. package/dist/usage-probe-contract.js +14 -0
  43. package/dist/usage-probe-job.js +18 -5
  44. package/dist/worktree-manager.d.ts +2 -1
  45. package/dist/worktree-manager.js +38 -10
  46. package/npm-shrinkwrap.json +2 -2
  47. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { executionDirectory } from './brain-directory.js';
1
2
  import { Cron } from 'croner';
2
3
  import { config } from './config.js';
3
4
  import { logger } from './logger.js';
@@ -8,7 +9,7 @@ import { collectMetrics } from './metrics/collector.js';
8
9
  import { sendMetrics } from './metrics/sender.js';
9
10
  import { markMaintenanceJobFired, markMaintenanceJobSuccess, markMaintenanceJobSkipped, } from './cron-telemetry.js';
10
11
  import { shouldSkipMaintenance } from './maintenance-activity.js';
11
- import { join } from 'path';
12
+ import { resolveBrainDir, fallbackBrainPath, validateBrainId } from './brain-path.js';
12
13
  const trackedBrains = new Map();
13
14
  /**
14
15
  * Read-only view of brains seen so far (via registerBrain). Used by the inbox
@@ -42,7 +43,7 @@ let maintenanceJob = null;
42
43
  * opposite bug — a genuinely-quiet brain wrongly evaluated as "nothing new,
43
44
  * skip" on its very first post-restart cron tick.
44
45
  *
45
- * Skips (rather than overwrites) any brainId already present in
46
+ * Skips (rather than overwrites) any workspace/channel already present in
46
47
  * trackedBrains. This guard is load-bearing, not just defensive (DevGuru
47
48
  * review, round 4): since round 3's critical-path detachment
48
49
  * (brains-bootstrap.ts's `void (async () => {...})()` retry), the retry
@@ -80,9 +81,21 @@ let maintenanceJob = null;
80
81
  export function seedTrackedBrains(brains) {
81
82
  let seeded = 0;
82
83
  for (const b of brains) {
83
- if (trackedBrains.has(b.brainId))
84
+ if (trackedBrains.has(`${b.workspaceId}:${b.channelId}`))
84
85
  continue;
85
- trackedBrains.set(b.brainId, {
86
+ let directory;
87
+ try {
88
+ validateBrainId(b.workspaceId);
89
+ validateBrainId(b.brainId);
90
+ directory = b.gitRepoUrl
91
+ ? { path: resolveBrainDir(b.workspaceId, b.brainId), workspaceId: b.workspaceId, brainId: b.brainId }
92
+ : { path: fallbackBrainPath() };
93
+ }
94
+ catch {
95
+ continue;
96
+ } // Validator logs + emits; invalid bootstrap records are never registered.
97
+ trackedBrains.set(`${b.workspaceId}:${b.channelId}`, {
98
+ directory,
86
99
  brainId: b.brainId,
87
100
  gitRepoUrl: b.gitRepoUrl,
88
101
  branch: b.branch,
@@ -91,7 +104,7 @@ export function seedTrackedBrains(brains) {
91
104
  botToken: b.botToken,
92
105
  channelId: b.channelId,
93
106
  });
94
- registerBrainForMetrics(b.brainId);
107
+ registerBrainForMetrics(b.brainId, b.workspaceId, directory);
95
108
  seeded++;
96
109
  }
97
110
  return seeded;
@@ -100,15 +113,26 @@ export function seedTrackedBrains(brains) {
100
113
  * Register a brain from a real message so the maintenance cron knows about it.
101
114
  * Called from processMessage() after brain path is resolved.
102
115
  */
103
- export function registerBrain(msg) {
116
+ export function registerBrain(msg, directory) {
104
117
  const brain = msg.teamvibe.brain;
105
118
  if (!brain?.brainId)
106
119
  return;
107
120
  // Don't register from maintenance messages (avoid self-referential loop)
108
121
  if (msg.source === 'maintenance')
109
122
  return;
110
- const isNew = !trackedBrains.has(brain.brainId);
111
- trackedBrains.set(brain.brainId, {
123
+ const key = `${msg.teamvibe.workspaceId}:${msg.teamvibe.channelId}`;
124
+ const isNew = ![...trackedBrains.values()].some(info => info.workspaceId === msg.teamvibe.workspaceId && info.brainId === brain.brainId);
125
+ validateBrainId(msg.teamvibe.workspaceId);
126
+ validateBrainId(brain.brainId);
127
+ const selectedDirectory = executionDirectory(directory ?? (brain.gitRepoUrl
128
+ ? { path: resolveBrainDir(msg.teamvibe.workspaceId, brain.brainId), workspaceId: msg.teamvibe.workspaceId, brainId: brain.brainId }
129
+ : { path: fallbackBrainPath(msg.response_context.slack?.channel) }));
130
+ const previous = trackedBrains.get(key);
131
+ if (previous && previous.directory.path !== selectedDirectory.path) {
132
+ logger.warn(`[Maintenance] Channel ${msg.teamvibe.channelId} reassigned; retained work will restart in the current brain ${brain.brainId}`);
133
+ }
134
+ trackedBrains.set(key, {
135
+ directory: selectedDirectory,
112
136
  brainId: brain.brainId,
113
137
  gitRepoUrl: brain.gitRepoUrl,
114
138
  branch: brain.branch,
@@ -122,12 +146,12 @@ export function registerBrain(msg) {
122
146
  // Metrics pipeline (teamvibe.ai#110): register for hourly collection. On the
123
147
  // first sighting of this brain, fire one immediate collect+send so we don't
124
148
  // wait up to an hour for the first sample.
125
- registerBrainForMetrics(brain.brainId);
149
+ registerBrainForMetrics(brain.brainId, msg.teamvibe.workspaceId, selectedDirectory);
126
150
  if (isNew) {
127
151
  const brainId = brain.brainId;
128
152
  void (async () => {
129
153
  try {
130
- const brainPath = join(config.BRAINS_PATH, brainId);
154
+ const brainPath = executionDirectory(selectedDirectory).path;
131
155
  const payload = await collectMetrics(brainPath, brainId);
132
156
  await sendMetrics(payload);
133
157
  }
@@ -142,21 +166,39 @@ export function registerBrain(msg) {
142
166
  */
143
167
  async function triggerAllBrains(processMessageFn) {
144
168
  const now = Date.now();
145
- for (const [brainId, info] of trackedBrains.entries()) {
169
+ // Channel routing is tracked independently, but maintenance still runs once
170
+ // per clone and uses the latest real activity across all of its channels.
171
+ const clones = new Map();
172
+ for (const info of trackedBrains.values()) {
173
+ const key = `${info.workspaceId}:${info.brainId}`;
174
+ const previous = clones.get(key);
175
+ if (!previous || (info.lastRealMessageAt ?? 0) > (previous.lastRealMessageAt ?? 0))
176
+ clones.set(key, info);
177
+ }
178
+ for (const info of clones.values()) {
179
+ const brainId = info.brainId;
180
+ const key = `${info.workspaceId}:${brainId}`;
146
181
  if (isAtCapacity()) {
147
182
  logger.debug(`[Maintenance] At capacity, skipping ${brainId}`);
148
183
  continue;
149
184
  }
150
- if (await shouldSkipMaintenance(config.BRAINS_PATH, brainId, info.lastRealMessageAt)) {
185
+ let directory;
186
+ try {
187
+ directory = executionDirectory(info.directory);
188
+ }
189
+ catch {
190
+ continue;
191
+ } // Validator logged + emitted; never reuse an aliased clone.
192
+ if (await shouldSkipMaintenance(directory.path, '', info.lastRealMessageAt)) {
151
193
  markMaintenanceJobSkipped();
152
194
  logger.debug(`[Maintenance] Skipping ${brainId} — no real activity since last maintenance (#131)`);
153
195
  continue;
154
196
  }
155
197
  logger.info(`[Maintenance] Triggering maintenance for brain ${brainId}`);
156
198
  const syntheticMessage = {
157
- id: `maintenance_${brainId}_${now}`,
199
+ id: `maintenance_${key}_${now}`,
158
200
  source: 'maintenance',
159
- thread_id: `maintenance_${brainId}_${now}`,
201
+ thread_id: `maintenance_${key}_${now}`,
160
202
  sender: {
161
203
  id: 'system',
162
204
  name: 'System',
@@ -1,11 +1,14 @@
1
+ import { type BrainDirectory } from '../brain-directory.js';
1
2
  interface RegisteredBrain {
2
3
  brainId: string;
4
+ workspaceId: string;
5
+ directory: BrainDirectory;
3
6
  }
4
7
  /**
5
8
  * Register (or update) a brain for hourly metrics collection.
6
9
  * Safe to call repeatedly with the same brainId.
7
10
  */
8
- export declare function registerBrainForMetrics(brainId: string): void;
11
+ export declare function registerBrainForMetrics(brainId: string, workspaceId: string, directory?: BrainDirectory): void;
9
12
  /**
10
13
  * Deterministic 32-bit FNV-1a hash of a string. Used to derive a stable
11
14
  * per-brain jitter offset for hourly cron alignment (see #110 review):
@@ -1,4 +1,5 @@
1
- import { join } from 'path';
1
+ import { resolveBrainDir, validateBrainId } from '../brain-path.js';
2
+ import { executionDirectory } from '../brain-directory.js';
2
3
  import { Cron } from 'croner';
3
4
  import { config } from '../config.js';
4
5
  import { logger } from '../logger.js';
@@ -12,17 +13,18 @@ let metricsJob = null;
12
13
  * Register (or update) a brain for hourly metrics collection.
13
14
  * Safe to call repeatedly with the same brainId.
14
15
  */
15
- export function registerBrainForMetrics(brainId) {
16
- if (!brainId)
17
- return;
18
- registered.set(brainId, { brainId });
16
+ export function registerBrainForMetrics(brainId, workspaceId, directory) {
17
+ validateBrainId(workspaceId);
18
+ validateBrainId(brainId);
19
+ const selected = executionDirectory(directory ?? { path: resolveBrainDir(workspaceId, brainId), workspaceId, brainId });
20
+ registered.set(`${workspaceId}:${brainId}`, { brainId, workspaceId, directory: selected });
19
21
  }
20
22
  /**
21
23
  * Collect + send metrics for a single brain. Never throws.
22
24
  */
23
25
  async function collectAndSendOne(brain) {
24
26
  try {
25
- const brainPath = join(config.BRAINS_PATH, brain.brainId);
27
+ const brainPath = executionDirectory(brain.directory).path;
26
28
  const payload = await collectMetrics(brainPath, brain.brainId);
27
29
  await sendMetrics(payload);
28
30
  }
package/dist/poller.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type BrainDirectory } from './brain-directory.js';
1
2
  import { logger } from './logger.js';
2
3
  import { type SpawnResult } from './claude-spawner.js';
3
4
  import { type KilledByPoller } from './classify-failed-iteration.js';
@@ -32,7 +33,7 @@ export declare function isPollerInitiatedFailure(result: {
32
33
  export declare function describeFailureReason(killedByPoller: KilledByPoller | undefined, exitCode: number | null): string;
33
34
  export declare const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = "NO_FURTHER_RESPONSE_NEEDED";
34
35
  export declare function endOfTurnFallbackText(hasSlackContext: boolean, result: Pick<SpawnResult, 'success' | 'sawSendMessageCall' | 'lastPlainTextContent'>): string | undefined;
35
- export declare function runInboxLoop(threadId: string, kbPath: string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession>): Promise<void>;
36
+ export declare function runInboxLoop(threadId: string, selectedDirectory: BrainDirectory | string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession>): Promise<void>;
36
37
  export declare function pollLoop(): Promise<void>;
37
38
  export declare function __setSleepForTest(impl: (ms: number) => Promise<void>): void;
38
39
  export declare function __setShuttingDownForTest(value: boolean): void;
package/dist/poller.js CHANGED
@@ -3,6 +3,7 @@ import { fetchSubscriptionPool } from './subscription-pool-client.js';
3
3
  import { formatPoolUsageLimitMessage } from './usage-limit-messages.js';
4
4
  import { logPoolUsageWrite } from './pool-logging.js';
5
5
  import { emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
6
+ import { executionDirectory, selectBrainDirectory } from './brain-directory.js';
6
7
  import { config, POLLER_VERSION } from './config.js';
7
8
  import { logger, setLogContext } from './logger.js';
8
9
  import { pollMessages, deleteMessage, } from './sqs-poller.js';
@@ -15,7 +16,7 @@ import { POOL_RECHECK_MS } from './subscription-selection.js';
15
16
  import { isPoolDeferred, readPoolDefer, deferPool, setPoolNotice, clearPoolDefer } from './pool-defer.js';
16
17
  import { isQuotaPaused, pauseForQuota, clearQuotaPauseAfterSuccess, QuotaPausedError } from './quota-pause.js';
17
18
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
18
- import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
19
+ import { ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
19
20
  import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
20
21
  import { postMaintenanceReports } from './maintenance-reporter.js';
21
22
  import { initAuth, stopRefresh, getPollerId } from './auth-provider.js';
@@ -25,6 +26,7 @@ import { bootstrapTrackedBrains } from './brains-bootstrap.js';
25
26
  import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
26
27
  import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
27
28
  import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage, saveInboxContext, saveInboxRuntime, readInboxRuntime, clearInboxRuntime } from './inbox-manager.js';
29
+ import { saveThreadRouting } from './thread-routing.js';
28
30
  import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
29
31
  import { Cron } from 'croner';
30
32
  import { isControlMessage, handleControlMessage } from './control-handler.js';
@@ -248,7 +250,9 @@ export function endOfTurnFallbackText(hasSlackContext, result) {
248
250
  }
249
251
  return text.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join(END_OF_TURN_SENTINEL_DISPLAY);
250
252
  }
251
- export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
253
+ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, sessionLog) {
254
+ const directory = executionDirectory(selectedDirectory);
255
+ const kbPath = directory.path;
252
256
  if (isQuotaPaused() && !getPollerId())
253
257
  return;
254
258
  const initialPool = await fetchRuntimePool(queueMessage);
@@ -284,10 +288,26 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
284
288
  if (lockToken) {
285
289
  activeLocks.set(threadId, lockToken);
286
290
  }
287
- let currentSessionId = session.session_id || undefined;
288
- let currentPin = session.subscription_pin;
291
+ saveThreadRouting(threadId, {
292
+ workspaceId: queueMessage.teamvibe.workspaceId,
293
+ channelId: queueMessage.teamvibe.channelId,
294
+ brainId: queueMessage.teamvibe.brain?.brainId,
295
+ directory: kbPath,
296
+ });
297
+ const directoryChanged = Boolean(session.workspace_path && session.workspace_path !== kbPath);
298
+ if (directoryChanged)
299
+ sessionLog.warn('[Loop] Session belonged to a different brain directory; restarting in the current brain');
300
+ let currentSessionId = directoryChanged ? undefined : session.session_id || undefined;
301
+ let currentPin = directoryChanged ? undefined : session.subscription_pin;
289
302
  let deferred = false;
290
303
  let poolIteration = false;
304
+ const persistSessionId = (id, pin) => {
305
+ if (!lockToken)
306
+ return Promise.reject(new Error('Missing lock'));
307
+ if (directoryChanged)
308
+ return updateSessionId(threadId, lockToken, id, pin, kbPath);
309
+ return pin ? updateSessionId(threadId, lockToken, id, pin) : updateSessionId(threadId, lockToken, id);
310
+ };
291
311
  const retryCompletedBookkeeping = async (state, pool, liveCompletion = false) => {
292
312
  if (!state.completed)
293
313
  return true;
@@ -299,7 +319,7 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
299
319
  const accountLabel = lastAttempt?.accountLabel !== state.pin.accountId ? lastAttempt?.accountLabel : undefined;
300
320
  if (state.completed.rateLimitInfo)
301
321
  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')));
322
+ writes.push(persistSessionId(state.sessionId, state.pin));
303
323
  const outcomes = await Promise.allSettled(writes);
304
324
  if (outcomes.some(outcome => outcome.status === 'rejected' || outcome.value === false)) {
305
325
  sessionLog.warn('[Loop] Inference completed; pool bookkeeping failed and will retry without running the agent');
@@ -370,7 +390,7 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
370
390
  break;
371
391
  }
372
392
  iteration++;
373
- const isFirstMessage = session.message_count === 1 && iteration === 1;
393
+ const isFirstMessage = (directoryChanged || session.message_count === 1) && iteration === 1;
374
394
  sessionLog.info(`[Loop] Iteration #${iteration} — processing: ${message.filename}`);
375
395
  await ensureBaseBrain();
376
396
  let inboxMsg = queueMessage;
@@ -386,21 +406,28 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
386
406
  try {
387
407
  // Reconstruct queue message from stored inbox data
388
408
  const storedData = JSON.parse(message.content);
409
+ const storedScope = storedData.teamvibe;
410
+ const scopeMismatch = storedScope && (storedScope.workspaceId !== queueMessage.teamvibe.workspaceId ||
411
+ storedScope.channelId !== queueMessage.teamvibe.channelId ||
412
+ storedScope.brain?.brainId !== queueMessage.teamvibe.brain?.brainId);
413
+ if (scopeMismatch) {
414
+ sessionLog.warn('[Loop] Inbox scope mismatch; executing with the current channel brain in its selected directory');
415
+ }
389
416
  inboxMsg = {
390
417
  ...queueMessage,
391
418
  ...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 },
419
+ // Routing comes from the current platform record, never agent-writable
420
+ // inbox data. This guard applies to both direct and pool execution.
421
+ thread_id: threadId,
422
+ teamvibe: { ...storedScope, ...queueMessage.teamvibe, brain: queueMessage.teamvibe.brain },
423
+ ...(scopeMismatch ? { response_context: queueMessage.response_context } : {}),
395
424
  };
396
425
  // Legacy delivery uses the original routing, just as on main.
397
426
  let result;
398
427
  if (!poolIteration) {
399
- result = await spawnClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
428
+ result = await spawnClaudeCode(inboxMsg, sessionLog, directory, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
400
429
  }
401
430
  else {
402
- if (storedData.teamvibe && (storedData.teamvibe.workspaceId !== queueMessage.teamvibe.workspaceId || storedData.teamvibe.channelId !== queueMessage.teamvibe.channelId))
403
- throw new PoolDeferredError();
404
431
  if (!queueMessage.teamvibe.botToken && queueMessage.response_context.slack) {
405
432
  const secrets = await fetchSecretsForSpawn({ pollerId: getPollerId() || '', workspaceId: queueMessage.teamvibe.workspaceId, channelId: queueMessage.teamvibe.channelId });
406
433
  const token = secrets.env.unwrap()['SLACK_BOT_TOKEN'];
@@ -421,7 +448,7 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
421
448
  currentSessionId = savedRuntime.sessionId;
422
449
  currentPin = savedRuntime.pin;
423
450
  }
424
- result = await spawnPoolClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing, {
451
+ result = await spawnPoolClaudeCode(inboxMsg, sessionLog, directory, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing, {
425
452
  pool,
426
453
  quotaDeferEpisodes,
427
454
  attempts: savedRuntime?.filename === message.filename ? savedRuntime.attempts : undefined,
@@ -450,7 +477,7 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
450
477
  },
451
478
  persist: async (id, pin, attempts) => {
452
479
  pin = { ...pin, inboxFilename: message.filename };
453
- if (!lockToken || await updateSessionId(threadId, lockToken, id, pin) === false)
480
+ if (!lockToken || await persistSessionId(id, pin) === false)
454
481
  throw new PoolDeferredError();
455
482
  currentSessionId = id;
456
483
  currentPin = pin;
@@ -466,9 +493,7 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
466
493
  currentSessionId = result.newSessionId;
467
494
  if (lockToken) {
468
495
  try {
469
- await (poolIteration
470
- ? updateSessionId(threadId, lockToken, result.newSessionId, currentPin)
471
- : updateSessionId(threadId, lockToken, result.newSessionId));
496
+ await persistSessionId(result.newSessionId, poolIteration ? currentPin : undefined);
472
497
  }
473
498
  catch (err) {
474
499
  sessionLog.error(`[Loop] Failed to persist new session ID to DynamoDB: ${err instanceof Error ? err.message : err}`);
@@ -528,8 +553,8 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
528
553
  context: { threadId: deliveryMsg.thread_id },
529
554
  });
530
555
  }
531
- if (deliveryMsg.teamvibe.brain?.brainId) {
532
- await pushBrainChanges(kbPath, deliveryMsg.teamvibe.brain.brainId, deliveryMsg.teamvibe.workspaceId);
556
+ if (directory.brainId) {
557
+ await pushBrainChanges(directory.path, directory.brainId, directory.workspaceId);
533
558
  }
534
559
  await postMaintenanceReports(kbPath, deliveryMsg.teamvibe.channelId);
535
560
  }
@@ -693,7 +718,7 @@ export async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
693
718
  currentPin = { ...pin, resumePending: false, sawSendMessageCall: false };
694
719
  try {
695
720
  if (lockToken && currentSessionId)
696
- await updateSessionId(threadId, lockToken, currentSessionId, currentPin);
721
+ await persistSessionId(currentSessionId, currentPin);
697
722
  }
698
723
  catch {
699
724
  sessionLog.warn('[Loop] Terminal failure cleared local continuation; stale pin is fenced by inbox filename');
@@ -838,9 +863,9 @@ async function processMessage(received) {
838
863
  const hasSlackContext = Boolean(queueMessage.response_context.slack?.channel &&
839
864
  queueMessage.response_context.slack?.message_ts);
840
865
  // Get brain path for this channel
841
- let kbPath;
866
+ let directory;
842
867
  try {
843
- kbPath = await getBrainPath(queueMessage.teamvibe.brain, queueMessage.response_context.slack?.channel, queueMessage.teamvibe.workspaceId);
868
+ directory = await selectBrainDirectory(queueMessage);
844
869
  }
845
870
  catch (error) {
846
871
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -860,7 +885,8 @@ async function processMessage(received) {
860
885
  return;
861
886
  }
862
887
  // Register brain so the maintenance cron knows about it.
863
- registerBrain(queueMessage);
888
+ registerBrain(queueMessage, directory);
889
+ const kbPath = directory.path;
864
890
  // Write raw message to inbox (spawnClaudeCode handles prompt formatting)
865
891
  const { botToken: _inboxToken, ...inboxTeamvibe } = queueMessage.teamvibe;
866
892
  writeMessage(kbPath, threadId, JSON.stringify({ ...queueMessage, teamvibe: inboxTeamvibe }));
@@ -872,7 +898,7 @@ async function processMessage(received) {
872
898
  }
873
899
  processingMessages.add(messageId);
874
900
  try {
875
- await runInboxLoop(threadId, kbPath, queueMessage, sessionLog);
901
+ await runInboxLoop(threadId, directory, queueMessage, sessionLog);
876
902
  }
877
903
  catch (error) {
878
904
  sessionLog.error(`Error in inbox loop: ${error instanceof Error ? error.message : error}`);
@@ -33,7 +33,7 @@ export function logPoolSelection(log, pool, states, model, now, selected, logAll
33
33
  poolInfo(log, `skipped account=${poolLabel(account)} reason=${usageOnly.kind === 'deferred' ? `usage >=${account.useFullQuota ? 100 : 90}%` : 'reactive quota'} eligibleAt=${new Date(candidate.eligibleAt).toISOString()}`);
34
34
  }
35
35
  else if (candidate.kind === 'selected' && selected.kind === 'selected' && account.accountId !== selected.account.accountId) {
36
- const reason = candidate.provenance === 'unknown' && selected.provenance === 'known' ? 'known observation preferred' : candidate.provenance === 'known' && selected.provenance === 'known' && candidate.score > selected.score ? 'higher usage score' : 'deterministic account order';
36
+ const reason = candidate.provenance === 'unknown' && selected.provenance === 'known' ? 'known observation preferred' : candidate.provenance === 'known' && selected.provenance === 'known' && candidate.score < selected.score ? 'lower headroom per hour' : 'deterministic account order';
37
37
  poolInfo(log, `skipped account=${poolLabel(account)} reason=${reason}`);
38
38
  }
39
39
  }
@@ -60,7 +60,7 @@ export function logPoolSelection(log, pool, states, model, now, selected, logAll
60
60
  poolInfo(log, `deferred reason=all accounts exhausted earliestResetAt=${earliestResetAt} eligibleAt=${new Date(selected.eligibleAt).toISOString()}`);
61
61
  }
62
62
  if (selected.kind === 'selected') {
63
- const score = selected.provenance === 'known' ? `${selected.score}% headroom=${100 - selected.score}%` : 'unknown (no observation)';
63
+ const score = selected.provenance === 'known' ? `${Number(selected.score.toFixed(2))}%/h headroom=${Number(((selected.account.useFullQuota ? 100 : 90) - selected.rateWindow.percent).toFixed(2))}% rateWindow=${selected.rateWindow.window}/${selected.rateWindow.scope} reason=${selected.reason}` : 'unknown (no observation)';
64
64
  poolInfo(log, `selected account=${poolLabel(selected.account)} model=${JSON.stringify(model ?? 'CLI default')} score=${score}`);
65
65
  }
66
66
  }
@@ -25,6 +25,17 @@ export declare function poolRunTelemetry(attempts: readonly PoolAttempt[], quota
25
25
  switchedAccounts: boolean;
26
26
  quotaDeferEpisodes: number;
27
27
  attempts: {
28
+ quota: import("./claude-quota.js").ClaudeQuotaFailure | undefined;
29
+ metrics: SessionMetrics | undefined;
30
+ lastRateLimitInfo: import("./claude-rate-limit.js").ClaudeRateLimitInfo | undefined;
31
+ score?: number | undefined;
32
+ reason?: "headroom_per_hour" | undefined;
33
+ rateWindow?: {
34
+ window: "five_hour" | "seven_day";
35
+ scope: "opus" | "haiku" | "global" | "sonnet" | "fable" | "uncertain";
36
+ percent: number | undefined;
37
+ resetAt: number | undefined;
38
+ } | undefined;
28
39
  accountId: string;
29
40
  accountLabel: string;
30
41
  sessionId: string | undefined;
@@ -32,13 +43,18 @@ export declare function poolRunTelemetry(attempts: readonly PoolAttempt[], quota
32
43
  success: boolean;
33
44
  exitCode: number | null;
34
45
  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
46
  }[];
47
+ lastRateLimitInfo?: import("./claude-rate-limit.js").ClaudeRateLimitInfo | undefined;
48
+ score?: number | undefined;
49
+ reason?: "headroom_per_hour" | undefined;
50
+ rateWindow?: {
51
+ window: "five_hour" | "seven_day";
52
+ scope: "opus" | "haiku" | "global" | "sonnet" | "fable" | "uncertain";
53
+ percent: number | undefined;
54
+ resetAt: number | undefined;
55
+ } | undefined;
39
56
  accountId?: string | undefined;
40
57
  accountLabel?: string | undefined;
41
58
  model?: string | undefined;
42
59
  observedAt?: number | undefined;
43
- lastRateLimitInfo?: import("./claude-rate-limit.js").ClaudeRateLimitInfo | undefined;
44
60
  };
@@ -82,6 +82,22 @@ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
82
82
  success: a.success,
83
83
  exitCode: a.exitCode,
84
84
  observedAt: a.observedAt,
85
+ ...(typeof a.score === 'number' &&
86
+ Number.isFinite(a.score) &&
87
+ a.score >= 0 &&
88
+ a.reason === 'headroom_per_hour' &&
89
+ a.rateWindow
90
+ ? {
91
+ score: a.score,
92
+ reason: a.reason,
93
+ rateWindow: {
94
+ window: a.rateWindow.window,
95
+ scope: a.rateWindow.scope,
96
+ percent: a.rateWindow.percent,
97
+ resetAt: a.rateWindow.resetAt,
98
+ },
99
+ }
100
+ : {}),
85
101
  quota: a.quota ? safeQuota(a.quota) : undefined,
86
102
  metrics: safeMetrics(a.metrics),
87
103
  lastRateLimitInfo: a.rateLimitInfo
@@ -96,6 +112,13 @@ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
96
112
  accountLabel: last.accountLabel,
97
113
  model: last.model,
98
114
  observedAt: last.observedAt,
115
+ ...(last.score !== undefined
116
+ ? {
117
+ score: last.score,
118
+ reason: last.reason,
119
+ rateWindow: last.rateWindow,
120
+ }
121
+ : {}),
99
122
  lastRateLimitInfo: last.lastRateLimitInfo,
100
123
  }
101
124
  : {}),
@@ -7,6 +7,6 @@ export interface AcquireLockResult {
7
7
  lockToken: string | null;
8
8
  }
9
9
  export declare function acquireSessionLock(threadId: string, workspacePath: string): Promise<AcquireLockResult>;
10
- export declare function updateSessionId(threadId: string, lockToken: string, newSessionId: string, pin?: SubscriptionPin): Promise<boolean>;
10
+ export declare function updateSessionId(threadId: string, lockToken: string, newSessionId: string, pin?: SubscriptionPin, workspacePath?: string): Promise<boolean>;
11
11
  export declare function releaseSessionLock(threadId: string, lockToken: string, newStatus?: SessionStatus, lastMessageTs?: string): Promise<void>;
12
12
  export declare function forceReleaseStalelock(threadId: string, maxAgeMs: number): Promise<boolean>;
@@ -150,15 +150,19 @@ export async function acquireSessionLock(threadId, workspacePath) {
150
150
  throw error;
151
151
  }
152
152
  }
153
- export async function updateSessionId(threadId, lockToken, newSessionId, pin) {
153
+ export async function updateSessionId(threadId, lockToken, newSessionId, pin, workspacePath) {
154
154
  try {
155
155
  await createDocClient().send(new UpdateCommand({
156
156
  TableName: getTableName(),
157
157
  Key: buildKey(threadId),
158
- UpdateExpression: pin ? 'SET session_id = :newSessionId, subscription_pin = :pin' : 'SET session_id = :newSessionId',
158
+ UpdateExpression: 'SET session_id = :newSessionId'
159
+ + (pin ? ', subscription_pin = :pin' : '')
160
+ + (workspacePath ? ', workspace_path = :workspacePath' : '')
161
+ + (workspacePath && !pin ? ' REMOVE subscription_pin' : ''),
159
162
  ConditionExpression: 'lock_token = :lockToken',
160
163
  ExpressionAttributeValues: {
161
164
  ':newSessionId': newSessionId,
165
+ ...(workspacePath ? { ':workspacePath': workspacePath } : {}),
162
166
  ...(pin ? { ':pin': Object.fromEntries(Object.entries(pin).filter(([, value]) => value !== undefined)) } : {}),
163
167
  ':lockToken': lockToken,
164
168
  },
@@ -38,7 +38,10 @@ export function redactStartupText(text, configuredTokens = []) {
38
38
  let safe = text
39
39
  .replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
40
40
  .replace(/\b(?:Authorization\s*:\s*)?Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
41
- .replace(/\bx-access-token\s*:\s*[^\s/@,;]+/gi, 'x-access-token:[REDACTED]');
41
+ .replace(/\bx-access-token\s*:\s*[^\s/@,;]+/gi, 'x-access-token:[REDACTED]')
42
+ // Cover both Claude's token name and the opt-in subprocess alias in
43
+ // shell assignments and JSON env diagnostics, even without a token list.
44
+ .replace(/(\b(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
42
45
  for (const token of configuredTokens) {
43
46
  if (token)
44
47
  safe = safe.split(token).join('[REDACTED]');
@@ -1,3 +1,4 @@
1
+ import { ACCOUNT_PROBE_FAILURE_REASONS } from './usage-probe-contract.js';
1
2
  import { record } from './subscription-selection.js';
2
3
  /** Unwrap only at the optional Claude probe or future child-spawn boundary. */
3
4
  export class SubscriptionCredential {
@@ -109,6 +110,12 @@ export async function fetchSubscriptionPool(input, options) {
109
110
  const raw = envelope['accounts'];
110
111
  if (!Array.isArray(raw) || raw.length > 1000)
111
112
  return { kind: 'fetch_unavailable', reason: 'invalid_response' };
113
+ const failureReason = envelope['probeFailureReason'];
114
+ if (failureReason !== undefined) {
115
+ if (!input.requestId || raw.length || !ACCOUNT_PROBE_FAILURE_REASONS.includes(failureReason))
116
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
117
+ return { kind: 'probe_account_unavailable', reason: failureReason };
118
+ }
112
119
  const poolConfigured = envelope['poolConfigured'];
113
120
  if (('poolConfigured' in envelope && typeof poolConfigured !== 'boolean') ||
114
121
  (poolConfigured === false && raw.length > 0))
@@ -16,6 +16,7 @@ export declare function selectPoolForResolvedModel<A extends AccountIdentity>(re
16
16
  pool: PoolFetchResult<A>;
17
17
  store: SubscriptionUsageStore;
18
18
  now: number;
19
+ currentAccountId?: string;
19
20
  log?: PoolInfoLogger;
20
21
  logAllExhausted?: boolean;
21
22
  }): Promise<ResolvedPoolSelection<A>>;
@@ -23,7 +23,7 @@ export async function selectPoolForResolvedModel(resolvedModel, input) {
23
23
  catch {
24
24
  return { kind: 'usage_unavailable', ...resolvedModel };
25
25
  }
26
- const selected = selectSubscriptionAccount({ pool: input.pool, states, model: resolvedModel.model, now: input.now });
26
+ const selected = selectSubscriptionAccount({ pool: input.pool, states, model: resolvedModel.model, now: input.now, currentAccountId: input.currentAccountId });
27
27
  if (input.log)
28
28
  logPoolSelection(input.log, input.pool, states, resolvedModel.model, input.now, selected, input.logAllExhausted);
29
29
  return { ...selected, ...resolvedModel };
@@ -2,13 +2,13 @@ import { type PoolInfoLogger } from './pool-logging.js';
2
2
  import { type SubscriptionAccount } from './subscription-pool-client.js';
3
3
  import { type ResolvedPoolModel } from './subscription-pool.js';
4
4
  import { FileSubscriptionUsageStore, type SubscriptionUsageStore } from './subscription-usage-store.js';
5
- import { type PoolFetchResult } from './subscription-selection.js';
5
+ import { type PoolFetchResult, type HeadroomScore } from './subscription-selection.js';
6
6
  import type { SubscriptionPin, TeamVibeQueueMessage, SessionMetrics } from './types.js';
7
7
  import type { PoolUsageLimitNotice } from './usage-limit-messages.js';
8
8
  import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
9
9
  import type { ClaudeQuotaFailure } from './claude-quota.js';
10
10
  import type { KilledByPoller } from './classify-failed-iteration.js';
11
- export interface PoolAttempt {
11
+ export interface PoolAttempt extends Partial<HeadroomScore> {
12
12
  workspaceId: string;
13
13
  accountId: string;
14
14
  accountLabel?: string;
@@ -46,10 +46,28 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
46
46
  account: SubscriptionAccount;
47
47
  store: FileSubscriptionUsageStore;
48
48
  pool: PoolFetchResult<SubscriptionAccount>;
49
+ selection: ({
50
+ kind: "selected";
51
+ account: SubscriptionAccount;
52
+ provenance: "known";
53
+ source: "passive" | "probe";
54
+ } & HeadroomScore & {
55
+ model: string | undefined;
56
+ modelSource: import("./claude-spawner.js").ModelSource;
57
+ }) | ({
58
+ kind: "selected";
59
+ account: SubscriptionAccount;
60
+ provenance: "unknown";
61
+ uncertainApplicability: boolean;
62
+ } & {
63
+ model: string | undefined;
64
+ modelSource: import("./claude-spawner.js").ModelSource;
65
+ });
49
66
  } | {
50
67
  account: undefined;
51
68
  store: FileSubscriptionUsageStore;
52
69
  pool: PoolFetchResult<SubscriptionAccount>;
70
+ selection: undefined;
53
71
  }>;
54
72
  export declare function accountPin(account: SubscriptionAccount, resolved: ResolvedPoolModel): SubscriptionPin;
55
73
  export declare function validateAccount(account: SubscriptionAccount, resolved: ResolvedPoolModel): Promise<void>;