@teamvibe/poller 0.8.2-rc.2 → 0.8.3-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/classify-failed-iteration.d.ts +7 -0
  2. package/dist/classify-failed-iteration.js +12 -0
  3. package/dist/claude-quota.d.ts +15 -0
  4. package/dist/claude-quota.js +188 -0
  5. package/dist/claude-rate-limit.d.ts +19 -0
  6. package/dist/claude-rate-limit.js +44 -0
  7. package/dist/claude-spawner.d.ts +15 -0
  8. package/dist/claude-spawner.js +323 -25
  9. package/dist/config-client.d.ts +2 -0
  10. package/dist/config-client.js +3 -0
  11. package/dist/event-queue.js +2 -2
  12. package/dist/inbox-manager.d.ts +22 -1
  13. package/dist/inbox-manager.js +57 -3
  14. package/dist/inbox-watcher.d.ts +1 -1
  15. package/dist/inbox-watcher.js +42 -20
  16. package/dist/poller.d.ts +1 -0
  17. package/dist/poller.js +316 -54
  18. package/dist/pool-defer.d.ts +13 -0
  19. package/dist/pool-defer.js +81 -0
  20. package/dist/pool-logging.d.ts +18 -0
  21. package/dist/pool-logging.js +79 -0
  22. package/dist/pool-telemetry.d.ts +44 -0
  23. package/dist/pool-telemetry.js +107 -0
  24. package/dist/quota-pause.d.ts +16 -0
  25. package/dist/quota-pause.js +96 -0
  26. package/dist/session-store.d.ts +2 -2
  27. package/dist/session-store.js +6 -3
  28. package/dist/slack-client.d.ts +4 -1
  29. package/dist/slack-client.js +19 -1
  30. package/dist/subscription-pool-client.d.ts +35 -0
  31. package/dist/subscription-pool-client.js +143 -0
  32. package/dist/subscription-pool.d.ts +21 -0
  33. package/dist/subscription-pool.js +30 -0
  34. package/dist/subscription-runtime.d.ts +57 -0
  35. package/dist/subscription-runtime.js +126 -0
  36. package/dist/subscription-selection.d.ts +77 -0
  37. package/dist/subscription-selection.js +157 -0
  38. package/dist/subscription-usage-probe.d.ts +16 -0
  39. package/dist/subscription-usage-probe.js +106 -0
  40. package/dist/subscription-usage-store.d.ts +50 -0
  41. package/dist/subscription-usage-store.js +223 -0
  42. package/dist/types.d.ts +35 -10
  43. package/dist/usage-limit-messages.d.ts +22 -0
  44. package/dist/usage-limit-messages.js +44 -0
  45. package/dist/usage-probe-job.d.ts +17 -0
  46. package/dist/usage-probe-job.js +186 -0
  47. package/dist/worktree-manager.js +4 -0
  48. package/npm-shrinkwrap.json +78 -77
  49. package/package.json +1 -1
