@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.
- package/dist/classify-failed-iteration.d.ts +7 -0
- package/dist/classify-failed-iteration.js +12 -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 +323 -25
- package/dist/config-client.d.ts +2 -0
- package/dist/config-client.js +3 -0
- package/dist/event-queue.js +2 -2
- 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.d.ts +1 -0
- package/dist/poller.js +316 -54
- package/dist/pool-defer.d.ts +13 -0
- package/dist/pool-defer.js +81 -0
- package/dist/pool-logging.d.ts +18 -0
- package/dist/pool-logging.js +79 -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 +19 -1
- package/dist/subscription-pool-client.d.ts +35 -0
- package/dist/subscription-pool-client.js +143 -0
- package/dist/subscription-pool.d.ts +21 -0
- package/dist/subscription-pool.js +30 -0
- package/dist/subscription-runtime.d.ts +57 -0
- package/dist/subscription-runtime.js +126 -0
- package/dist/subscription-selection.d.ts +77 -0
- package/dist/subscription-selection.js +157 -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 +50 -0
- package/dist/subscription-usage-store.js +223 -0
- package/dist/types.d.ts +35 -10
- package/dist/usage-limit-messages.d.ts +22 -0
- package/dist/usage-limit-messages.js +44 -0
- package/dist/usage-probe-job.d.ts +17 -0
- package/dist/usage-probe-job.js +186 -0
- package/dist/worktree-manager.js +4 -0
- package/npm-shrinkwrap.json +78 -77
- package/package.json +1 -1
package/dist/claude-spawner.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import { logPoolFetch, logPoolUsageWrite, poolInfo, poolLabel } from './pool-logging.js';
|
|
2
|
+
import { accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
|
|
1
3
|
import { spawn } from 'child_process';
|
|
2
4
|
import { join } from 'path';
|
|
3
5
|
import { readFileSync, existsSync } from 'fs';
|
|
6
|
+
import { mkdir } from 'fs/promises';
|
|
4
7
|
import { WebClient } from '@slack/web-api';
|
|
5
8
|
import { config } from './config.js';
|
|
6
9
|
import { logger } from './logger.js';
|
|
7
10
|
import { getBaseBrainPath } from './brain-manager.js';
|
|
8
11
|
import { getPollerId } from './auth-provider.js';
|
|
9
12
|
import { sendSlackMessage } from './slack-client.js';
|
|
10
|
-
import { emitError } from './event-queue.js';
|
|
13
|
+
import { emitError, emitEvent } from './event-queue.js';
|
|
11
14
|
import { createWorktree, removeWorktree, isWorktreeIsolationEnabled } from './worktree-manager.js';
|
|
12
15
|
import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js';
|
|
13
16
|
import { fetchConfigForSpawn } from './config-client.js';
|
|
17
|
+
import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
18
|
+
import { parseClaudeQuotaFailure } from './claude-quota.js';
|
|
19
|
+
import { accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
|
|
20
|
+
import { selectPoolForResolvedModel } from './subscription-pool.js';
|
|
21
|
+
import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
|
|
14
22
|
/**
|
|
15
23
|
* Maps tool names (or prefixes) to human-readable status messages.
|
|
16
24
|
* More specific patterns are checked first.
|
|
@@ -462,6 +470,17 @@ export function buildSpawnEnv(params) {
|
|
|
462
470
|
// drops against this var so they land somewhere still being
|
|
463
471
|
// watched after the session (and worktree) is gone. See tv#265.
|
|
464
472
|
POLLER_BRAIN_PATH: params.brainPath,
|
|
473
|
+
...(params.subscriptionAccount ? {
|
|
474
|
+
CLAUDE_CODE_OAUTH_TOKEN: params.subscriptionAccount.credential.unwrap(),
|
|
475
|
+
ANTHROPIC_API_KEY: undefined,
|
|
476
|
+
ANTHROPIC_AUTH_TOKEN: undefined,
|
|
477
|
+
ANTHROPIC_BASE_URL: undefined,
|
|
478
|
+
ANTHROPIC_CUSTOM_HEADERS: undefined,
|
|
479
|
+
CLAUDE_CODE_OAUTH_TOKEN_FILE: undefined,
|
|
480
|
+
CLAUDE_CODE_USE_BEDROCK: undefined,
|
|
481
|
+
CLAUDE_CODE_USE_VERTEX: undefined,
|
|
482
|
+
CLAUDE_CODE_USE_FOUNDRY: undefined,
|
|
483
|
+
} : {}),
|
|
465
484
|
};
|
|
466
485
|
}
|
|
467
486
|
export function buildPrompt(msg, contextHint) {
|
|
@@ -737,7 +756,51 @@ function resolveTypingStatusDetail(event, assistantText) {
|
|
|
737
756
|
export function resolveTypingStatus(event, assistantText) {
|
|
738
757
|
return resolveTypingStatusDetail(event, assistantText)?.text ?? null;
|
|
739
758
|
}
|
|
740
|
-
|
|
759
|
+
function existingResultMetrics(event, pooled) {
|
|
760
|
+
// Preserve the legacy extractor exactly for non-pool spawns.
|
|
761
|
+
if (!pooled)
|
|
762
|
+
return {
|
|
763
|
+
duration_ms: event.duration_ms || 0,
|
|
764
|
+
duration_api_ms: event.duration_api_ms || 0,
|
|
765
|
+
num_turns: event.num_turns || 0,
|
|
766
|
+
total_cost_usd: event.total_cost_usd || 0,
|
|
767
|
+
input_tokens: event.usage?.input_tokens || 0,
|
|
768
|
+
output_tokens: event.usage?.output_tokens || 0,
|
|
769
|
+
cache_read_input_tokens: event.usage?.cache_read_input_tokens || 0,
|
|
770
|
+
cache_creation_input_tokens: event.usage?.cache_creation_input_tokens || 0,
|
|
771
|
+
model: Object.keys(event.modelUsage || {})[0] || 'unknown',
|
|
772
|
+
};
|
|
773
|
+
const result = {};
|
|
774
|
+
for (const key of ['duration_ms', 'duration_api_ms', 'num_turns', 'total_cost_usd']) {
|
|
775
|
+
if (typeof event[key] === 'number' && Number.isFinite(event[key]) && event[key] >= 0)
|
|
776
|
+
result[key] = event[key];
|
|
777
|
+
}
|
|
778
|
+
for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
|
|
779
|
+
if (typeof event.usage?.[key] === 'number' && Number.isFinite(event.usage[key]) && event.usage[key] >= 0)
|
|
780
|
+
result[key] = event.usage[key];
|
|
781
|
+
}
|
|
782
|
+
const model = Object.keys(event.modelUsage || {})[0];
|
|
783
|
+
if (model)
|
|
784
|
+
result.model = model;
|
|
785
|
+
return result;
|
|
786
|
+
}
|
|
787
|
+
// Context occupancy is one model call's input, never the accumulated result usage.
|
|
788
|
+
function resultMetrics(event, pooled, lastAssistant) {
|
|
789
|
+
const metrics = existingResultMetrics(event, pooled);
|
|
790
|
+
const usage = lastAssistant?.usage;
|
|
791
|
+
const inputs = [usage?.input_tokens, usage?.cache_read_input_tokens, usage?.cache_creation_input_tokens];
|
|
792
|
+
if (inputs.every(value => typeof value === 'number' && Number.isFinite(value) && value >= 0)) {
|
|
793
|
+
const tokens = inputs.reduce((sum, value) => sum + value, 0);
|
|
794
|
+
if (Number.isFinite(tokens))
|
|
795
|
+
metrics.context_tokens = tokens;
|
|
796
|
+
}
|
|
797
|
+
const model = lastAssistant?.model ?? Object.keys(event.modelUsage || {})[0];
|
|
798
|
+
const window = event.modelUsage?.[model]?.contextWindow;
|
|
799
|
+
if (typeof window === 'number' && Number.isFinite(window) && window > 0)
|
|
800
|
+
metrics.context_window = window;
|
|
801
|
+
return metrics;
|
|
802
|
+
}
|
|
803
|
+
async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
|
|
741
804
|
const slackContext = msg.response_context.slack;
|
|
742
805
|
const isCronMessage = msg.source === 'cron';
|
|
743
806
|
const isMaintenance = msg.source === 'maintenance';
|
|
@@ -757,7 +820,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
757
820
|
// the more informative signal. Logic extracted into buildContextHint for
|
|
758
821
|
// unit testability — the counters are injected so tests don't need to mock
|
|
759
822
|
// the Slack WebClient module.
|
|
760
|
-
const contextHint = await buildContextHint({
|
|
823
|
+
const contextHint = runtime?.resumePending ? undefined : await buildContextHint({
|
|
761
824
|
isFirstMessage,
|
|
762
825
|
lastMessageTs,
|
|
763
826
|
slack: slackContext,
|
|
@@ -779,17 +842,25 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
779
842
|
// Fetch scoped model config for this spawn — same hoisting rationale as
|
|
780
843
|
// secrets above. Fail-soft: returns {} on any error, which resolveModel
|
|
781
844
|
// treats as "nothing at this tier" and falls through to the env default.
|
|
782
|
-
const ddbModelConfig = await fetchConfigForSpawn({
|
|
845
|
+
const ddbModelConfig = runtime ? {} : await fetchConfigForSpawn({
|
|
783
846
|
pollerId: getPollerId() || '',
|
|
784
847
|
workspaceId: msg.teamvibe.workspaceId,
|
|
785
848
|
channelId: msg.teamvibe.channelId,
|
|
786
849
|
});
|
|
850
|
+
if (runtime?.account)
|
|
851
|
+
await validateAccount(runtime.account, runtime.resolved);
|
|
787
852
|
return new Promise((resolve) => {
|
|
788
|
-
|
|
853
|
+
// Preparation above awaits remote config/context. Another thread may
|
|
854
|
+
// have exhausted the shared account meanwhile. No awaits before spawn.
|
|
855
|
+
if (!runtime?.account && isQuotaPaused())
|
|
856
|
+
throw new QuotaPausedError();
|
|
857
|
+
if (runtime?.shouldStop())
|
|
858
|
+
throw new PoolDeferredError();
|
|
859
|
+
const prompt = runtime?.resumePending ? 'Continue the pending work in this session from the last completed action. Do not repeat completed actions or messages.' : buildPrompt(msg, contextHint);
|
|
789
860
|
// teamvibe.ai#293 bug 2: decides plain-text vs. stream-json stdin. Empty
|
|
790
861
|
// `extraArgs`/single-chunk `stdinChunks` (the overwhelming majority of
|
|
791
862
|
// sessions — no inline images) reproduces the exact prior behavior.
|
|
792
|
-
const spawnStdinPlan = buildSpawnStdinPlan(prompt, msg.inlineImages);
|
|
863
|
+
const spawnStdinPlan = buildSpawnStdinPlan(prompt, runtime?.resumePending ? undefined : msg.inlineImages);
|
|
793
864
|
// MCP server config passed via --mcp-config flag (no temp files needed)
|
|
794
865
|
const mcpServerPath = join(getBaseBrainPath(), 'mcp', 'slack.mjs');
|
|
795
866
|
const teamvibeApiServerPath = join(getBaseBrainPath(), 'mcp', 'teamvibe-api.mjs');
|
|
@@ -819,10 +890,14 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
819
890
|
// > CLAUDE_MODEL env default. See resolveModel's docstring for why
|
|
820
891
|
// settings.json stays on top (known, accepted blind spot).
|
|
821
892
|
const brainSettingsModel = typeof brainSettings['model'] === 'string' ? brainSettings['model'] : undefined;
|
|
822
|
-
const { model, modelSource } = resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
|
|
893
|
+
const { model, modelSource } = runtime?.resolved ?? resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
|
|
823
894
|
if (model) {
|
|
824
895
|
args.push('--model', model);
|
|
825
|
-
|
|
896
|
+
// The service normalized the scoped config; only describe that mapping
|
|
897
|
+
// when this tier actually supplied the flag (brain settings stay verbatim).
|
|
898
|
+
const normalizedFrom = runtime?.modelNormalizedFrom ?? (model === ddbModelConfig.model && modelSource === ddbModelConfig.modelSource
|
|
899
|
+
? ddbModelConfig.modelNormalizedFrom : undefined);
|
|
900
|
+
sessionLog.info(`Using model: ${model} (source: ${modelSource ?? 'unknown'})${normalizedFrom ? ` (normalized from ${normalizedFrom})` : ''}`);
|
|
826
901
|
}
|
|
827
902
|
// Inbox protocol: instruct Claude to check for mid-task messages (thread-scoped)
|
|
828
903
|
const inboxNewDir = `.inbox/${msg.thread_id}/new`;
|
|
@@ -864,13 +939,15 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
864
939
|
const envPath = config.PERSISTENT_STORAGE_PATH
|
|
865
940
|
? `${config.PERSISTENT_STORAGE_PATH}/bin:${process.env['PATH'] || ''}`
|
|
866
941
|
: process.env['PATH'];
|
|
942
|
+
if (runtime?.account)
|
|
943
|
+
poolInfo(sessionLog, `token target account=${poolLabel(runtime.account)} session=${JSON.stringify(sessionId ?? 'new')} credential=selected account token`);
|
|
867
944
|
const proc = spawn(config.CLAUDE_CLI_PATH, args, {
|
|
868
945
|
cwd,
|
|
869
946
|
env: buildSpawnEnv({
|
|
870
947
|
spawnSecretsEnv: spawnSecrets.env.unwrap(),
|
|
871
948
|
processEnv: process.env,
|
|
872
949
|
envPath,
|
|
873
|
-
claudeConfigDir: getBaseBrainPath(),
|
|
950
|
+
claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
|
|
874
951
|
persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
|
|
875
952
|
slackContext,
|
|
876
953
|
teamvibeApiUrl: config.TEAMVIBE_API_URL,
|
|
@@ -881,6 +958,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
881
958
|
teamvibePollerId: getPollerId() || '',
|
|
882
959
|
inboxThreadId: msg.thread_id,
|
|
883
960
|
brainPath,
|
|
961
|
+
subscriptionAccount: runtime?.account,
|
|
884
962
|
}),
|
|
885
963
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
886
964
|
// teamvibeai/teamvibe.ai#73: run `claude` (via setsid) as the leader of
|
|
@@ -903,7 +981,11 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
903
981
|
let stdout = '';
|
|
904
982
|
let stderr = '';
|
|
905
983
|
let stdoutBuffer = '';
|
|
984
|
+
// Retain CLI error/result evidence, not the full parsed assistant/tool stream.
|
|
985
|
+
const failureEvents = [];
|
|
986
|
+
let rateLimitInfo;
|
|
906
987
|
let metrics;
|
|
988
|
+
let lastAssistant;
|
|
907
989
|
// teamvibe.ai#284 (poller-brain#235): end-of-turn guard state — tracked
|
|
908
990
|
// across the whole run so the `close` handler can tell poller.ts whether
|
|
909
991
|
// a successful turn actually delivered anything to Slack.
|
|
@@ -958,6 +1040,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
958
1040
|
for (const line of lines) {
|
|
959
1041
|
try {
|
|
960
1042
|
const event = JSON.parse(line);
|
|
1043
|
+
if (event?.type === 'rate_limit_event') {
|
|
1044
|
+
rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
|
|
1045
|
+
}
|
|
1046
|
+
if (event?.type === 'result' || event?.type === 'error' || (event?.type === 'assistant' && event.error)) {
|
|
1047
|
+
failureEvents.push(event);
|
|
1048
|
+
}
|
|
961
1049
|
// Capture the pre-event activity timestamp BEFORE the unconditional
|
|
962
1050
|
// overwrite below — needed to compute the "original 10min deadline
|
|
963
1051
|
// from silence start" for a compact_boundary line, since that line
|
|
@@ -993,20 +1081,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
993
1081
|
sessionLog.info('[Watchdog] Activity resumed after compaction, reverting to normal idle watchdog');
|
|
994
1082
|
compactionKillDeadline = null;
|
|
995
1083
|
}
|
|
1084
|
+
if (event.type === 'assistant')
|
|
1085
|
+
lastAssistant = { usage: event.message?.usage, model: event.message?.model };
|
|
996
1086
|
// Extract metrics from result event
|
|
997
1087
|
if (event.type === 'result') {
|
|
998
|
-
metrics =
|
|
999
|
-
|
|
1000
|
-
duration_api_ms: event.duration_api_ms || 0,
|
|
1001
|
-
num_turns: event.num_turns || 0,
|
|
1002
|
-
total_cost_usd: event.total_cost_usd || 0,
|
|
1003
|
-
input_tokens: event.usage?.input_tokens || 0,
|
|
1004
|
-
output_tokens: event.usage?.output_tokens || 0,
|
|
1005
|
-
cache_read_input_tokens: event.usage?.cache_read_input_tokens || 0,
|
|
1006
|
-
cache_creation_input_tokens: event.usage?.cache_creation_input_tokens || 0,
|
|
1007
|
-
model: Object.keys(event.modelUsage || {})[0] || 'unknown',
|
|
1008
|
-
};
|
|
1009
|
-
sessionLog.info(`[Metrics] Cost: $${metrics.total_cost_usd.toFixed(4)}, ` +
|
|
1088
|
+
metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
|
|
1089
|
+
sessionLog.info(`[Metrics] Cost: $${metrics.total_cost_usd?.toFixed(4) ?? 'unknown'}, ` +
|
|
1010
1090
|
`Duration: ${metrics.duration_ms}ms, ` +
|
|
1011
1091
|
`Turns: ${metrics.num_turns}, ` +
|
|
1012
1092
|
`Tokens: ${metrics.input_tokens}in/${metrics.output_tokens}out`);
|
|
@@ -1169,8 +1249,29 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1169
1249
|
// not something shutdown() awaits.
|
|
1170
1250
|
void sweepProcessGroup(proc, CLOSE_SWEEP_GRACE_MS, { unref: true });
|
|
1171
1251
|
const killReason = killReasonByProc.get(proc);
|
|
1252
|
+
// Preserve a final unterminated stream-json error/result and its metrics.
|
|
1253
|
+
if (stdoutBuffer.trim()) {
|
|
1254
|
+
try {
|
|
1255
|
+
const event = JSON.parse(stdoutBuffer);
|
|
1256
|
+
if (event?.type === 'rate_limit_event') {
|
|
1257
|
+
rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
|
|
1258
|
+
}
|
|
1259
|
+
failureEvents.push(event);
|
|
1260
|
+
if (event?.type === 'assistant')
|
|
1261
|
+
lastAssistant = { usage: event.message?.usage, model: event.message?.model };
|
|
1262
|
+
if (event?.type === 'result')
|
|
1263
|
+
metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
// Non-JSON stdout is not trusted CLI error evidence.
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
const quota = parseClaudeQuotaFailure({ exitCode: code, stderr, events: failureEvents, rateLimitInfo }, new Date());
|
|
1172
1270
|
const result = {
|
|
1173
|
-
success: code === 0,
|
|
1271
|
+
success: code === 0 && !quota,
|
|
1272
|
+
attemptedSessionId: sessionId,
|
|
1273
|
+
...(quota ? { quota } : {}),
|
|
1274
|
+
...(rateLimitInfo ? { rateLimitInfo } : {}),
|
|
1174
1275
|
output: stdout,
|
|
1175
1276
|
exitCode: code,
|
|
1176
1277
|
metrics,
|
|
@@ -1191,6 +1292,9 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1191
1292
|
else if (killReason === 'zombie') {
|
|
1192
1293
|
result.error = 'Killed as zombie process (stale session lock)';
|
|
1193
1294
|
}
|
|
1295
|
+
else if (quota) {
|
|
1296
|
+
result.error = 'Claude subscription usage limit reached.';
|
|
1297
|
+
}
|
|
1194
1298
|
else if (code !== 0) {
|
|
1195
1299
|
// `signal` matters here specifically: a process terminated by a
|
|
1196
1300
|
// signal reports `code: null`, not a numeric exit code — e.g. a
|
|
@@ -1220,6 +1324,8 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1220
1324
|
output: stdout,
|
|
1221
1325
|
exitCode: null,
|
|
1222
1326
|
error: error.message,
|
|
1327
|
+
...(rateLimitInfo ? { rateLimitInfo } : {}),
|
|
1328
|
+
attemptedSessionId: sessionId,
|
|
1223
1329
|
sawSendMessageCall,
|
|
1224
1330
|
lastPlainTextContent,
|
|
1225
1331
|
});
|
|
@@ -1246,9 +1352,11 @@ function shouldIsolateInWorktree(msg) {
|
|
|
1246
1352
|
* createSession() placeholder forever and the thread's next message does
|
|
1247
1353
|
* `--resume <id-claude-code-never-used>`, which fails 100% of the time.
|
|
1248
1354
|
* (teamvibe.ai#77, DevGuru review round)
|
|
1355
|
+
* Quota failures also report the attempted ID, even on the first turn, so
|
|
1356
|
+
* later pool orchestration can continue that same session (#401).
|
|
1249
1357
|
*/
|
|
1250
1358
|
export function finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessionId) {
|
|
1251
|
-
if (isFirstMessage && retryResult.success && effectiveSessionId) {
|
|
1359
|
+
if (isFirstMessage && (retryResult.success || retryResult.quota) && effectiveSessionId) {
|
|
1252
1360
|
return { ...retryResult, newSessionId: effectiveSessionId };
|
|
1253
1361
|
}
|
|
1254
1362
|
return retryResult;
|
|
@@ -1282,14 +1390,14 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
1282
1390
|
const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
|
|
1283
1391
|
// If --resume failed, retry as a fresh session (session files may have been lost on container restart)
|
|
1284
1392
|
let retryResult = result;
|
|
1285
|
-
if (!result.success && effectiveSessionId && !isFirstMessage) {
|
|
1393
|
+
if (!result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
|
|
1286
1394
|
sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
|
|
1287
1395
|
retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
|
|
1288
1396
|
}
|
|
1289
1397
|
// If session ID is "already in use" (stale lock file), retry immediately with a
|
|
1290
1398
|
// fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
|
|
1291
1399
|
// retry's (teamvibe.ai#77 acceptance criteria: "no zombie kill needed first").
|
|
1292
|
-
if (!retryResult.success && effectiveSessionId && retryResult.error?.includes('already in use')) {
|
|
1400
|
+
if (!retryResult.success && !retryResult.quota && effectiveSessionId && retryResult.error?.includes('already in use')) {
|
|
1293
1401
|
sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
|
|
1294
1402
|
const { randomUUID } = await import('crypto');
|
|
1295
1403
|
const freshId = randomUUID();
|
|
@@ -1306,6 +1414,196 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
1306
1414
|
}
|
|
1307
1415
|
}
|
|
1308
1416
|
}
|
|
1417
|
+
// Configured-pool orchestration is deliberately separate from legacy spawns.
|
|
1418
|
+
export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, poolRuntime) {
|
|
1419
|
+
// Worktree isolation: spawned (non-foreground) sessions run in
|
|
1420
|
+
// <brain>/.worktrees/<thread_id>/ so the sync-watchdog's reset --hard on
|
|
1421
|
+
// the main working dir can't trample in-flight writes or pre-push commits.
|
|
1422
|
+
// See teamvibe.ai#120. cwd remains the main brain path on fallback / failure.
|
|
1423
|
+
let effectiveCwd = cwd;
|
|
1424
|
+
let loggingPool = false;
|
|
1425
|
+
let retainWorktree = poolRuntime?.pin?.resumePending === true;
|
|
1426
|
+
const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
|
|
1427
|
+
let worktreeOwner;
|
|
1428
|
+
if (poolRuntime?.pin?.resumePending && poolRuntime.pin.effectiveCwd) {
|
|
1429
|
+
const pinnedCwd = poolRuntime.pin.effectiveCwd;
|
|
1430
|
+
if (pinnedCwd !== cwd && pinnedCwd !== join(cwd, '.worktrees', msg.thread_id))
|
|
1431
|
+
throw new PoolDeferredError();
|
|
1432
|
+
if (!existsSync(pinnedCwd)) {
|
|
1433
|
+
sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${pinnedCwd} for pending session ${sessionId ?? 'unknown'}`);
|
|
1434
|
+
try {
|
|
1435
|
+
if (pinnedCwd === cwd)
|
|
1436
|
+
await mkdir(cwd, { recursive: true });
|
|
1437
|
+
else
|
|
1438
|
+
await createWorktree(cwd, msg.thread_id);
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
sessionLog.warn('[Worktree] Pinned worktree recreation failed; pending work retained for the next recheck');
|
|
1442
|
+
throw new PoolDeferredError();
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
effectiveCwd = pinnedCwd;
|
|
1446
|
+
if (effectiveCwd !== cwd)
|
|
1447
|
+
worktreeOwner = { brainPath: cwd, id: msg.thread_id };
|
|
1448
|
+
}
|
|
1449
|
+
else if (shouldIsolateInWorktree(msg)) {
|
|
1450
|
+
try {
|
|
1451
|
+
effectiveCwd = await createWorktree(cwd, msg.thread_id);
|
|
1452
|
+
worktreeOwner = { brainPath: cwd, id: msg.thread_id };
|
|
1453
|
+
sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
|
|
1454
|
+
}
|
|
1455
|
+
catch (err) {
|
|
1456
|
+
sessionLog.warn(`[Worktree] Failed to create worktree, falling back to main brain dir: ${err instanceof Error ? err.message : err}`);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
try {
|
|
1460
|
+
// First-message spawns always mint a fresh session ID rather than
|
|
1461
|
+
// recycling whatever placeholder DynamoDB currently holds for this
|
|
1462
|
+
// thread (teamvibe.ai#77) — --resume on non-first messages is unchanged.
|
|
1463
|
+
let effectiveSessionId = sessionId;
|
|
1464
|
+
if (isFirstMessage && !poolRuntime?.pin?.resumePending) {
|
|
1465
|
+
const { randomUUID } = await import('crypto');
|
|
1466
|
+
effectiveSessionId = randomUUID();
|
|
1467
|
+
}
|
|
1468
|
+
const fetchedPool = poolRuntime ? poolRuntime.pool ?? await fetchRuntimePool(msg) : undefined;
|
|
1469
|
+
loggingPool = fetchedPool !== undefined && fetchedPool.kind !== 'no_accounts_configured';
|
|
1470
|
+
if (fetchedPool)
|
|
1471
|
+
logPoolFetch(sessionLog, fetchedPool);
|
|
1472
|
+
const scoped = poolRuntime ? await fetchConfigForSpawn({ pollerId: getPollerId() || '', workspaceId: msg.teamvibe.workspaceId, channelId: msg.teamvibe.channelId }) : undefined;
|
|
1473
|
+
const settings = readBrainSettings(effectiveCwd);
|
|
1474
|
+
const resolved = fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime?.pin?.workspaceId === msg.teamvibe.workspaceId
|
|
1475
|
+
? { model: poolRuntime.pin.model, modelSource: poolRuntime.pin.modelSource }
|
|
1476
|
+
: resolveModel(typeof settings['model'] === 'string' ? settings['model'] : undefined, scoped ?? {}, config.CLAUDE_MODEL);
|
|
1477
|
+
const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool, sessionLog) : undefined;
|
|
1478
|
+
const claudeConfigDir = poolRuntime?.pin?.resumePending ? poolRuntime.pin.claudeConfigDir ?? getBaseBrainPath() : getBaseBrainPath();
|
|
1479
|
+
const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
|
|
1480
|
+
? scoped?.modelNormalizedFrom : undefined;
|
|
1481
|
+
const runtime = poolRuntime ? { resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, shouldStop: poolRuntime.shouldStop, resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true } : undefined;
|
|
1482
|
+
let sawSendMessageCall = poolRuntime?.pin?.resumePending === true && poolRuntime.pin.sawSendMessageCall === true;
|
|
1483
|
+
const attempt = async (id, first) => {
|
|
1484
|
+
if (prepared?.account && id && poolRuntime) {
|
|
1485
|
+
try {
|
|
1486
|
+
await poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts);
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
throw new PoolDeferredError();
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
// Only a quota continuation shares delivery history. A fresh-session
|
|
1493
|
+
// fallback must report the fresh attempt's delivery flags independently.
|
|
1494
|
+
if (!runtime?.resumePending)
|
|
1495
|
+
sawSendMessageCall = false;
|
|
1496
|
+
const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
|
|
1497
|
+
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
1498
|
+
if (prepared?.account) {
|
|
1499
|
+
if (poolAttempts.length >= 1000)
|
|
1500
|
+
poolAttempts.shift();
|
|
1501
|
+
poolAttempts.push({ workspaceId: prepared.account.workspaceId, accountId: prepared.account.accountId, credentialVersion: prepared.account.credentialVersion, accountLabel: prepared.account.label ?? prepared.account.accountId, observedAt: Date.now(), rateLimitInfo: result.rateLimitInfo, sessionId: id, model: resolved.model ?? result.metrics?.model, success: result.success, exitCode: result.exitCode, killedByPoller: result.killedByPoller, quota: result.quota, metrics: result.metrics });
|
|
1502
|
+
emitEvent('pool.account_attempted', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime?.quotaDeferEpisodes) });
|
|
1503
|
+
if (result.success && id && poolRuntime) {
|
|
1504
|
+
// Complete the inbox before any fallible post-inference store writes.
|
|
1505
|
+
// The loop retains bookkeeping independently of the original prompt.
|
|
1506
|
+
const bookkeepingComplete = await poolRuntime.complete(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: false }, poolAttempts, result.rateLimitInfo);
|
|
1507
|
+
if (bookkeepingComplete !== false)
|
|
1508
|
+
await emitCandidateUsage(msg, [prepared.account], prepared.store);
|
|
1509
|
+
retainWorktree = false;
|
|
1510
|
+
return result;
|
|
1511
|
+
}
|
|
1512
|
+
if (result.quota && !result.killedByPoller) {
|
|
1513
|
+
runtime.resumePending = true;
|
|
1514
|
+
retainWorktree = true;
|
|
1515
|
+
}
|
|
1516
|
+
// A failure in one store must not prevent the other stores from
|
|
1517
|
+
// retaining the attempt. Selection waits for all writes before switching.
|
|
1518
|
+
const writes = [];
|
|
1519
|
+
if (result.quota && !result.killedByPoller)
|
|
1520
|
+
writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.quota.window ? { rateLimitType: result.quota.window } : undefined, () => prepared.store.recordQuota(prepared.account, result.quota, Date.now(), { expireFutureObservations: true }), 'quota'));
|
|
1521
|
+
if (result.rateLimitInfo)
|
|
1522
|
+
writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.rateLimitInfo, () => prepared.store.recordPassive(prepared.account, result.rateLimitInfo, Date.now(), { expireFutureObservations: true })));
|
|
1523
|
+
if (id && poolRuntime)
|
|
1524
|
+
writes.push(poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts));
|
|
1525
|
+
const outcomes = await Promise.allSettled(writes);
|
|
1526
|
+
if (outcomes.some(outcome => outcome.status === 'rejected'))
|
|
1527
|
+
throw new PoolDeferredError();
|
|
1528
|
+
}
|
|
1529
|
+
return result;
|
|
1530
|
+
};
|
|
1531
|
+
const attemptedAccounts = new Set();
|
|
1532
|
+
let switched = false;
|
|
1533
|
+
const finishQuota = async (initial) => {
|
|
1534
|
+
let result = initial;
|
|
1535
|
+
while (prepared?.account && result.quota && !result.killedByPoller && poolRuntime && runtime) {
|
|
1536
|
+
attemptedAccounts.add(prepared.account.accountId);
|
|
1537
|
+
if (poolRuntime.shouldStop() || attemptedAccounts.size >= 1000)
|
|
1538
|
+
throw new PoolDeferredError();
|
|
1539
|
+
prepared.pool = await fetchRuntimePool(msg);
|
|
1540
|
+
logPoolFetch(sessionLog, prepared.pool);
|
|
1541
|
+
if (prepared.pool.kind === 'ready')
|
|
1542
|
+
await emitCandidateUsage(msg, prepared.pool.accounts, prepared.store);
|
|
1543
|
+
// Reactive evidence was persisted by attempt() before choosing another account.
|
|
1544
|
+
if (prepared.pool.kind === 'ready')
|
|
1545
|
+
for (const account of prepared.pool.accounts) {
|
|
1546
|
+
if (attemptedAccounts.has(account.accountId))
|
|
1547
|
+
poolInfo(sessionLog, `skipped account=${poolLabel(account)} reason=already exhausted in this session`);
|
|
1548
|
+
}
|
|
1549
|
+
const remaining = prepared.pool.kind === 'ready' ? prepared.pool.accounts.filter(a => !attemptedAccounts.has(a.accountId)) : [];
|
|
1550
|
+
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: remaining.length && prepared.pool.kind === 'ready' ? { ...prepared.pool, accounts: remaining } : prepared.pool, store: prepared.store, now: Date.now(), log: sessionLog, logAllExhausted: remaining.length === 0 });
|
|
1551
|
+
if (selected.kind !== 'selected' || attemptedAccounts.has(selected.account.accountId))
|
|
1552
|
+
throw await poolDeferredError(prepared.pool, resolved, prepared.store, undefined, remaining.length ? sessionLog : undefined);
|
|
1553
|
+
effectiveSessionId = result.attemptedSessionId ?? effectiveSessionId;
|
|
1554
|
+
if (!effectiveSessionId)
|
|
1555
|
+
throw new PoolDeferredError();
|
|
1556
|
+
const fromAccount = prepared.account;
|
|
1557
|
+
poolInfo(sessionLog, `switch from=${poolLabel(fromAccount)} to=${poolLabel(selected.account)} session=${JSON.stringify(effectiveSessionId)}`);
|
|
1558
|
+
prepared.account = selected.account;
|
|
1559
|
+
runtime.account = selected.account;
|
|
1560
|
+
runtime.resumePending = true;
|
|
1561
|
+
switched = true;
|
|
1562
|
+
const selectedUsage = await prepared.store.read(selected.account).catch(() => ({ reactive: [], passive: undefined }));
|
|
1563
|
+
emitEvent('pool.account_switched', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime.quotaDeferEpisodes), ...accountUsageTelemetry(selected.account, selectedUsage), lastRateLimitInfo: selectedUsage.passive?.rateLimitInfo, observedAt: selectedUsage.passive?.observedAt, fromAccountId: fromAccount.accountId, fromAccountLabel: fromAccount.label ?? fromAccount.accountId, model: resolved.model, chainLength: poolAttempts.length + 1, switchedAccounts: true });
|
|
1564
|
+
result = await attempt(effectiveSessionId, false);
|
|
1565
|
+
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
1566
|
+
}
|
|
1567
|
+
return { ...result, sawSendMessageCall, ...(prepared?.account ? { poolAttempts } : {}) };
|
|
1568
|
+
};
|
|
1569
|
+
const result = await finishQuota(await attempt(effectiveSessionId, isFirstMessage));
|
|
1570
|
+
let replacedSessionId = false;
|
|
1571
|
+
// If --resume failed, retry as a fresh session (session files may have been lost on container restart)
|
|
1572
|
+
let retryResult = result;
|
|
1573
|
+
if (!runtime?.resumePending && !switched && !result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
|
|
1574
|
+
sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
|
|
1575
|
+
retryResult = await finishQuota(await attempt(effectiveSessionId, true));
|
|
1576
|
+
}
|
|
1577
|
+
// If session ID is "already in use" (stale lock file), retry immediately with a
|
|
1578
|
+
// fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
|
|
1579
|
+
// retry's (teamvibe.ai#77 acceptance criteria: "no zombie kill needed first").
|
|
1580
|
+
if (!runtime?.resumePending && !switched && !retryResult.success && !retryResult.quota && effectiveSessionId && retryResult.error?.includes('already in use')) {
|
|
1581
|
+
sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
|
|
1582
|
+
const { randomUUID } = await import('crypto');
|
|
1583
|
+
const freshId = randomUUID();
|
|
1584
|
+
effectiveSessionId = freshId;
|
|
1585
|
+
replacedSessionId = true;
|
|
1586
|
+
retryResult = await finishQuota(await attempt(freshId, true));
|
|
1587
|
+
}
|
|
1588
|
+
return finalizeSpawnResult(retryResult, isFirstMessage || replacedSessionId, effectiveSessionId);
|
|
1589
|
+
}
|
|
1590
|
+
catch (error) {
|
|
1591
|
+
if (error instanceof PoolDeferredError) {
|
|
1592
|
+
if (loggingPool)
|
|
1593
|
+
poolInfo(sessionLog, `work deferred eligibleAt=${new Date(error.eligibleAt).toISOString()} providerResetAt=${error.resetAt ?? 'unknown'}`);
|
|
1594
|
+
retainWorktree = true;
|
|
1595
|
+
error.attempts = poolAttempts;
|
|
1596
|
+
}
|
|
1597
|
+
throw error;
|
|
1598
|
+
}
|
|
1599
|
+
finally {
|
|
1600
|
+
if (worktreeOwner && !retainWorktree) {
|
|
1601
|
+
removeWorktree(worktreeOwner.brainPath, worktreeOwner.id).catch((err) => {
|
|
1602
|
+
sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1309
1607
|
const activeProcesses = new Map();
|
|
1310
1608
|
const activeProcessesByThread = new Map();
|
|
1311
1609
|
// tv#317: when the last `claude` process ended, so the idle-restart gate can require
|
package/dist/config-client.d.ts
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
export type ModelConfigScope = 'poller' | 'workspace' | 'channel';
|
|
27
27
|
export interface ResolvedModelConfig {
|
|
28
|
+
/** Original stored id supplied by the service after read normalization. */
|
|
29
|
+
modelNormalizedFrom?: string;
|
|
28
30
|
model?: string;
|
|
29
31
|
reasoningEffort?: string;
|
|
30
32
|
modelSource?: ModelConfigScope;
|
package/dist/config-client.js
CHANGED
|
@@ -104,6 +104,9 @@ export async function fetchConfigForSpawn(input) {
|
|
|
104
104
|
if (typeof env.REASONING_EFFORT === 'string' && env.REASONING_EFFORT) {
|
|
105
105
|
result.reasoningEffort = env.REASONING_EFFORT;
|
|
106
106
|
}
|
|
107
|
+
if (result.model && typeof parsed.modelNormalizedFrom === 'string' && parsed.modelNormalizedFrom) {
|
|
108
|
+
result.modelNormalizedFrom = parsed.modelNormalizedFrom;
|
|
109
|
+
}
|
|
107
110
|
if (isModelConfigScope(sources.MODEL))
|
|
108
111
|
result.modelSource = sources.MODEL;
|
|
109
112
|
if (isModelConfigScope(sources.REASONING_EFFORT))
|
package/dist/event-queue.js
CHANGED
|
@@ -69,7 +69,7 @@ export async function flush() {
|
|
|
69
69
|
return;
|
|
70
70
|
isFlushing = true;
|
|
71
71
|
try {
|
|
72
|
-
const batch =
|
|
72
|
+
const batch = queue.splice(0);
|
|
73
73
|
const failed = [];
|
|
74
74
|
for (const event of batch) {
|
|
75
75
|
const ok = await flushOne(event);
|
|
@@ -77,7 +77,7 @@ export async function flush() {
|
|
|
77
77
|
failed.push(event);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
queue = failed;
|
|
80
|
+
queue = [...failed, ...queue].slice(-MAX_QUEUE_SIZE);
|
|
81
81
|
if (failed.length > 0) {
|
|
82
82
|
logger.debug(`[EventQueue] ${failed.length} event(s) failed to flush, will retry`);
|
|
83
83
|
}
|
package/dist/inbox-manager.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
2
|
+
import type { PoolAttempt } from './subscription-runtime.js';
|
|
3
|
+
import type { TeamVibeQueueMessage, SubscriptionPin } from './types.js';
|
|
1
4
|
/**
|
|
2
5
|
* Write a new message to the inbox.
|
|
3
6
|
* Returns the filename of the created message file.
|
|
@@ -23,7 +26,7 @@ export declare function markProcessed(brainPath: string, threadId: string, filen
|
|
|
23
26
|
/**
|
|
24
27
|
* Move a message from processing/ back to new/.
|
|
25
28
|
*
|
|
26
|
-
* Used
|
|
29
|
+
* Used for quota-paused spawn preparation and a poller-initiated *shutdown* kill (teamvibe.ai#73,
|
|
27
30
|
* PR#308 round-5): pickNextMessage() above moves a file to processing/ as
|
|
28
31
|
* soon as it's picked up, not once it completes, so a message killed
|
|
29
32
|
* mid-flight by shutdown() is left sitting in processing/. The inbox
|
|
@@ -49,3 +52,21 @@ export declare function requeueMessage(brainPath: string, threadId: string, file
|
|
|
49
52
|
* Check if there are unprocessed messages in new/ or processing/.
|
|
50
53
|
*/
|
|
51
54
|
export declare function hasUnprocessed(brainPath: string, threadId: string): boolean;
|
|
55
|
+
export declare function saveInboxContext(brainPath: string, msg: TeamVibeQueueMessage): void;
|
|
56
|
+
export declare function readInboxContext(brainPath: string, threadId: string): TeamVibeQueueMessage | undefined;
|
|
57
|
+
export interface InboxRuntime {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
pin: SubscriptionPin;
|
|
60
|
+
filename: string;
|
|
61
|
+
quotaDeferEpisodes?: number;
|
|
62
|
+
attempts?: readonly PoolAttempt[];
|
|
63
|
+
/** Inference finished; only bookkeeping may be retried, never the prompt. */
|
|
64
|
+
completed?: {
|
|
65
|
+
observedAt: number;
|
|
66
|
+
retryAt: number;
|
|
67
|
+
rateLimitInfo?: ClaudeRateLimitInfo;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export declare function saveInboxRuntime(brainPath: string, threadId: string, state: InboxRuntime): void;
|
|
71
|
+
export declare function readInboxRuntime(brainPath: string, threadId: string): InboxRuntime | undefined;
|
|
72
|
+
export declare function clearInboxRuntime(brainPath: string, threadId: string): void;
|