@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.
@@ -1,3 +1,4 @@
1
+ import type { PoolDefer } from './pool-defer.js';
1
2
  import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
2
3
  import type { PoolAttempt } from './subscription-runtime.js';
3
4
  import type { TeamVibeQueueMessage, SubscriptionPin } from './types.js';
@@ -81,3 +82,6 @@ export declare function discardHandledInboxDrops(brainPath: string, threadId: st
81
82
  export declare function restartInboxInDirectory(source: string, target: string, threadId: string): void;
82
83
  /** Preserve unroutable work for operator review and remove it from replay queues. */
83
84
  export declare function quarantineInbox(brainPath: string, threadId: string, includeFailed?: boolean): void;
85
+ /** Only original queue deliveries carry inbound identity; synthetic drops never wake holds. */
86
+ export declare function inboxQueueMessageIds(brainPath: string, threadId: string): string[];
87
+ export declare function hasInboxMessageAfter(brainPath: string, threadId: string, pause: Pick<PoolDefer, 'handledInboundIds'>): boolean;
@@ -305,3 +305,32 @@ export function quarantineInbox(brainPath, threadId, includeFailed = false) {
305
305
  renameSync(join(root, name), join(quarantine, `${Date.now()}-${name}`));
306
306
  }
307
307
  }
308
+ /** Only original queue deliveries carry inbound identity; synthetic drops never wake holds. */
309
+ export function inboxQueueMessageIds(brainPath, threadId) {
310
+ const ids = new Set();
311
+ for (const subdir of [PROCESSING_DIR, NEW_DIR]) {
312
+ const directory = join(getInboxPath(brainPath, threadId), subdir);
313
+ let names;
314
+ try {
315
+ names = readdirSync(directory);
316
+ }
317
+ catch {
318
+ continue;
319
+ }
320
+ for (const name of names) {
321
+ if (!name.endsWith('.txt'))
322
+ continue;
323
+ try {
324
+ const message = JSON.parse(readFileSync(join(directory, name), 'utf8'));
325
+ if (message.executionOrigin === 'queue' && message.thread_id === threadId && typeof message.id === 'string' && message.id)
326
+ ids.add(message.id);
327
+ }
328
+ catch { /* Plain background drops and concurrent moves have no inbound identity. */ }
329
+ }
330
+ }
331
+ return [...ids];
332
+ }
333
+ export function hasInboxMessageAfter(brainPath, threadId, pause) {
334
+ const handled = new Set(pause.handledInboundIds ?? []);
335
+ return inboxQueueMessageIds(brainPath, threadId).some(id => !handled.has(id));
336
+ }
@@ -1,7 +1,7 @@
1
1
  import { executionDirectory } from './brain-directory.js';
2
- import { discardHandledInboxDrops, markProcessed, quarantineInbox, readInboxChannelContext, readInboxContext, readInboxRuntime, restartInboxInDirectory } from './inbox-manager.js';
2
+ import { hasInboxMessageAfter, discardHandledInboxDrops, markProcessed, quarantineInbox, readInboxChannelContext, readInboxContext, readInboxRuntime, restartInboxInDirectory } from './inbox-manager.js';
3
3
  import { listThreadRoutings, readThreadRouting, removeThreadRouting, threadRoutingKey } from './thread-routing.js';
4
- import { isPoolDeferred, readPoolDefer } from './pool-defer.js';
4
+ import { observePoolAccounts, isPoolDeferred, readPoolDefer } from './pool-defer.js';
5
5
  import { existsSync, readdirSync, rmSync, watch as fsWatch } from 'fs';
6
6
  import { join } from 'path';
7
7
  import { config } from './config.js';
@@ -11,6 +11,7 @@ import { logger } from './logger.js';
11
11
  import { isAtCapacity } from './claude-spawner.js';
12
12
  import { getTrackedBrains } from './maintenance-cron.js';
13
13
  import { runInboxLoop, isInboxLoopActive } from './poller.js';
14
+ import { fetchRuntimePool } from './subscription-runtime.js';
14
15
  import { isQuotaPaused } from './quota-pause.js';
15
16
  import { emitError } from './event-queue.js';
16
17
  import { sendSlackMessage } from './slack-client.js';
