@teamvibe/poller 0.8.3 → 0.8.4-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/brain-directory.d.ts +14 -0
- package/dist/brain-directory.js +16 -0
- package/dist/brain-manager.d.ts +2 -2
- package/dist/brain-manager.js +32 -19
- package/dist/brain-path.d.ts +9 -0
- package/dist/brain-path.js +65 -0
- package/dist/claude-spawner.d.ts +3 -2
- package/dist/claude-spawner.js +195 -74
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/event-queue.js +14 -1
- package/dist/inbox-manager.d.ts +11 -0
- package/dist/inbox-manager.js +101 -6
- package/dist/inbox-watcher.d.ts +9 -4
- package/dist/inbox-watcher.js +219 -49
- package/dist/maintenance-cron.d.ts +4 -2
- package/dist/maintenance-cron.js +56 -14
- package/dist/metrics/scheduler.d.ts +4 -1
- package/dist/metrics/scheduler.js +8 -6
- package/dist/poller.d.ts +2 -1
- package/dist/poller.js +50 -24
- package/dist/pool-logging.js +2 -2
- package/dist/pool-telemetry.d.ts +21 -5
- package/dist/pool-telemetry.js +28 -11
- package/dist/session-store.d.ts +1 -1
- package/dist/session-store.js +6 -2
- package/dist/startup-auth.js +4 -1
- package/dist/subscription-pool-client.js +7 -0
- package/dist/subscription-pool.d.ts +1 -0
- package/dist/subscription-pool.js +1 -1
- package/dist/subscription-runtime.d.ts +21 -3
- package/dist/subscription-runtime.js +17 -9
- package/dist/subscription-selection.d.ts +20 -3
- package/dist/subscription-selection.js +66 -4
- package/dist/subscription-usage-store.d.ts +2 -0
- package/dist/subscription-usage-store.js +41 -20
- package/dist/thread-routing.d.ts +23 -0
- package/dist/thread-routing.js +81 -0
- package/dist/types.d.ts +4 -10
- package/dist/usage-limit-messages.d.ts +1 -1
- package/dist/usage-limit-messages.js +1 -1
- package/dist/usage-probe-contract.d.ts +15 -0
- package/dist/usage-probe-contract.js +14 -0
- package/dist/usage-probe-job.js +20 -7
- package/dist/worktree-manager.d.ts +2 -1
- package/dist/worktree-manager.js +38 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/maintenance-cron.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
84
|
+
if (trackedBrains.has(`${b.workspaceId}:${b.channelId}`))
|
|
84
85
|
continue;
|
|
85
|
-
|
|
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
|
|
111
|
-
trackedBrains.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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_${
|
|
199
|
+
id: `maintenance_${key}_${now}`,
|
|
158
200
|
source: 'maintenance',
|
|
159
|
-
thread_id: `maintenance_${
|
|
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 {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 =
|
|
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,
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
288
|
-
|
|
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(
|
|
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
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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 (
|
|
532
|
-
await pushBrainChanges(
|
|
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
|
|
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
|
|
866
|
+
let directory;
|
|
842
867
|
try {
|
|
843
|
-
|
|
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,
|
|
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}`);
|
package/dist/pool-logging.js
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
}
|
package/dist/pool-telemetry.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type StoredAccountUsage, type SubscriptionUsageStore } from './subscrip
|
|
|
3
3
|
import type { PoolAttempt } from './subscription-runtime.js';
|
|
4
4
|
import type { SessionMetrics, TeamVibeQueueMessage } from './types.js';
|
|
5
5
|
/** Explicit transport DTO: never serialize a pool account/credential envelope. */
|
|
6
|
-
export declare function accountUsageTelemetry(account: SubscriptionAccount, state: StoredAccountUsage): {
|
|
6
|
+
export declare function accountUsageTelemetry(account: SubscriptionAccount, state: StoredAccountUsage, reportedAt?: number): {
|
|
7
7
|
reactive: {
|
|
8
8
|
observedAt: number;
|
|
9
9
|
window: import("./claude-rate-limit.js").ClaudeQuotaWindow | undefined;
|
|
@@ -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
|
};
|
package/dist/pool-telemetry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { emitEvent } from './event-queue.js';
|
|
2
|
-
import {
|
|
3
|
-
import { safeQuota, safeRateLimitInfo, } from './subscription-usage-store.js';
|
|
2
|
+
import { reactiveEligibleAt } from './subscription-selection.js';
|
|
3
|
+
import { usageStateForSelection, safeQuota, safeRateLimitInfo, } from './subscription-usage-store.js';
|
|
4
4
|
function safeText(value) {
|
|
5
5
|
return value && value.length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(value)
|
|
6
6
|
? value
|
|
@@ -30,18 +30,12 @@ function safeMetrics(metrics) {
|
|
|
30
30
|
return result;
|
|
31
31
|
}
|
|
32
32
|
/** Explicit transport DTO: never serialize a pool account/credential envelope. */
|
|
33
|
-
export function accountUsageTelemetry(account, state) {
|
|
34
|
-
const
|
|
35
|
-
? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt)
|
|
36
|
-
: undefined;
|
|
37
|
-
const probe = state.probe?.usage;
|
|
38
|
-
const usage = passive && (!probe || passive.observedAt >= probe.observedAt)
|
|
39
|
-
? passive
|
|
40
|
-
: probe;
|
|
33
|
+
export function accountUsageTelemetry(account, state, reportedAt = Date.now()) {
|
|
34
|
+
const { usage } = usageStateForSelection(state, reportedAt);
|
|
41
35
|
return {
|
|
42
36
|
accountId: account.accountId,
|
|
43
37
|
accountLabel: account.label ?? account.accountId,
|
|
44
|
-
reportedAt
|
|
38
|
+
reportedAt,
|
|
45
39
|
...(usage ? { usage } : {}),
|
|
46
40
|
...(state.probe
|
|
47
41
|
? {
|
|
@@ -82,6 +76,22 @@ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
|
|
|
82
76
|
success: a.success,
|
|
83
77
|
exitCode: a.exitCode,
|
|
84
78
|
observedAt: a.observedAt,
|
|
79
|
+
...(typeof a.score === 'number' &&
|
|
80
|
+
Number.isFinite(a.score) &&
|
|
81
|
+
a.score >= 0 &&
|
|
82
|
+
a.reason === 'headroom_per_hour' &&
|
|
83
|
+
a.rateWindow
|
|
84
|
+
? {
|
|
85
|
+
score: a.score,
|
|
86
|
+
reason: a.reason,
|
|
87
|
+
rateWindow: {
|
|
88
|
+
window: a.rateWindow.window,
|
|
89
|
+
scope: a.rateWindow.scope,
|
|
90
|
+
percent: a.rateWindow.percent,
|
|
91
|
+
resetAt: a.rateWindow.resetAt,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
85
95
|
quota: a.quota ? safeQuota(a.quota) : undefined,
|
|
86
96
|
metrics: safeMetrics(a.metrics),
|
|
87
97
|
lastRateLimitInfo: a.rateLimitInfo
|
|
@@ -96,6 +106,13 @@ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
|
|
|
96
106
|
accountLabel: last.accountLabel,
|
|
97
107
|
model: last.model,
|
|
98
108
|
observedAt: last.observedAt,
|
|
109
|
+
...(last.score !== undefined
|
|
110
|
+
? {
|
|
111
|
+
score: last.score,
|
|
112
|
+
reason: last.reason,
|
|
113
|
+
rateWindow: last.rateWindow,
|
|
114
|
+
}
|
|
115
|
+
: {}),
|
|
99
116
|
lastRateLimitInfo: last.lastRateLimitInfo,
|
|
100
117
|
}
|
|
101
118
|
: {}),
|
package/dist/session-store.d.ts
CHANGED
|
@@ -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>;
|
package/dist/session-store.js
CHANGED
|
@@ -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:
|
|
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
|
},
|
package/dist/startup-auth.js
CHANGED
|
@@ -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 };
|