@@ -0,0 +1,18 @@
1
+ import type { SessionLogger } from './logger.js';
2
+ import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
3
+ import { type AccountIdentity, type AccountUsageState, type PoolFetchResult, type SelectionOutcome } from './subscription-selection.js';
4
+ export type PoolInfoLogger = Pick<SessionLogger, 'info'>;
5
+ type LabeledAccount = AccountIdentity & {
6
+ label?: string;
7
+ };
8
+ export declare function poolInfo(log: PoolInfoLogger, message: string): void;
9
+ export declare function poolLabel(account: {
10
+ label?: string;
11
+ }): string;
12
+ export declare function logPoolFetch(log: PoolInfoLogger, pool: PoolFetchResult): void;
13
+ export declare function logPoolSelection<A extends LabeledAccount>(log: PoolInfoLogger, pool: PoolFetchResult<A>, states: Readonly<Record<string, AccountUsageState>>, model: string | undefined, now: number, selected: SelectionOutcome<A>, logAllExhausted?: boolean): void;
14
+ /** Log the real store promise without changing its rejection or retry behavior. */
15
+ export declare function logPoolUsageWrite(log: PoolInfoLogger, account: {
16
+ label?: string;
17
+ }, info: ClaudeRateLimitInfo | undefined, write: () => Promise<void>, source?: 'passive' | 'quota'): Promise<void>;
18
+ export {};
@@ -0,0 +1,79 @@
1
+ import { redactStartupText } from './startup-auth.js';
2
+ import { normalizePassiveUsage, selectSubscriptionAccount } from './subscription-selection.js';
3
+ import { safeRateLimitInfo } from './subscription-usage-store.js';
4
+ // Never serialize accounts, credentials, provider strings or caught errors.
5
+ // Labels have already been checked by the pool client; JSON quoting keeps
6
+ // arbitrary model/label whitespace on one physical log line.
7
+ export function poolInfo(log, message) {
8
+ log.info(redactStartupText(`[Pool] ${message}`).replace(/[\r\n]/g, ' '));
9
+ }
10
+ export function poolLabel(account) {
11
+ const label = account.label;
12
+ return JSON.stringify(label && label.length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(label) ? label : 'unlabeled');
13
+ }
14
+ export function logPoolFetch(log, pool) {
15
+ if (pool.kind === 'no_accounts_configured')
16
+ return;
17
+ // The API exposes usable members only; hidden disabled labels are unavailable.
18
+ const enabled = pool.kind === 'ready' ? pool.accounts.filter(a => a.enabled).length : pool.kind === 'fetch_unavailable' && pool.reason === 'no_usable_accounts' ? 0 : 'unknown';
19
+ const configured = pool.kind === 'ready' || pool.kind === 'fetch_unavailable' && pool.reason === 'no_usable_accounts' ? 'true' : 'unknown';
20
+ poolInfo(log, `fetch poolConfigured=${configured} enabled=${enabled} usable=${enabled} result=${pool.kind}${pool.kind === 'fetch_unavailable' ? ` reason=${pool.reason}` : ''}`);
21
+ }
22
+ export function logPoolSelection(log, pool, states, model, now, selected, logAllExhausted = true) {
23
+ if (pool.kind !== 'ready')
24
+ return;
25
+ for (const account of pool.accounts) {
26
+ if (!account.enabled) {
27
+ poolInfo(log, `skipped account=${poolLabel(account)} reason=disabled`);
28
+ continue;
29
+ }
30
+ const candidate = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states, model, now });
31
+ if (candidate.kind === 'deferred') {
32
+ const usageOnly = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states: { [account.accountId]: { usage: states[account.accountId]?.usage } }, model, now });
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
+ }
35
+ else if (candidate.kind === 'selected' && selected.kind === 'selected' && account.accountId !== selected.account.accountId) {
36
+ const reason = candidate.provenance === 'unknown' && selected.provenance === 'known' ? 'known observation preferred' : candidate.provenance === 'known' && selected.provenance === 'known' && candidate.score > selected.score ? 'higher usage score' : 'deterministic account order';
37
+ poolInfo(log, `skipped account=${poolLabel(account)} reason=${reason}`);
38
+ }
39
+ }
40
+ if (selected.kind === 'deferred' && logAllExhausted) {
41
+ // Ask the same policy about each observation so model scope, freshness and
42
+ // reset expiry stay identical to selection. An account must clear ALL its
43
+ // blockers; the pool can resume when its first account clears them.
44
+ const resets = pool.accounts.filter(a => a.enabled).map(account => {
45
+ const state = states[account.accountId];
46
+ const deadlines = [];
47
+ for (const window of state?.usage?.windows ?? []) {
48
+ const candidate = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states: { [account.accountId]: { usage: { ...state.usage, windows: [window] } } }, model, now });
49
+ if (candidate.kind === 'deferred')
50
+ deadlines.push(window.resetAt);
51
+ }
52
+ for (const reactive of state?.reactive ?? []) {
53
+ const candidate = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states: { [account.accountId]: { reactive: [reactive] } }, model, now });
54
+ if (candidate.kind === 'deferred')
55
+ deadlines.push(reactive.quota.resetAt ? Date.parse(reactive.quota.resetAt) : undefined);
56
+ }
57
+ return deadlines.length && deadlines.every(d => d !== undefined && Number.isFinite(d)) ? Math.max(...deadlines) : undefined;
58
+ });
59
+ const earliestResetAt = resets.length && resets.every(r => r !== undefined) ? new Date(Math.min(...resets)).toISOString() : 'unknown';
60
+ poolInfo(log, `deferred reason=all accounts exhausted earliestResetAt=${earliestResetAt} eligibleAt=${new Date(selected.eligibleAt).toISOString()}`);
61
+ }
62
+ if (selected.kind === 'selected') {
63
+ const score = selected.provenance === 'known' ? `${selected.score}% headroom=${100 - selected.score}%` : 'unknown (no observation)';
64
+ poolInfo(log, `selected account=${poolLabel(selected.account)} model=${JSON.stringify(model ?? 'CLI default')} score=${score}`);
65
+ }
66
+ }
67
+ /** Log the real store promise without changing its rejection or retry behavior. */
68
+ export async function logPoolUsageWrite(log, account, info, write, source = 'passive') {
69
+ const windows = info ? normalizePassiveUsage(safeRateLimitInfo(info), Date.now()).windows.map(w => `${w.window}${source === 'passive' ? `/${w.scope}` : ''}:${w.percent === undefined ? 'unknown' : `${Number(w.percent.toFixed(2))}%`}`).join(',') : '';
70
+ try {
71
+ await write();
72
+ poolInfo(log, `usage write account=${poolLabel(account)} result=ok windows=${windows || 'none'} source=${source}`);
73
+ }
74
+ catch (error) {
75
+ const name = error instanceof Error && ['Error', 'TypeError', 'RangeError', 'AbortError', 'TimeoutError', 'SubscriptionUsageStoreError'].includes(error.name) ? error.name : 'UnknownError';
76
+ poolInfo(log, `usage write account=${poolLabel(account)} result=failed errorClass=${name} windows=${windows || 'none'} source=${source}`);
77
+ throw error;
78
+ }
79
+ }
@@ -0,0 +1,44 @@
1
+ import type { SubscriptionAccount } from './subscription-pool-client.js';
2
+ import { type StoredAccountUsage, type SubscriptionUsageStore } from './subscription-usage-store.js';
3
+ import type { PoolAttempt } from './subscription-runtime.js';
4
+ import type { SessionMetrics, TeamVibeQueueMessage } from './types.js';
5
+ /** Explicit transport DTO: never serialize a pool account/credential envelope. */
6
+ export declare function accountUsageTelemetry(account: SubscriptionAccount, state: StoredAccountUsage): {
7
+ reactive: {
8
+ observedAt: number;
9
+ window: import("./claude-rate-limit.js").ClaudeQuotaWindow | undefined;
10
+ scope: string;
11
+ resetAt: number | undefined;
12
+ eligibleAt: number;
13
+ }[];
14
+ lastRateLimitInfo?: import("./claude-rate-limit.js").ClaudeRateLimitInfo | undefined;
15
+ probeStatus?: import("./subscription-usage-store.js").ProbeStatus | undefined;
16
+ probeObservedAt?: number | undefined;
17
+ usage?: import("./subscription-selection.js").UsageObservation | undefined;
18
+ accountId: string;
19
+ accountLabel: string;
20
+ reportedAt: number;
21
+ };
22
+ export declare function emitCandidateUsage(msg: TeamVibeQueueMessage, accounts: readonly SubscriptionAccount[], store: SubscriptionUsageStore): Promise<void>;
23
+ export declare function poolRunTelemetry(attempts: readonly PoolAttempt[], quotaDeferEpisodes?: number): {
24
+ chainLength: number;
25
+ switchedAccounts: boolean;
26
+ quotaDeferEpisodes: number;
27
+ attempts: {
28
+ accountId: string;
29
+ accountLabel: string;
30
+ sessionId: string | undefined;
31
+ model: string | undefined;
32
+ success: boolean;
33
+ exitCode: number | null;
34
+ 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
+ }[];
39
+ accountId?: string | undefined;
40
+ accountLabel?: string | undefined;
41
+ model?: string | undefined;
42
+ observedAt?: number | undefined;
43
+ lastRateLimitInfo?: import("./claude-rate-limit.js").ClaudeRateLimitInfo | undefined;
44
+ };
@@ -0,0 +1,107 @@
1
+ import { emitEvent } from './event-queue.js';
2
+ import { normalizePassiveUsage, reactiveEligibleAt, } from './subscription-selection.js';
3
+ import { safeQuota, safeRateLimitInfo, } from './subscription-usage-store.js';
4
+ function safeText(value) {
5
+ return value && value.length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(value)
6
+ ? value
7
+ : undefined;
8
+ }
9
+ function safeMetrics(metrics) {
10
+ if (!metrics)
11
+ return undefined;
12
+ const result = {};
13
+ for (const key of [
14
+ 'total_cost_usd',
15
+ 'input_tokens',
16
+ 'output_tokens',
17
+ 'cache_read_input_tokens',
18
+ 'cache_creation_input_tokens',
19
+ 'duration_ms',
20
+ 'duration_api_ms',
21
+ 'num_turns',
22
+ ]) {
23
+ const value = metrics[key];
24
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
25
+ result[key] = value;
26
+ }
27
+ const model = safeText(metrics.model);
28
+ if (model)
29
+ result.model = model;
30
+ return result;
31
+ }
32
+ /** Explicit transport DTO: never serialize a pool account/credential envelope. */
33
+ export function accountUsageTelemetry(account, state) {
34
+ const passive = state.passive
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;
41
+ return {
42
+ accountId: account.accountId,
43
+ accountLabel: account.label ?? account.accountId,
44
+ reportedAt: Date.now(),
45
+ ...(usage ? { usage } : {}),
46
+ ...(state.probe
47
+ ? {
48
+ probeStatus: state.probe.status,
49
+ probeObservedAt: state.probe.observedAt,
50
+ }
51
+ : {}),
52
+ ...(state.passive
53
+ ? { lastRateLimitInfo: state.passive.rateLimitInfo }
54
+ : {}),
55
+ reactive: state.reactive.map((r) => ({
56
+ observedAt: r.observedAt,
57
+ window: r.quota.window,
58
+ scope: r.quota.modelScope ?? 'global',
59
+ resetAt: r.quota.resetAt ? Date.parse(r.quota.resetAt) : undefined,
60
+ eligibleAt: reactiveEligibleAt(r),
61
+ })),
62
+ };
63
+ }
64
+ export async function emitCandidateUsage(msg, accounts, store) {
65
+ // Usage storage is already local. Observability failures never change selection.
66
+ for (const account of accounts) {
67
+ try {
68
+ emitEvent('pool.usage_observed', msg.teamvibe.channelId, {
69
+ workspaceId: msg.teamvibe.workspaceId,
70
+ ...accountUsageTelemetry(account, await store.read(account)),
71
+ });
72
+ }
73
+ catch { }
74
+ }
75
+ }
76
+ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
77
+ const safeAttempts = attempts.map((a) => ({
78
+ accountId: a.accountId,
79
+ accountLabel: safeText(a.accountLabel) ?? a.accountId,
80
+ sessionId: safeText(a.sessionId),
81
+ model: safeText(a.model),
82
+ success: a.success,
83
+ exitCode: a.exitCode,
84
+ observedAt: a.observedAt,
85
+ quota: a.quota ? safeQuota(a.quota) : undefined,
86
+ metrics: safeMetrics(a.metrics),
87
+ lastRateLimitInfo: a.rateLimitInfo
88
+ ? safeRateLimitInfo(a.rateLimitInfo)
89
+ : undefined,
90
+ }));
91
+ const last = safeAttempts.at(-1);
92
+ return {
93
+ ...(last
94
+ ? {
95
+ accountId: last.accountId,
96
+ accountLabel: last.accountLabel,
97
+ model: last.model,
98
+ observedAt: last.observedAt,
99
+ lastRateLimitInfo: last.lastRateLimitInfo,
100
+ }
101
+ : {}),
102
+ chainLength: attempts.length,
103
+ switchedAccounts: new Set(attempts.map((a) => a.accountId)).size > 1,
104
+ quotaDeferEpisodes,
105
+ attempts: safeAttempts,
106
+ };
107
+ }
@@ -0,0 +1,16 @@
1
+ import type { ClaudeQuotaFailure } from './claude-quota.js';
2
+ export declare const UNKNOWN_QUOTA_RECHECK_MS: number;
3
+ export declare class QuotaPausedError extends Error {
4
+ constructor();
5
+ }
6
+ export interface QuotaPause {
7
+ createdAt: number;
8
+ resumeAt: number;
9
+ brainPath: string;
10
+ threadId: string;
11
+ }
12
+ export declare function readQuotaPause(): QuotaPause | undefined;
13
+ export declare function isQuotaPaused(): boolean;
14
+ /** Persist before notification; only the first quota result owns the notice. */
15
+ export declare function pauseForQuota(quota: ClaudeQuotaFailure, brainPath: string, threadId: string): boolean;
16
+ export declare function clearQuotaPauseAfterSuccess(): void;
@@ -0,0 +1,96 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { config } from './config.js';
4
+ import { logger } from './logger.js';
5
+ export const UNKNOWN_QUOTA_RECHECK_MS = 15 * 60_000;
6
+ export class QuotaPausedError extends Error {
7
+ constructor() {
8
+ super('Claude account is paused for quota');
9
+ }
10
+ }
11
+ // The ambient credential path retains the original poller-wide pause.
12
+ // Configured subscription pools use per-account usage and per-thread pool-defer.
13
+ function markerPath() {
14
+ return join(config.TEAMVIBE_DATA_DIR, 'quota-pause.json');
15
+ }
16
+ const unreadableMarkers = new Set();
17
+ const cleanupFailures = new Set();
18
+ export function readQuotaPause() {
19
+ const path = markerPath();
20
+ try {
21
+ const pause = JSON.parse(readFileSync(path, 'utf8'));
22
+ if (pause && Number.isFinite(pause.createdAt) && Number.isFinite(pause.resumeAt)
23
+ && Number.isFinite(pause.resumeAt - pause.createdAt) && pause.resumeAt > pause.createdAt
24
+ && typeof pause.brainPath === 'string' && typeof pause.threadId === 'string') {
25
+ unreadableMarkers.delete(path);
26
+ return pause;
27
+ }
28
+ throw new Error('Invalid quota pause marker');
29
+ }
30
+ catch (error) {
31
+ if (error.code === 'ENOENT') {
32
+ unreadableMarkers.delete(path);
33
+ return undefined;
34
+ }
35
+ if (!unreadableMarkers.has(path)) {
36
+ unreadableMarkers.add(path);
37
+ logger.warn('[QuotaPause] Unreadable marker treated as expired', error instanceof Error ? error.message : String(error));
38
+ }
39
+ try {
40
+ rmSync(path, { recursive: true, force: true });
41
+ }
42
+ catch {
43
+ // A directory or unreadable file can still be moved out of the way.
44
+ // Cleanup failure must never stop the watcher from resuming saved work.
45
+ try {
46
+ renameSync(path, `${path}.invalid`);
47
+ }
48
+ catch { }
49
+ }
50
+ return undefined;
51
+ }
52
+ }
53
+ export function isQuotaPaused() {
54
+ const pause = readQuotaPause();
55
+ if (!pause)
56
+ return false;
57
+ const now = Date.now();
58
+ // A backward clock correction must not extend the original pause duration.
59
+ const bound = pause.resumeAt - pause.createdAt;
60
+ return now >= pause.createdAt && now < pause.resumeAt && pause.resumeAt - now <= bound;
61
+ }
62
+ /** Persist before notification; only the first quota result owns the notice. */
63
+ export function pauseForQuota(quota, brainPath, threadId) {
64
+ if (isQuotaPaused())
65
+ return false;
66
+ const now = Date.now();
67
+ const reset = quota.resetAt ? Date.parse(quota.resetAt) : NaN;
68
+ const pause = {
69
+ createdAt: now,
70
+ resumeAt: Number.isFinite(reset) && reset > now ? reset : now + UNKNOWN_QUOTA_RECHECK_MS,
71
+ brainPath,
72
+ threadId,
73
+ };
74
+ mkdirSync(config.TEAMVIBE_DATA_DIR, { recursive: true });
75
+ const path = markerPath();
76
+ writeFileSync(`${path}.tmp`, JSON.stringify(pause), { mode: 0o600 });
77
+ renameSync(`${path}.tmp`, path);
78
+ return true;
79
+ }
80
+ export function clearQuotaPauseAfterSuccess() {
81
+ // An already-running request may succeed after another thread exhausted the
82
+ // account. Its success must not erase that thread's newly active pause.
83
+ const path = markerPath();
84
+ try {
85
+ if (!isQuotaPaused()) {
86
+ rmSync(path, { recursive: true, force: true });
87
+ cleanupFailures.delete(path);
88
+ }
89
+ }
90
+ catch (error) {
91
+ if (!unreadableMarkers.has(path) && !cleanupFailures.has(path)) {
92
+ cleanupFailures.add(path);
93
+ logger.warn('[QuotaPause] Marker cleanup failed after success', error instanceof Error ? error.message : String(error));
94
+ }
95
+ }
96
+ }
@@ -1,4 +1,4 @@
1
- import type { SessionRecord, SessionStatus } from './types.js';
1
+ import type { SessionRecord, SessionStatus, SubscriptionPin } from './types.js';
2
2
  export declare function getSession(threadId: string): Promise<SessionRecord | null>;
