@teamvibe/poller 0.8.6 → 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.
- package/bin/tv-git-credential.mjs +39 -0
- package/dist/auth-provider.js +8 -1
- package/dist/bg-task-registry.d.ts +12 -0
- package/dist/bg-task-registry.js +65 -0
- package/dist/brain-manager.d.ts +4 -0
- package/dist/brain-manager.js +87 -14
- package/dist/brains-bootstrap.js +6 -4
- package/dist/claude-spawner.d.ts +3 -0
- package/dist/claude-spawner.js +54 -21
- package/dist/connections-client.d.ts +66 -0
- package/dist/connections-client.js +128 -0
- package/dist/event-queue.js +11 -0
- package/dist/git-credential-env.d.ts +3 -0
- package/dist/git-credential-env.js +37 -0
- package/dist/git-credential-session.d.ts +33 -0
- package/dist/git-credential-session.js +265 -0
- package/dist/idle-restart.d.ts +1 -1
- package/dist/idle-restart.js +7 -62
- package/dist/inbox-manager.d.ts +4 -0
- package/dist/inbox-manager.js +29 -0
- package/dist/inbox-watcher.js +16 -3
- package/dist/maintenance-cron.d.ts +6 -0
- package/dist/poller.js +16 -7
- package/dist/pool-defer.d.ts +24 -1
- package/dist/pool-defer.js +101 -5
- package/dist/pool-telemetry.js +1 -0
- package/dist/redact-text.d.ts +3 -1
- package/dist/redact-text.js +17 -1
- package/dist/session-instance.d.ts +9 -0
- package/dist/session-instance.js +36 -0
- package/dist/subscription-runtime.js +2 -0
- package/dist/usage-probe-job.d.ts +2 -0
- package/dist/usage-probe-job.js +7 -1
- package/dist/worktree-manager.d.ts +7 -4
- package/dist/worktree-manager.js +94 -36
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
package/dist/inbox-manager.d.ts
CHANGED
|
@@ -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;
|
package/dist/inbox-manager.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/inbox-watcher.js
CHANGED
|
@@ -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)
|
|
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 () => {
|
|
@@ -37,6 +37,12 @@ export interface BootstrapBrainInfo {
|
|
|
37
37
|
botId: string;
|
|
38
38
|
botToken: string;
|
|
39
39
|
channelId: string;
|
|
40
|
+
connectionId?: string;
|
|
41
|
+
credentialVersion?: string;
|
|
42
|
+
bindingVersion?: number;
|
|
43
|
+
channelConnectionId?: string;
|
|
44
|
+
channelCredentialVersion?: string;
|
|
45
|
+
channelBindingVersion?: number;
|
|
40
46
|
}
|
|
41
47
|
/**
|
|
42
48
|
* Seed trackedBrains at startup from the backend's authoritative brain list
|
package/dist/poller.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { startGitCredentialJanitor, registeredGitCredentialTasks } from './git-credential-session.js';
|
|
1
2
|
import { isUsageProbe, handleUsageProbe } from './usage-probe-job.js';
|
|
2
3
|
import { fetchSubscriptionPool } from './subscription-pool-client.js';
|
|
3
4
|
import { formatPoolUsageLimitMessage } from './usage-limit-messages.js';
|
|
@@ -13,7 +14,7 @@ import { classifyFailedIteration } from './classify-failed-iteration.js';
|
|
|
13
14
|
import { fetchSecretsForSpawn } from './secrets-client.js';
|
|
14
15
|
import { PoolDeferredError, fetchRuntimePool, subscriptionUsageStore, HELD_PROBE_INTERVAL_MS } from './subscription-runtime.js';
|
|
15
16
|
import { POOL_RECHECK_MS } from './subscription-selection.js';
|
|
16
|
-
import { isPoolDeferred, readPoolDefer, deferPool, setPoolNotice, clearPoolDefer } from './pool-defer.js';
|
|
17
|
+
import { invalidatePoolDefer, invalidateWorkspacePoolDefers, observePoolAccounts, isPoolDeferred, readPoolDefer, deferPool, setPoolNotice, clearPoolDefer } from './pool-defer.js';
|
|
17
18
|
import { isQuotaPaused, pauseForQuota, clearQuotaPauseAfterSuccess, QuotaPausedError } from './quota-pause.js';
|
|
18
19
|
import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
|
|
19
20
|
import { ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
|
|
@@ -25,7 +26,7 @@ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './main
|
|
|
25
26
|
import { bootstrapTrackedBrains } from './brains-bootstrap.js';
|
|
26
27
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
|
|
27
28
|
import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
|
|
28
|
-
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';
|
|
29
30
|
import { saveThreadRouting } from './thread-routing.js';
|
|
30
31
|
import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
|
|
31
32
|
import { Cron } from 'croner';
|
|
@@ -256,12 +257,17 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
|
|
|
256
257
|
if (isQuotaPaused() && !getPollerId())
|
|
257
258
|
return;
|
|
258
259
|
const initialPool = await fetchRuntimePool(queueMessage);
|
|
260
|
+
observePoolAccounts(queueMessage.teamvibe.workspaceId, initialPool, { log: line => sessionLog.info(line) });
|
|
259
261
|
if (initialPool.kind === 'no_accounts_configured') {
|
|
260
262
|
if (isQuotaPaused())
|
|
261
263
|
return;
|
|
262
264
|
}
|
|
263
|
-
else if (isPoolDeferred(threadId))
|
|
264
|
-
|
|
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
|
+
}
|
|
265
271
|
if (activeLoops.has(threadId)) {
|
|
266
272
|
sessionLog.info(`Loop already running for thread ${threadId}, skipping`);
|
|
267
273
|
return;
|
|
@@ -369,6 +375,7 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
|
|
|
369
375
|
// Dispatch before ANY pool state/context/model preparation. The explicit
|
|
370
376
|
// no-pool outcome runs the original spawn and #408 pause gates.
|
|
371
377
|
const pool = iteration === 0 ? initialPool : await fetchRuntimePool(queueMessage);
|
|
378
|
+
observePoolAccounts(queueMessage.teamvibe.workspaceId, pool, { log: line => sessionLog.info(line) });
|
|
372
379
|
poolIteration = pool.kind !== 'no_accounts_configured';
|
|
373
380
|
if (!poolIteration && isQuotaPaused())
|
|
374
381
|
break;
|
|
@@ -620,7 +627,7 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
|
|
|
620
627
|
// Revisit held work hourly so background probes can refresh the account.
|
|
621
628
|
// Selection still enforces each window's real reset before inference.
|
|
622
629
|
const recheckAt = error.cause === 'usage' ? Math.min(error.eligibleAt, Date.now() + HELD_PROBE_INTERVAL_MS) : error.eligibleAt;
|
|
623
|
-
const pause = deferPool(threadId, recheckAt, error.resetAt);
|
|
630
|
+
const pause = deferPool(threadId, recheckAt, error.resetAt, inboxQueueMessageIds(kbPath, threadId));
|
|
624
631
|
const retained = readInboxRuntime(kbPath, threadId);
|
|
625
632
|
// Rechecks of one exhaustion episode do not inflate the episode count.
|
|
626
633
|
quotaDeferEpisodes = Math.max(1, quotaDeferEpisodes);
|
|
@@ -900,7 +907,7 @@ async function processMessage(received) {
|
|
|
900
907
|
const kbPath = directory.path;
|
|
901
908
|
// Write raw message to inbox (spawnClaudeCode handles prompt formatting)
|
|
902
909
|
const { botToken: _inboxToken, ...inboxTeamvibe } = queueMessage.teamvibe;
|
|
903
|
-
writeMessage(kbPath, threadId, JSON.stringify({ ...queueMessage, teamvibe: inboxTeamvibe }));
|
|
910
|
+
writeMessage(kbPath, threadId, JSON.stringify({ ...queueMessage, executionOrigin: receiptHandle ? 'queue' : 'maintenance', teamvibe: inboxTeamvibe }));
|
|
904
911
|
sessionLog.info(`Wrote message to inbox`);
|
|
905
912
|
// Delete SQS message immediately — it's now persisted in the inbox
|
|
906
913
|
if (receiptHandle) {
|
|
@@ -1028,6 +1035,7 @@ export async function pollLoop() {
|
|
|
1028
1035
|
apiUrl: config.TEAMVIBE_API_URL,
|
|
1029
1036
|
pollerToken: config.TEAMVIBE_POLLER_TOKEN || '',
|
|
1030
1037
|
}),
|
|
1038
|
+
onHeadroom: workspaceId => invalidateWorkspacePoolDefers(workspaceId, 'usage_update'),
|
|
1031
1039
|
store: subscriptionUsageStore(),
|
|
1032
1040
|
cliPath: config.CLAUDE_CLI_PATH,
|
|
1033
1041
|
isAtCapacity,
|
|
@@ -1232,7 +1240,8 @@ export async function startPoller() {
|
|
|
1232
1240
|
logger.error(`Startup base brain failed permanently: ${startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO])}`);
|
|
1233
1241
|
process.exit(1);
|
|
1234
1242
|
}
|
|
1235
|
-
|
|
1243
|
+
startGitCredentialJanitor();
|
|
1244
|
+
reconcileBgTasksOnBoot(registeredGitCredentialTasks());
|
|
1236
1245
|
if (config.IDLE_RESTART_ENABLED) {
|
|
1237
1246
|
// Defaults off (tv#317 follow-up, DevGuru review): a silent disabled gate and an
|
|
1238
1247
|
// enabled gate that just hasn't hit its threshold yet look identical in production
|
package/dist/pool-defer.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/pool-defer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/pool-telemetry.js
CHANGED
|
@@ -139,6 +139,7 @@ export function emitPoolSelection(msg, selection) {
|
|
|
139
139
|
selectedAccountLabel: safeText(selection.account.label) ?? 'unlabeled',
|
|
140
140
|
decidedAt: lastSelectionAt,
|
|
141
141
|
candidates: selection.candidates ?? [],
|
|
142
|
+
...(selection.pinned ? { pinned: true } : {}),
|
|
142
143
|
...(selection.switch ? { switch: selection.switch } : {}),
|
|
143
144
|
});
|
|
144
145
|
}
|
package/dist/redact-text.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export declare function registerConfiguredToken(token: string): void;
|
|
2
|
+
export declare function redactRepoUrl(url: string): string;
|
|
3
|
+
export declare function redactGitDiagnostic(text: string): string;
|
|
2
4
|
export declare function redactStartupText(text: string, configuredTokens?: readonly string[]): string;
|
package/dist/redact-text.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
/** Redact credentials in diagnostics without initializing poller authentication. */
|
|
2
|
+
const issuedTokens = new Set();
|
|
3
|
+
export function registerConfiguredToken(token) { if (token)
|
|
4
|
+
issuedTokens.add(token); }
|
|
5
|
+
export function redactRepoUrl(url) {
|
|
6
|
+
return redactGitDiagnostic(url.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, '$1<redacted>@'));
|
|
7
|
+
}
|
|
8
|
+
export function redactGitDiagnostic(text) {
|
|
9
|
+
let safe = text
|
|
10
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, '$1<redacted>@')
|
|
11
|
+
.replace(/\b(?:x-access-token|oauth2):[^\s/@]+/gi, match => match.split(':')[0] + ':<redacted>')
|
|
12
|
+
.replace(/\bgh[pos]_[A-Za-z0-9]{20,}\b/g, '<redacted>');
|
|
13
|
+
for (const token of issuedTokens)
|
|
14
|
+
if (token)
|
|
15
|
+
safe = safe.split(token).join('<redacted>');
|
|
16
|
+
return safe;
|
|
17
|
+
}
|
|
2
18
|
export function redactStartupText(text, configuredTokens = []) {
|
|
3
19
|
let safe = text
|
|
4
20
|
.replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
|
|
@@ -7,7 +23,7 @@ export function redactStartupText(text, configuredTokens = []) {
|
|
|
7
23
|
// Cover both Claude's token name and the opt-in subprocess alias in
|
|
8
24
|
// shell assignments and JSON env diagnostics, even without a token list.
|
|
9
25
|
.replace(/(\b(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
|
|
10
|
-
for (const token of configuredTokens) {
|
|
26
|
+
for (const token of [...issuedTokens, ...configuredTokens]) {
|
|
11
27
|
if (token)
|
|
12
28
|
safe = safe.split(token).join('[REDACTED]');
|
|
13
29
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SessionInstance {
|
|
2
|
+
sessionInstanceId: string;
|
|
3
|
+
createdAt: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function createSessionInstance(): SessionInstance;
|
|
6
|
+
export declare function readSessionOwner(path: string): SessionInstance | undefined;
|
|
7
|
+
export declare function writeSessionOwner(path: string, owner: SessionInstance): void;
|
|
8
|
+
export declare function sameSessionInstance(a: SessionInstance | undefined, b: SessionInstance | undefined): boolean;
|
|
9
|
+
export declare function reportSessionOwnerMismatch(owner: SessionInstance, current?: SessionInstance): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
export function createSessionInstance() {
|
|
5
|
+
return { sessionInstanceId: randomUUID(), createdAt: new Date().toISOString() };
|
|
6
|
+
}
|
|
7
|
+
export function readSessionOwner(path) {
|
|
8
|
+
try {
|
|
9
|
+
const owner = JSON.parse(readFileSync(path, 'utf8'));
|
|
10
|
+
if (typeof owner.sessionInstanceId === 'string' && typeof owner.createdAt === 'string')
|
|
11
|
+
return owner;
|
|
12
|
+
}
|
|
13
|
+
catch { /* Missing or malformed ownership is never permission to delete. */ }
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
export function writeSessionOwner(path, owner) {
|
|
17
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(temporary, JSON.stringify({ sessionInstanceId: owner.sessionInstanceId, createdAt: owner.createdAt }), { mode: 0o600, flag: 'wx' });
|
|
20
|
+
chmodSync(temporary, 0o600);
|
|
21
|
+
renameSync(temporary, path);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
rmSync(temporary, { force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function sameSessionInstance(a, b) {
|
|
28
|
+
return a !== undefined && b !== undefined && a.sessionInstanceId === b.sessionInstanceId && a.createdAt === b.createdAt;
|
|
29
|
+
}
|
|
30
|
+
const reported = new Set();
|
|
31
|
+
export function reportSessionOwnerMismatch(owner, current) {
|
|
32
|
+
if (reported.has(owner.sessionInstanceId))
|
|
33
|
+
return;
|
|
34
|
+
reported.add(owner.sessionInstanceId);
|
|
35
|
+
logger.warn(`[GitCredential] Cleanup ownership changed; retaining successor worktree and credentials (expected ${owner.sessionInstanceId}, actual ${current?.sessionInstanceId ?? 'missing-or-malformed'})`);
|
|
36
|
+
}
|
|
@@ -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. */
|
package/dist/usage-probe-job.js
CHANGED
|
@@ -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,3 +1,4 @@
|
|
|
1
|
+
import { type SessionInstance } from './session-instance.js';
|
|
1
2
|
/**
|
|
2
3
|
* Create a fresh worktree for a spawned session. Returns the absolute path of
|
|
3
4
|
* the worktree, which the caller should use as the spawned process's `cwd`.
|
|
@@ -5,17 +6,19 @@
|
|
|
5
6
|
* Idempotent against existing worktrees at the same path: if one is found,
|
|
6
7
|
* it's removed and recreated to guarantee a clean start.
|
|
7
8
|
*/
|
|
8
|
-
export declare function createWorktree(brainPath: string, sessionId: string, branch?: string): Promise<string>;
|
|
9
|
+
export declare function createWorktree(brainPath: string, sessionId: string, branch?: string, instance?: SessionInstance): Promise<string>;
|
|
9
10
|
/**
|
|
10
11
|
* Remove a worktree by session ID. Force-removes so dirty state from a crashed
|
|
11
12
|
* session doesn't block cleanup — the contract is that any session work the
|
|
12
13
|
* caller wanted to preserve has already been pushed to `origin/<branch>`.
|
|
13
14
|
*/
|
|
14
|
-
export declare function removeWorktree(brainPath: string, sessionId: string): Promise<void>;
|
|
15
|
+
export declare function removeWorktree(brainPath: string, sessionId: string, instance?: SessionInstance | null): Promise<void>;
|
|
15
16
|
/**
|
|
16
17
|
* Sweep orphan worktrees left behind by crashed sessions. Removes every
|
|
17
|
-
*
|
|
18
|
-
* than `maxAgeMs`.
|
|
18
|
+
* known-dead instance immediately, or an unregistered/legacy orphan older
|
|
19
|
+
* than `maxAgeMs`. Live sessions and registered tasks always retain their
|
|
20
|
+
* worktrees, including those with missing, malformed or changed markers.
|
|
21
|
+
* Intended to run once at poller startup.
|
|
19
22
|
*/
|
|
20
23
|
export declare function cleanupOrphanWorktrees(brainPath: string, maxAgeMs?: number): Promise<{
|
|
21
24
|
removed: string[];
|