@teamvibe/poller 0.8.2-rc.1 → 0.8.3-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/classify-failed-iteration.d.ts +7 -0
- package/dist/classify-failed-iteration.js +8 -0
- package/dist/claude-quota.d.ts +15 -0
- package/dist/claude-quota.js +188 -0
- package/dist/claude-rate-limit.d.ts +19 -0
- package/dist/claude-rate-limit.js +44 -0
- package/dist/claude-spawner.d.ts +15 -0
- package/dist/claude-spawner.js +307 -26
- package/dist/config-client.d.ts +2 -0
- package/dist/config-client.js +3 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/inbox-manager.d.ts +22 -1
- package/dist/inbox-manager.js +57 -3
- package/dist/inbox-watcher.d.ts +1 -1
- package/dist/inbox-watcher.js +42 -20
- package/dist/poller.js +262 -51
- package/dist/pool-defer.d.ts +13 -0
- package/dist/pool-defer.js +81 -0
- package/dist/pool-telemetry.d.ts +44 -0
- package/dist/pool-telemetry.js +107 -0
- package/dist/quota-pause.d.ts +16 -0
- package/dist/quota-pause.js +96 -0
- package/dist/session-store.d.ts +2 -2
- package/dist/session-store.js +6 -3
- package/dist/slack-client.d.ts +4 -1
- package/dist/slack-client.js +100 -11
- package/dist/subscription-pool-client.d.ts +32 -0
- package/dist/subscription-pool-client.js +139 -0
- package/dist/subscription-pool.d.ts +18 -0
- package/dist/subscription-pool.js +24 -0
- package/dist/subscription-runtime.d.ts +53 -0
- package/dist/subscription-runtime.js +74 -0
- package/dist/subscription-selection.d.ts +75 -0
- package/dist/subscription-selection.js +142 -0
- package/dist/subscription-usage-probe.d.ts +16 -0
- package/dist/subscription-usage-probe.js +106 -0
- package/dist/subscription-usage-store.d.ts +47 -0
- package/dist/subscription-usage-store.js +206 -0
- package/dist/types.d.ts +25 -9
- package/dist/worktree-manager.js +4 -0
- package/npm-shrinkwrap.json +78 -77
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/session-store.d.ts
CHANGED
|
@@ -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<
|
|
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>;
|
package/dist/session-store.js
CHANGED
|
@@ -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
|
}
|
package/dist/slack-client.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/slack-client.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { WebClient } from '@slack/web-api';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { emitError } from './event-queue.js';
|
|
4
|
+
import { config } from './config.js';
|
|
4
5
|
// Cache user info to avoid repeated API calls
|
|
5
6
|
const userCache = new Map();
|
|
6
7
|
// Per-token Slack client cache
|
|
7
8
|
const slackClients = new Map();
|
|
9
|
+
// Slack: "must be less than 51 characters [json-pointer:/loading_messages/0]"
|
|
10
|
+
const SLACK_LOADING_MESSAGE_MAX = 50;
|
|
8
11
|
function getSlackClient(botToken) {
|
|
9
12
|
let client = slackClients.get(botToken);
|
|
10
13
|
if (!client) {
|
|
@@ -42,19 +45,37 @@ export async function getUserInfo(botToken, userId) {
|
|
|
42
45
|
userCache.set(userId, fallback);
|
|
43
46
|
return fallback;
|
|
44
47
|
}
|
|
45
|
-
export async function sendSlackMessage(msg, text) {
|
|
48
|
+
export async function sendSlackMessage(msg, text, options) {
|
|
46
49
|
if (!msg.response_context.slack) {
|
|
47
50
|
logger.error('No Slack context in message, cannot respond');
|
|
48
51
|
return;
|
|
49
52
|
}
|
|
50
53
|
const { channel, thread_ts } = msg.response_context.slack;
|
|
51
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
|
+
}
|
|
52
72
|
await slack.chat.postMessage({
|
|
53
73
|
channel,
|
|
54
74
|
thread_ts,
|
|
55
75
|
text,
|
|
56
76
|
unfurl_links: false,
|
|
57
77
|
unfurl_media: false,
|
|
78
|
+
...(options ? { client_msg_id: options.clientMessageId, metadata: { event_type: 'subscription_pool_defer', event_payload: { episodeId: options.clientMessageId } } } : {}),
|
|
58
79
|
});
|
|
59
80
|
}
|
|
60
81
|
export async function sendSlackError(msg, error) {
|
|
@@ -95,25 +116,59 @@ export async function setThreadStatus(msg, status) {
|
|
|
95
116
|
const { channel, thread_ts } = msg.response_context.slack;
|
|
96
117
|
const slack = getSlackClient(msg.teamvibe.botToken);
|
|
97
118
|
const safeStatus = neutralizeSlackMarkup(status);
|
|
119
|
+
const payload = {
|
|
120
|
+
channel_id: channel,
|
|
121
|
+
thread_ts,
|
|
122
|
+
status: safeStatus,
|
|
123
|
+
};
|
|
98
124
|
try {
|
|
99
125
|
await slack.apiCall('assistant.threads.setStatus', {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
status: safeStatus,
|
|
103
|
-
...(safeStatus ? { loading_messages: [safeStatus] } : {}),
|
|
126
|
+
...payload,
|
|
127
|
+
...(safeStatus ? { loading_messages: [truncateLoadingMessage(safeStatus)] } : {}),
|
|
104
128
|
});
|
|
105
129
|
}
|
|
106
|
-
catch {
|
|
130
|
+
catch (error) {
|
|
131
|
+
const messages = error?.data?.response_metadata?.messages;
|
|
132
|
+
const details = [error?.message, ...(Array.isArray(messages) ? messages : [])].join(' ');
|
|
133
|
+
if (safeStatus && error?.data?.error === 'invalid_arguments' && details.includes('loading_messages')) {
|
|
134
|
+
// API errors may echo request fields; never log the error or status.
|
|
135
|
+
logger.warn('Slack rejected loading messages; retrying thread status');
|
|
136
|
+
try {
|
|
137
|
+
await slack.apiCall('assistant.threads.setStatus', payload);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
emitError('slack', 'Failed to set thread status', { severity: 'warn' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
107
145
|
// API errors may echo request fields; status can contain private narration.
|
|
108
146
|
logger.warn('Failed to set thread status');
|
|
109
147
|
emitError('slack', `Failed to set thread status`, { severity: 'warn' });
|
|
110
148
|
}
|
|
111
149
|
}
|
|
150
|
+
function truncateLoadingMessage(text) {
|
|
151
|
+
// Treat escaped markup as whole tokens so truncation never leaves a partial entity.
|
|
152
|
+
const tokens = text.match(/&(?:lt|gt|amp);|[\s\S]/gu) ?? [];
|
|
153
|
+
if (text.length <= SLACK_LOADING_MESSAGE_MAX)
|
|
154
|
+
return text;
|
|
155
|
+
let result = '';
|
|
156
|
+
for (const token of tokens) {
|
|
157
|
+
if (result.length + token.length >= SLACK_LOADING_MESSAGE_MAX)
|
|
158
|
+
break;
|
|
159
|
+
result += token;
|
|
160
|
+
}
|
|
161
|
+
return result + '…';
|
|
162
|
+
}
|
|
112
163
|
function neutralizeSlackMarkup(text) {
|
|
113
164
|
return text.replace(/[<>&]/g, (character) => ({ '<': '<', '>': '>', '&': '&' })[character]);
|
|
114
165
|
}
|
|
115
166
|
export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
116
167
|
let currentStatus = statusText;
|
|
168
|
+
let currentSource = statusText === 'Thinking...' ? 'default' : 'comment';
|
|
169
|
+
let commentShownAt = currentSource === 'comment' ? Date.now() : 0;
|
|
170
|
+
let pendingCanned = null;
|
|
171
|
+
let holdTimer;
|
|
117
172
|
let stopped = false;
|
|
118
173
|
const logStatus = (status, source) => {
|
|
119
174
|
if (!status)
|
|
@@ -122,8 +177,25 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
|
122
177
|
logger.info(`[Typing] status -> ${source} (${status.length} chars)`);
|
|
123
178
|
};
|
|
124
179
|
// Set initial status
|
|
125
|
-
logStatus(currentStatus,
|
|
180
|
+
logStatus(currentStatus, currentSource);
|
|
126
181
|
setThreadStatus(msg, currentStatus);
|
|
182
|
+
const cancelHold = () => {
|
|
183
|
+
if (holdTimer !== undefined)
|
|
184
|
+
clearTimeout(holdTimer);
|
|
185
|
+
holdTimer = undefined;
|
|
186
|
+
pendingCanned = null;
|
|
187
|
+
};
|
|
188
|
+
const showStatus = (text, source) => {
|
|
189
|
+
const changed = text !== currentStatus;
|
|
190
|
+
currentStatus = text;
|
|
191
|
+
currentSource = text ? source : 'default';
|
|
192
|
+
if (text && source === 'comment')
|
|
193
|
+
commentShownAt = Date.now();
|
|
194
|
+
if (changed) {
|
|
195
|
+
logStatus(text, source);
|
|
196
|
+
setThreadStatus(msg, text);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
127
199
|
// Keepalive every 3 seconds (Slack clears after ~2 min timeout)
|
|
128
200
|
let failures = 0;
|
|
129
201
|
const interval = setInterval(async () => {
|
|
@@ -142,16 +214,33 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
|
142
214
|
updateStatus: (newStatus, source = 'comment') => {
|
|
143
215
|
if (stopped)
|
|
144
216
|
return;
|
|
145
|
-
if (newStatus
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
217
|
+
if (!newStatus || source === 'comment') {
|
|
218
|
+
cancelHold();
|
|
219
|
+
showStatus(newStatus, source);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const remainingHold = config.TYPING_COMMENT_MIN_HOLD_MS - (Date.now() - commentShownAt);
|
|
223
|
+
if (currentSource === 'comment' && remainingHold > 0) {
|
|
224
|
+
pendingCanned = { text: newStatus, source };
|
|
225
|
+
if (holdTimer === undefined) {
|
|
226
|
+
holdTimer = setTimeout(() => {
|
|
227
|
+
holdTimer = undefined;
|
|
228
|
+
const pending = pendingCanned;
|
|
229
|
+
pendingCanned = null;
|
|
230
|
+
if (!stopped && pending)
|
|
231
|
+
showStatus(pending.text, pending.source);
|
|
232
|
+
}, remainingHold);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
149
235
|
}
|
|
236
|
+
cancelHold();
|
|
237
|
+
showStatus(newStatus, source);
|
|
150
238
|
},
|
|
151
239
|
stop: () => {
|
|
152
240
|
if (stopped)
|
|
153
241
|
return;
|
|
154
242
|
stopped = true;
|
|
243
|
+
cancelHold();
|
|
155
244
|
clearInterval(interval);
|
|
156
245
|
if (currentStatus)
|
|
157
246
|
logStatus('', 'default');
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
}
|
|
20
|
+
export interface PoolClientOptions {
|
|
21
|
+
apiUrl: string;
|
|
22
|
+
pollerToken: string;
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
/** Deadline covers response headers AND body, even for an uncooperative adapter. */
|
|
28
|
+
export declare function boundedRequest<T>(work: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal, timeoutMs?: number): Promise<T>;
|
|
29
|
+
/** Bounded, ephemeral raw body. Neither it nor caught errors leave this module. */
|
|
30
|
+
export declare function readSubscriptionJson(response: Response): Promise<unknown>;
|
|
31
|
+
/** No global config, logging, workspace fallback or credential cache. */
|
|
32
|
+
export declare function fetchSubscriptionPool(input: PoolFetchInput, options: PoolClientOptions): Promise<PoolFetchResult<SubscriptionAccount>>;
|
|
@@ -0,0 +1,139 @@
|
|
|
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 || !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
|
+
url.searchParams.set('channelId', input.channelId);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { kind: 'fetch_unavailable', reason: 'configuration' };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return await boundedRequest(async (signal) => {
|
|
88
|
+
const response = await (options.fetch ?? fetch)(url, {
|
|
89
|
+
method: 'GET', signal,
|
|
90
|
+
headers: { Authorization: `Bearer ${options.pollerToken}`, 'Cache-Control': 'no-store' },
|
|
91
|
+
redirect: 'error',
|
|
92
|
+
});
|
|
93
|
+
if (response.status === 401 || response.status === 403)
|
|
94
|
+
return { kind: 'fetch_unauthorized', status: response.status };
|
|
95
|
+
if (!response.ok)
|
|
96
|
+
return { kind: 'fetch_unavailable', reason: 'http', status: response.status };
|
|
97
|
+
let body;
|
|
98
|
+
try {
|
|
99
|
+
body = await readSubscriptionJson(response);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { kind: 'fetch_unavailable', reason: 'invalid_response' };
|
|
103
|
+
}
|
|
104
|
+
const envelope = record(body);
|
|
105
|
+
const raw = envelope['accounts'];
|
|
106
|
+
if (!Array.isArray(raw) || raw.length > 1000)
|
|
107
|
+
return { kind: 'fetch_unavailable', reason: 'invalid_response' };
|
|
108
|
+
const poolConfigured = envelope['poolConfigured'];
|
|
109
|
+
if (('poolConfigured' in envelope && typeof poolConfigured !== 'boolean') ||
|
|
110
|
+
(poolConfigured === false && raw.length > 0))
|
|
111
|
+
return { kind: 'fetch_unavailable', reason: 'invalid_response' };
|
|
112
|
+
const accounts = [];
|
|
113
|
+
const ids = new Set();
|
|
114
|
+
for (const value of raw) {
|
|
115
|
+
const item = record(value);
|
|
116
|
+
const credential = record(item['credential']);
|
|
117
|
+
if (typeof item['accountId'] !== 'string' || !item['accountId'] || ids.has(item['accountId']) ||
|
|
118
|
+
item['workspaceId'] !== input.workspaceId || item['enabled'] !== true || item['harness'] !== 'CLAUDE' ||
|
|
119
|
+
typeof item['credentialVersion'] !== 'string' || !item['credentialVersion'] ||
|
|
120
|
+
credential['type'] !== 'claude-setup-token' || typeof credential['token'] !== 'string' || !credential['token']) {
|
|
121
|
+
return { kind: 'fetch_unavailable', reason: 'invalid_response' };
|
|
122
|
+
}
|
|
123
|
+
ids.add(item['accountId']);
|
|
124
|
+
accounts.push({ accountId: item['accountId'], workspaceId: input.workspaceId, credentialVersion: item['credentialVersion'], enabled: 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']) });
|
|
125
|
+
}
|
|
126
|
+
if (accounts.length)
|
|
127
|
+
return { kind: 'ready', accounts };
|
|
128
|
+
if (poolConfigured === false)
|
|
129
|
+
return { kind: 'no_accounts_configured' };
|
|
130
|
+
if (poolConfigured === true)
|
|
131
|
+
return { kind: 'fetch_unavailable', reason: 'no_usable_accounts' };
|
|
132
|
+
// Older platforms cannot distinguish an absent pool from disabled members.
|
|
133
|
+
return { kind: 'unknown_pool_state' };
|
|
134
|
+
}, options.signal, options.timeoutMs);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return { kind: 'fetch_unavailable', reason: options.signal?.aborted ? 'cancelled' : 'network' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { resolveModel } from './claude-spawner.js';
|
|
2
|
+
import { type AccountIdentity, type PoolFetchResult, type SelectionOutcome } from './subscription-selection.js';
|
|
3
|
+
import { type SubscriptionUsageStore } from './subscription-usage-store.js';
|
|
4
|
+
export type ResolvedPoolModel = ReturnType<typeof resolveModel>;
|
|
5
|
+
export type ResolvedPoolSelection<A extends AccountIdentity> = (SelectionOutcome<A> | {
|
|
6
|
+
kind: 'usage_unavailable';
|
|
7
|
+
}) & ResolvedPoolModel;
|
|
8
|
+
/**
|
|
9
|
+
* #405 integration seam: resolveModel ONCE before calling, then reuse the
|
|
10
|
+
* returned model/modelSource for --model. No fetch, probe, spawn or env fallback
|
|
11
|
+
* occurs here. Only no_accounts_configured permits the legacy env credential.
|
|
12
|
+
*/
|
|
13
|
+
export declare function selectPoolForResolvedModel<A extends AccountIdentity>(resolvedModel: ResolvedPoolModel, input: {
|
|
14
|
+
workspaceId: string;
|
|
15
|
+
pool: PoolFetchResult<A>;
|
|
16
|
+
store: SubscriptionUsageStore;
|
|
17
|
+
now: number;
|
|
18
|
+
}): Promise<ResolvedPoolSelection<A>>;
|