3
3
  export declare function createSession(threadId: string, workspacePath: string): Promise<SessionRecord>;
4
4
  export interface AcquireLockResult {
@@ -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): Promise<void>;
10
+ export declare function updateSessionId(threadId: string, lockToken: string, newSessionId: string, pin?: SubscriptionPin): 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>;
@@ -43,6 +43,7 @@ function generateLockToken() {
43
43
  export async function getSession(threadId) {
44
44
  const key = buildKey(threadId);
45
45
  const result = await createDocClient().send(new GetCommand({
46
+ ConsistentRead: true,
46
47
  TableName: getTableName(),
47
48
  Key: key,
48
49
  }));
@@ -149,25 +150,27 @@ export async function acquireSessionLock(threadId, workspacePath) {
149
150
  throw error;
150
151
  }
151
152
  }
152
- export async function updateSessionId(threadId, lockToken, newSessionId) {
153
+ export async function updateSessionId(threadId, lockToken, newSessionId, pin) {
153
154
  try {
154
155
  await createDocClient().send(new UpdateCommand({
155
156
  TableName: getTableName(),
156
157
  Key: buildKey(threadId),
157
- UpdateExpression: 'SET session_id = :newSessionId',
158
+ UpdateExpression: pin ? 'SET session_id = :newSessionId, subscription_pin = :pin' : 'SET session_id = :newSessionId',
158
159
  ConditionExpression: 'lock_token = :lockToken',
159
160
  ExpressionAttributeValues: {
160
161
  ':newSessionId': newSessionId,
162
+ ...(pin ? { ':pin': Object.fromEntries(Object.entries(pin).filter(([, value]) => value !== undefined)) } : {}),
161
163
  ':lockToken': lockToken,
162
164
  },
163
165
  }));
164
166
  logger.info(`Updated session_id for thread ${threadId} to ${newSessionId}`);
167
+ return true;
165
168
  }
166
169
  catch (error) {
167
170
  if (error.name === 'ConditionalCheckFailedException') {
168
171
  logger.warn(`Lock token mismatch when updating session_id for ${threadId}`);
169
172
  emitError('session', `Lock token mismatch when updating session_id for ${threadId}`, { severity: 'warn' });
170
- return;
173
+ return false;
171
174
  }
172
175
  throw error;
173
176
  }
@@ -3,7 +3,10 @@ export declare function getUserInfo(botToken: string, userId: string): Promise<{
3
3
  name: string;
4
4
  realName: string;
5
5
  }>;
6
- export declare function sendSlackMessage(msg: TeamVibeQueueMessage, text: string): Promise<void>;
6
+ export declare function sendSlackMessage(msg: TeamVibeQueueMessage, text: string, options?: {
7
+ clientMessageId: string;
8
+ reconcile?: boolean;
9
+ }): Promise<void>;
7
10
  export declare function sendSlackError(msg: TeamVibeQueueMessage, error: string): Promise<void>;
8
11
  export declare function addReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
9
12
  export declare function setThreadStatus(msg: TeamVibeQueueMessage, status: string): Promise<void>;
@@ -45,19 +45,37 @@ export async function getUserInfo(botToken, userId) {
45
45
  userCache.set(userId, fallback);
46
46
  return fallback;
47
47
  }
48
- export async function sendSlackMessage(msg, text) {
48
+ export async function sendSlackMessage(msg, text, options) {
49
49
  if (!msg.response_context.slack) {
50
50
  logger.error('No Slack context in message, cannot respond');
51
51
  return;
52
52
  }
53
53
  const { channel, thread_ts } = msg.response_context.slack;
54
54
  const slack = getSlackClient(msg.teamvibe.botToken);
55
+ if (options?.reconcile) {
56
+ let cursor;
57
+ // A claim may have reached Slack before the process/connection died.
58
+ // Reconcile by stable episode ID; absence or read failure never reposts it.
59
+ for (let page = 0; page < 20; page++) {
60
+ const response = await slack.conversations.replies({ channel, ts: thread_ts, limit: 100, cursor, include_all_metadata: true });
61
+ if (response.messages?.some(message => {
62
+ const payload = message.metadata?.event_payload;
63
+ return ('client_msg_id' in message && message.client_msg_id === options.clientMessageId) || (payload !== null && typeof payload === 'object' && 'episodeId' in payload && payload.episodeId === options.clientMessageId);
64
+ }))
65
+ return;
66
+ cursor = response.response_metadata?.next_cursor;
67
+ if (!cursor)
68
+ break;
69
+ }
70
+ throw new Error('Pool notice delivery remains unresolved');
71
+ }
55
72
  await slack.chat.postMessage({
56
73
  channel,
57
74
  thread_ts,
58
75
  text,
59
76
  unfurl_links: false,
60
77
  unfurl_media: false,
78
+ ...(options ? { client_msg_id: options.clientMessageId, metadata: { event_type: 'subscription_pool_defer', event_payload: { episodeId: options.clientMessageId } } } : {}),
61
79
  });
62
80
  }
63
81
  export async function sendSlackError(msg, error) {
@@ -0,0 +1,35 @@
1
+ import type { AccountIdentity, PoolFetchResult } from './subscription-selection.js';
2
+ /** Unwrap only at the optional Claude probe or future child-spawn boundary. */
3
+ export declare class SubscriptionCredential {
4
+ #private;
5
+ constructor(token: string);
6
+ unwrap(): string;
7
+ toJSON(): string;
8
+ toString(): string;
9
+ }
10
+ export interface SubscriptionAccount extends AccountIdentity {
11
+ harness: 'CLAUDE';
12
+ label?: string;
13
+ credential: SubscriptionCredential;
14
+ }
15
+ export interface PoolFetchInput {
16
+ workspaceId: string;
17
+ channelId?: string;
18
+ pollerId: string;
19
+ accountId?: string;
20
+ requestId?: string;
21
+ credentialVersion?: string;
22
+ }
23
+ export interface PoolClientOptions {
24
+ apiUrl: string;
25
+ pollerToken: string;
26
+ fetch?: typeof fetch;
27
+ signal?: AbortSignal;
28
+ timeoutMs?: number;
29
+ }
30
+ /** Deadline covers response headers AND body, even for an uncooperative adapter. */
31
+ export declare function boundedRequest<T>(work: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal, timeoutMs?: number): Promise<T>;
32
+ /** Bounded, ephemeral raw body. Neither it nor caught errors leave this module. */
33
+ export declare function readSubscriptionJson(response: Response): Promise<unknown>;
34
+ /** No global config, logging, workspace fallback or credential cache. */
35
+ export declare function fetchSubscriptionPool(input: PoolFetchInput, options: PoolClientOptions): Promise<PoolFetchResult<SubscriptionAccount>>;
@@ -0,0 +1,143 @@
1
+ import { record } from './subscription-selection.js';
2
+ /** Unwrap only at the optional Claude probe or future child-spawn boundary. */
3
+ export class SubscriptionCredential {
4
+ #token;
5
+ constructor(token) { this.#token = token; }
6
+ unwrap() { return this.#token; }
7
+ toJSON() { return '[REDACTED]'; }
8
+ toString() { return '[SubscriptionCredential REDACTED]'; }
9
+ [Symbol.for('nodejs.util.inspect.custom')]() { return this.toString(); }
10
+ }
11
+ /** Deadline covers response headers AND body, even for an uncooperative adapter. */
12
+ export async function boundedRequest(work, signal, timeoutMs = 15_000) {
13
+ const controller = new AbortController();
14
+ const cancel = () => controller.abort();
15
+ signal?.addEventListener('abort', cancel, { once: true });
16
+ const timer = setTimeout(cancel, timeoutMs);
17
+ if (signal?.aborted)
18
+ cancel();
19
+ let onAbort = () => { };
20
+ try {
21
+ return await Promise.race([
22
+ new Promise((_, reject) => {
23
+ onAbort = () => reject(new Error('Subscription request interrupted'));
24
+ controller.signal.addEventListener('abort', onAbort, { once: true });
25
+ if (controller.signal.aborted)
26
+ onAbort();
27
+ }),
28
+ Promise.resolve().then(() => {
29
+ if (controller.signal.aborted)
30
+ throw new Error('Subscription request interrupted');
31
+ return work(controller.signal);
32
+ }),
33
+ ]);
34
+ }
35
+ finally {
36
+ clearTimeout(timer);
37
+ signal?.removeEventListener('abort', cancel);
38
+ controller.signal.removeEventListener('abort', onAbort);
39
+ }
40
+ }
41
+ /** Bounded, ephemeral raw body. Neither it nor caught errors leave this module. */
42
+ export async function readSubscriptionJson(response) {
43
+ const reader = response.body?.getReader();
44
+ if (!reader)
45
+ throw new Error('Invalid subscription response');
46
+ const chunks = [];
47
+ let size = 0;
48
+ try {
49
+ while (true) {
50
+ const chunk = await reader.read();
51
+ if (chunk.done)
52
+ break;
53
+ size += chunk.value.byteLength;
54
+ if (size > 1024 * 1024)
55
+ throw new Error('Subscription response too large');
56
+ chunks.push(chunk.value);
57
+ }
58
+ const bytes = new Uint8Array(size);
59
+ let offset = 0;
60
+ for (const chunk of chunks) {
61
+ bytes.set(chunk, offset);
62
+ offset += chunk.byteLength;
63
+ }
64
+ return JSON.parse(new TextDecoder().decode(bytes));
65
+ }
66
+ finally {
67
+ void reader.cancel().catch(() => { });
68
+ }
69
+ }
70
+ /** No global config, logging, workspace fallback or credential cache. */
71
+ export async function fetchSubscriptionPool(input, options) {
72
+ let url;
73
+ try {
74
+ if (!input.pollerId || !input.workspaceId || (!input.channelId && !input.requestId) || !options.pollerToken)
75
+ throw new Error('Missing configuration');
76
+ url = new URL('/subscription-accounts/spawn', options.apiUrl);
77
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
78
+ throw new Error('Invalid URL');
79
+ url.searchParams.set('pollerId', input.pollerId);
80
+ url.searchParams.set('workspaceId', input.workspaceId);
81
+ if (input.channelId)
82
+ url.searchParams.set('channelId', input.channelId);
83
+ for (const name of ['accountId', 'requestId', 'credentialVersion'])
84
+ if (input[name])
85
+ url.searchParams.set(name, input[name]);
86
+ }
87
+ catch {
88
+ return { kind: 'fetch_unavailable', reason: 'configuration' };
89
+ }
90
+ try {
91
+ return await boundedRequest(async (signal) => {
92
+ const response = await (options.fetch ?? fetch)(url, {
93
+ method: 'GET', signal,
94
+ headers: { Authorization: `Bearer ${options.pollerToken}`, 'Cache-Control': 'no-store' },
95
+ redirect: 'error',
96
+ });
97
+ if (response.status === 401 || response.status === 403)
98
+ return { kind: 'fetch_unauthorized', status: response.status };
99
+ if (!response.ok)
100
+ return { kind: 'fetch_unavailable', reason: 'http', status: response.status };
101
+ let body;
102
+ try {
103
+ body = await readSubscriptionJson(response);
104
+ }
105
+ catch {
106
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
107
+ }
108
+ const envelope = record(body);
109
+ const raw = envelope['accounts'];
110
+ if (!Array.isArray(raw) || raw.length > 1000)
111
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
112
+ const poolConfigured = envelope['poolConfigured'];
113
+ if (('poolConfigured' in envelope && typeof poolConfigured !== 'boolean') ||
114
+ (poolConfigured === false && raw.length > 0))
115
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
116
+ const accounts = [];
117
+ const ids = new Set();
118
+ for (const value of raw) {
119
+ const item = record(value);
120
+ const credential = record(item['credential']);
121
+ if (typeof item['accountId'] !== 'string' || !item['accountId'] || ids.has(item['accountId']) ||
122
+ item['workspaceId'] !== input.workspaceId || item['enabled'] !== true || item['harness'] !== 'CLAUDE' ||
123
+ typeof item['credentialVersion'] !== 'string' || !item['credentialVersion'] ||
124
+ credential['type'] !== 'claude-setup-token' || typeof credential['token'] !== 'string' || !credential['token']) {
125
+ return { kind: 'fetch_unavailable', reason: 'invalid_response' };
126
+ }
127
+ ids.add(item['accountId']);
128
+ accounts.push({ accountId: item['accountId'], workspaceId: input.workspaceId, credentialVersion: item['credentialVersion'], enabled: true, useFullQuota: item['useFullQuota'] === true, harness: 'CLAUDE', ...(typeof item['label'] === 'string' && item['label'].length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(item['label']) ? { label: item['label'] } : {}), credential: new SubscriptionCredential(credential['token']) });
129
+ }
130
+ if (accounts.length)
131
+ return { kind: 'ready', accounts };
132
+ if (poolConfigured === false)
133
+ return { kind: 'no_accounts_configured' };
134
+ if (poolConfigured === true)
135
+ return { kind: 'fetch_unavailable', reason: 'no_usable_accounts' };
136
+ // Older platforms cannot distinguish an absent pool from disabled members.
137
+ return { kind: 'unknown_pool_state' };
138
+ }, options.signal, options.timeoutMs);
139
+ }
140
+ catch {
141
+ return { kind: 'fetch_unavailable', reason: options.signal?.aborted ? 'cancelled' : 'network' };
142
+ }
143
+ }