@@ -151,6 +152,7 @@ export async function checkInboxForNewSessions(deps = defaultDeps()) {
151
152
  const expired = await sweepThreadRouting(deps, brains);
152
153
  const started = new Set();
153
154
  const recoveries = [];
155
+ const checkedPools = new Set();
154
156
  scan: for (const brainPath of brains) {
155
157
  if (deps.isAtCapacity())
156
158
  break;
@@ -160,7 +162,7 @@ export async function checkInboxForNewSessions(deps = defaultDeps()) {
160
162
  break scan;
161
163
  if (expired.has(threadRoutingKey(threadId)))
162
164
  continue;
163
- if (started.has(threadId) || recoveringThreads.has(threadId) || deps.isInboxLoopActive(threadId) || isPoolDeferred(threadId))
165
+ if (started.has(threadId) || recoveringThreads.has(threadId) || deps.isInboxLoopActive(threadId))
164
166
  continue;
165
167
  discardHandledInboxDrops(brainPath, threadId);
166
168
  const runtime = readInboxRuntime(brainPath, threadId);
@@ -193,6 +195,17 @@ export async function checkInboxForNewSessions(deps = defaultDeps()) {
193
195
  quarantineRetainedThread(deps, brainPath, threadId, durable);
194
196
  continue;
195
197
  }
198
+ if (isPoolDeferred(threadId)) {
199
+ // Held work still observes pool membership on every watcher tick.
200
+ const key = JSON.stringify([info.workspaceId, info.channelId]);
201
+ if (!checkedPools.has(key)) {
202
+ checkedPools.add(key);
203
+ observePoolAccounts(info.workspaceId, await fetchRuntimePool(message));
204
+ }
205
+ const pause = readPoolDefer(threadId);
206
+ if (isPoolDeferred(threadId) && pause && !hasInboxMessageAfter(brainPath, threadId, pause))
207
+ continue;
208
+ }
196
209
  started.add(threadId);
197
210
  recoveringThreads.add(threadId);
198
211
  recoveries.push((async () => {
@@ -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
- return;
265
+ else if (isPoolDeferred(threadId)) {
266
+ const pause = readPoolDefer(threadId);
267
+ if (!hasInboxMessageAfter(kbPath, threadId, pause))
268
+ return;
269
+ invalidatePoolDefer(threadId, queueMessage.teamvibe.workspaceId, 'new_message', { log: line => sessionLog.info(line) }, inboxQueueMessageIds(kbPath, threadId));
270
+ }
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
- reconcileBgTasksOnBoot();
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
@@ -1,4 +1,16 @@
1
+ import { emitEvent } from './event-queue.js';
2
+ import type { PoolFetchResult } from './subscription-selection.js';
1
3
  export interface PoolDefer {
4
+ /** Additive ownership index; older markers use the existing hashed routing file. */
5
+ scope?: {
6
+ workspaceId: string;
7
+ threadId: string;
8
+ channelId: string;
9
+ };
10
+ /** Enabled capacity known when this hold was last evaluated. */
11
+ poolAccountIds?: string[];
12
+ /** Queue arrivals already considered in this exhaustion episode. */
13
+ handledInboundIds?: string[];
2
14
  createdAt: number;
3
15
  resumeAt: number;
4
16
  resetAt?: string;
@@ -7,7 +19,18 @@ export interface PoolDefer {
7
19
  }
8
20
  export declare function readPoolDefer(threadId: string): PoolDefer | undefined;
9
21
  export declare function isPoolDeferred(threadId: string): boolean;
10
- export declare function deferPool(threadId: string, eligibleAt: number, resetAt?: string): PoolDefer;
22
+ export declare function deferPool(threadId: string, eligibleAt: number, resetAt?: string, handledInboundIds?: string[]): PoolDefer;
11
23
  /** Claim durably before Slack I/O. Ambiguous sends are never blindly replayed. */
12
24
  export declare function setPoolNotice(threadId: string, episodeId: string, notice: PoolDefer['notice']): boolean;
13
25
  export declare function clearPoolDefer(threadId: string): void;
26
+ export type PoolDeferInvalidationReason = 'usage_update' | 'pool_changed' | 'new_message';
27
+ interface InvalidationObserver {
28
+ emit?: typeof emitEvent;
29
+ log?: (line: string) => void;
30
+ }
31
+ /** Expire atomically, retaining the episode and notice claim until a successful spawn. */
32
+ export declare function invalidatePoolDefer(threadId: string, workspaceId: string, reason: PoolDeferInvalidationReason, observer?: InvalidationObserver, handledInboundIds?: string[]): void;
33
+ export declare function invalidateWorkspacePoolDefers(workspaceId: string, reason: Exclude<PoolDeferInvalidationReason, 'new_message'>, observer?: InvalidationObserver): void;
34
+ /** Only authoritative enabled account sets advance the baseline; never cache credentials. */
35
+ export declare function observePoolAccounts(workspaceId: string, pool: PoolFetchResult, observer?: InvalidationObserver): void;
36
+ export {};
@@ -1,19 +1,27 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { config } from './config.js';
5
5
  import { logger } from './logger.js';
6
+ import { emitEvent } from './event-queue.js';
7
+ import { readThreadRouting, threadRoutingKey } from './thread-routing.js';
6
8
  import { POOL_RECHECK_MS } from './subscription-selection.js';
7
9
  function pathFor(threadId) {
8
10
  return join(config.TEAMVIBE_DATA_DIR, 'pool-defer', `${createHash('sha256').update(threadId).digest('hex')}.json`);
9
11
  }
10
12
  const warned = new Set();
11
13
  export function readPoolDefer(threadId) {
12
- const path = pathFor(threadId);
14
+ return readMarker(pathFor(threadId));
15
+ }
16
+ function readMarker(path) {
13
17
  try {
14
18
  const state = JSON.parse(readFileSync(path, 'utf8'));
15
- if (!Number.isFinite(state.createdAt) || !Number.isFinite(state.resumeAt) || state.resumeAt <= state.createdAt || state.resumeAt > 253402300799000 || typeof state.episodeId !== 'string' || !['pending', 'claimed', 'delivered', 'ambiguous'].includes(state.notice))
19
+ if (!Number.isFinite(state.createdAt) || !Number.isFinite(state.resumeAt) || state.resumeAt < state.createdAt || state.resumeAt > 253402300799000 || typeof state.episodeId !== 'string' || !['pending', 'claimed', 'delivered', 'ambiguous'].includes(state.notice))
16
20
  throw new Error('Invalid defer');
21
+ for (const ids of [state.poolAccountIds, state.handledInboundIds]) {
22
+ if (ids !== undefined && (!Array.isArray(ids) || ids.some(id => typeof id !== 'string')))
23
+ throw new Error('Invalid defer baseline');
24
+ }
17
25
  warned.delete(path);
18
26
  return state;
19
27
  }
@@ -56,10 +64,14 @@ function write(threadId, state) {
56
64
  catch { }
57
65
  }
58
66
  }
59
- export function deferPool(threadId, eligibleAt, resetAt) {
67
+ export function deferPool(threadId, eligibleAt, resetAt, handledInboundIds = []) {
60
68
  const previous = readPoolDefer(threadId);
61
69
  const now = Date.now();
62
- const state = { createdAt: now, resumeAt: Number.isFinite(eligibleAt) && eligibleAt > now ? eligibleAt : now + POOL_RECHECK_MS, episodeId: previous?.episodeId ?? randomUUID(), notice: previous?.notice ?? 'pending', ...(resetAt ? { resetAt } : {}) };
70
+ const routing = readThreadRouting(threadId);
71
+ const scope = routing ? { workspaceId: routing.workspaceId, channelId: routing.channelId, threadId } : previous?.scope;
72
+ const fingerprint = scope ? poolFingerprints.get(poolKey(scope.workspaceId)) : undefined;
73
+ const poolAccountIds = fingerprint === undefined ? previous?.poolAccountIds : JSON.parse(fingerprint);
74
+ const state = { ...(scope ? { scope } : {}), ...(poolAccountIds ? { poolAccountIds } : {}), handledInboundIds: [...new Set([...(previous?.handledInboundIds ?? []), ...handledInboundIds])], createdAt: now, resumeAt: Number.isFinite(eligibleAt) && eligibleAt > now ? eligibleAt : now + POOL_RECHECK_MS, episodeId: previous?.episodeId ?? randomUUID(), notice: previous?.notice ?? 'pending', ...(resetAt ? { resetAt } : {}) };
63
75
  write(threadId, state);
64
76
  return state;
65
77
  }
@@ -79,3 +91,87 @@ export function clearPoolDefer(threadId) {
79
91
  logger.warn('[PoolDefer] Completed episode cleanup will be retried');
80
92
  }
81
93
  }
94
+ /** Expire atomically, retaining the episode and notice claim until a successful spawn. */
95
+ export function invalidatePoolDefer(threadId, workspaceId, reason, observer = {}, handledInboundIds = []) {
96
+ const state = readPoolDefer(threadId);
97
+ if (!state || !isPoolDeferred(threadId))
98
+ return;
99
+ const routing = readThreadRouting(threadId);
100
+ const scope = state.scope ?? (routing ? { ...routing, threadId } : undefined);
101
+ if (!scope || scope.workspaceId !== workspaceId)
102
+ return;
103
+ write(threadId, { ...state, resumeAt: state.createdAt, handledInboundIds: [...new Set([...(state.handledInboundIds ?? []), ...handledInboundIds])] });
104
+ const metadata = { workspaceId, threadId, reason, episodeId: state.episodeId };
105
+ // Observability cannot undo the durable wake-up.
106
+ try {
107
+ (observer.log ?? (line => logger.info(line)))(`[Pool] defer invalidated workspace=${JSON.stringify(workspaceId)} thread=${JSON.stringify(threadId)} reason=${reason} episode=${JSON.stringify(state.episodeId)}`);
108
+ }
109
+ catch { }
110
+ try {
111
+ (observer.emit ?? emitEvent)('pool.defer_invalidated', scope.channelId, metadata);
112
+ }
113
+ catch { }
114
+ }
115
+ /** One directory scan, one bounded routing lookup per legacy marker; no inbox scan. */
116
+ function* workspacePoolDefers(workspaceId) {
117
+ const root = join(config.TEAMVIBE_DATA_DIR, 'pool-defer');
118
+ let names;
119
+ try {
120
+ names = readdirSync(root);
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ for (const name of names) {
126
+ if (!/^[a-f0-9]{64}\.json$/.test(name))
127
+ continue;
128
+ try {
129
+ const state = readMarker(join(root, name));
130
+ if (!state)
131
+ continue;
132
+ let scope = state.scope;
133
+ if (!scope) {
134
+ // Pre-#572 markers share their hash with durable thread routing.
135
+ scope = JSON.parse(readFileSync(join(config.TEAMVIBE_DATA_DIR, 'thread-routing', name), 'utf8'));
136
+ }
137
+ if (scope?.workspaceId === workspaceId && typeof scope.threadId === 'string' && threadRoutingKey(scope.threadId) === name)
138
+ yield { state, scope };
139
+ }
140
+ catch {
141
+ logger.warn('[PoolDefer] Workspace invalidation will be retried');
142
+ }
143
+ }
144
+ }
145
+ export function invalidateWorkspacePoolDefers(workspaceId, reason, observer = {}) {
146
+ for (const { scope } of workspacePoolDefers(workspaceId))
147
+ invalidatePoolDefer(scope.threadId, workspaceId, reason, observer);
148
+ }
149
+ const poolFingerprints = new Map();
150
+ function poolKey(workspaceId) {
151
+ return JSON.stringify([config.TEAMVIBE_DATA_DIR, workspaceId]);
152
+ }
153
+ /** Only authoritative enabled account sets advance the baseline; never cache credentials. */
154
+ export function observePoolAccounts(workspaceId, pool, observer = {}) {
155
+ if (pool.kind !== 'ready' && pool.kind !== 'no_accounts_configured' && !(pool.kind === 'fetch_unavailable' && pool.reason === 'no_usable_accounts'))
156
+ return;
157
+ const poolAccountIds = pool.kind === 'ready' ? [...new Set(pool.accounts.filter(a => a.enabled && a.workspaceId === workspaceId).map(a => a.accountId))].sort() : [];
158
+ const fingerprint = JSON.stringify(poolAccountIds);
159
+ const key = poolKey(workspaceId);
160
+ if (poolFingerprints.get(key) === fingerprint)
161
+ return;
162
+ // The first fetch after boot also compares the durable per-marker baselines.
163
+ for (const { state, scope } of workspacePoolDefers(workspaceId)) {
164
+ const now = Date.now();
165
+ if (now < state.createdAt || now >= state.resumeAt)
166
+ continue;
167
+ if (state.poolAccountIds && poolAccountIds.some(id => !state.poolAccountIds.includes(id))) {
168
+ invalidatePoolDefer(scope.threadId, workspaceId, 'pool_changed', observer);
169
+ }
170
+ else {
171
+ // Reductions never add capacity. Adopt them so a later re-enable wakes work.
172
+ // A legacy marker adopts its first observed baseline without waking work.
173
+ write(scope.threadId, { ...state, poolAccountIds });
174
+ }
175
+ }
176
+ poolFingerprints.set(key, fingerprint);
177
+ }
@@ -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
  }
@@ -1,2 +1,4 @@
1
- /** Redact credentials in diagnostics without initializing poller authentication. */
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;
@@ -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. */
@@ -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
- * worktree under `<brainPath>/.worktrees/` whose directory mtime is older
18
- * than `maxAgeMs`. Intended to run once at poller startup.
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[];