@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
package/dist/claude-spawner.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
+
import { accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
|
|
1
2
|
import { spawn } from 'child_process';
|
|
2
3
|
import { join } from 'path';
|
|
3
4
|
import { readFileSync, existsSync } from 'fs';
|
|
5
|
+
import { mkdir } from 'fs/promises';
|
|
4
6
|
import { WebClient } from '@slack/web-api';
|
|
5
7
|
import { config } from './config.js';
|
|
6
8
|
import { logger } from './logger.js';
|
|
7
9
|
import { getBaseBrainPath } from './brain-manager.js';
|
|
8
10
|
import { getPollerId } from './auth-provider.js';
|
|
9
11
|
import { sendSlackMessage } from './slack-client.js';
|
|
10
|
-
import { emitError } from './event-queue.js';
|
|
12
|
+
import { emitError, emitEvent } from './event-queue.js';
|
|
11
13
|
import { createWorktree, removeWorktree, isWorktreeIsolationEnabled } from './worktree-manager.js';
|
|
12
14
|
import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js';
|
|
13
15
|
import { fetchConfigForSpawn } from './config-client.js';
|
|
16
|
+
import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
17
|
+
import { parseClaudeQuotaFailure } from './claude-quota.js';
|
|
18
|
+
import { accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
|
|
19
|
+
import { selectPoolForResolvedModel } from './subscription-pool.js';
|
|
20
|
+
import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
|
|
14
21
|
/**
|
|
15
22
|
* Maps tool names (or prefixes) to human-readable status messages.
|
|
16
23
|
* More specific patterns are checked first.
|
|
@@ -462,6 +469,17 @@ export function buildSpawnEnv(params) {
|
|
|
462
469
|
// drops against this var so they land somewhere still being
|
|
463
470
|
// watched after the session (and worktree) is gone. See tv#265.
|
|
464
471
|
POLLER_BRAIN_PATH: params.brainPath,
|
|
472
|
+
...(params.subscriptionAccount ? {
|
|
473
|
+
CLAUDE_CODE_OAUTH_TOKEN: params.subscriptionAccount.credential.unwrap(),
|
|
474
|
+
ANTHROPIC_API_KEY: undefined,
|
|
475
|
+
ANTHROPIC_AUTH_TOKEN: undefined,
|
|
476
|
+
ANTHROPIC_BASE_URL: undefined,
|
|
477
|
+
ANTHROPIC_CUSTOM_HEADERS: undefined,
|
|
478
|
+
CLAUDE_CODE_OAUTH_TOKEN_FILE: undefined,
|
|
479
|
+
CLAUDE_CODE_USE_BEDROCK: undefined,
|
|
480
|
+
CLAUDE_CODE_USE_VERTEX: undefined,
|
|
481
|
+
CLAUDE_CODE_USE_FOUNDRY: undefined,
|
|
482
|
+
} : {}),
|
|
465
483
|
};
|
|
466
484
|
}
|
|
467
485
|
export function buildPrompt(msg, contextHint) {
|
|
@@ -737,7 +755,51 @@ function resolveTypingStatusDetail(event, assistantText) {
|
|
|
737
755
|
export function resolveTypingStatus(event, assistantText) {
|
|
738
756
|
return resolveTypingStatusDetail(event, assistantText)?.text ?? null;
|
|
739
757
|
}
|
|
740
|
-
|
|
758
|
+
function existingResultMetrics(event, pooled) {
|
|
759
|
+
// Preserve the legacy extractor exactly for non-pool spawns.
|
|
760
|
+
if (!pooled)
|
|
761
|
+
return {
|
|
762
|
+
duration_ms: event.duration_ms || 0,
|
|
763
|
+
duration_api_ms: event.duration_api_ms || 0,
|
|
764
|
+
num_turns: event.num_turns || 0,
|
|
765
|
+
total_cost_usd: event.total_cost_usd || 0,
|
|
766
|
+
input_tokens: event.usage?.input_tokens || 0,
|
|
767
|
+
output_tokens: event.usage?.output_tokens || 0,
|
|
768
|
+
cache_read_input_tokens: event.usage?.cache_read_input_tokens || 0,
|
|
769
|
+
cache_creation_input_tokens: event.usage?.cache_creation_input_tokens || 0,
|
|
770
|
+
model: Object.keys(event.modelUsage || {})[0] || 'unknown',
|
|
771
|
+
};
|
|
772
|
+
const result = {};
|
|
773
|
+
for (const key of ['duration_ms', 'duration_api_ms', 'num_turns', 'total_cost_usd']) {
|
|
774
|
+
if (typeof event[key] === 'number' && Number.isFinite(event[key]) && event[key] >= 0)
|
|
775
|
+
result[key] = event[key];
|
|
776
|
+
}
|
|
777
|
+
for (const key of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
|
|
778
|
+
if (typeof event.usage?.[key] === 'number' && Number.isFinite(event.usage[key]) && event.usage[key] >= 0)
|
|
779
|
+
result[key] = event.usage[key];
|
|
780
|
+
}
|
|
781
|
+
const model = Object.keys(event.modelUsage || {})[0];
|
|
782
|
+
if (model)
|
|
783
|
+
result.model = model;
|
|
784
|
+
return result;
|
|
785
|
+
}
|
|
786
|
+
// Context occupancy is one model call's input, never the accumulated result usage.
|
|
787
|
+
function resultMetrics(event, pooled, lastAssistant) {
|
|
788
|
+
const metrics = existingResultMetrics(event, pooled);
|
|
789
|
+
const usage = lastAssistant?.usage;
|
|
790
|
+
const inputs = [usage?.input_tokens, usage?.cache_read_input_tokens, usage?.cache_creation_input_tokens];
|
|
791
|
+
if (inputs.every(value => typeof value === 'number' && Number.isFinite(value) && value >= 0)) {
|
|
792
|
+
const tokens = inputs.reduce((sum, value) => sum + value, 0);
|
|
793
|
+
if (Number.isFinite(tokens))
|
|
794
|
+
metrics.context_tokens = tokens;
|
|
795
|
+
}
|
|
796
|
+
const model = lastAssistant?.model ?? Object.keys(event.modelUsage || {})[0];
|
|
797
|
+
const window = event.modelUsage?.[model]?.contextWindow;
|
|
798
|
+
if (typeof window === 'number' && Number.isFinite(window) && window > 0)
|
|
799
|
+
metrics.context_window = window;
|
|
800
|
+
return metrics;
|
|
801
|
+
}
|
|
802
|
+
async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
|
|
741
803
|
const slackContext = msg.response_context.slack;
|
|
742
804
|
const isCronMessage = msg.source === 'cron';
|
|
743
805
|
const isMaintenance = msg.source === 'maintenance';
|
|
@@ -757,7 +819,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
757
819
|
// the more informative signal. Logic extracted into buildContextHint for
|
|
758
820
|
// unit testability — the counters are injected so tests don't need to mock
|
|
759
821
|
// the Slack WebClient module.
|
|
760
|
-
const contextHint = await buildContextHint({
|
|
822
|
+
const contextHint = runtime?.resumePending ? undefined : await buildContextHint({
|
|
761
823
|
isFirstMessage,
|
|
762
824
|
lastMessageTs,
|
|
763
825
|
slack: slackContext,
|
|
@@ -779,17 +841,25 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
779
841
|
// Fetch scoped model config for this spawn — same hoisting rationale as
|
|
780
842
|
// secrets above. Fail-soft: returns {} on any error, which resolveModel
|
|
781
843
|
// treats as "nothing at this tier" and falls through to the env default.
|
|
782
|
-
const ddbModelConfig = await fetchConfigForSpawn({
|
|
844
|
+
const ddbModelConfig = runtime ? {} : await fetchConfigForSpawn({
|
|
783
845
|
pollerId: getPollerId() || '',
|
|
784
846
|
workspaceId: msg.teamvibe.workspaceId,
|
|
785
847
|
channelId: msg.teamvibe.channelId,
|
|
786
848
|
});
|
|
849
|
+
if (runtime?.account)
|
|
850
|
+
await validateAccount(runtime.account, runtime.resolved);
|
|
787
851
|
return new Promise((resolve) => {
|
|
788
|
-
|
|
852
|
+
// Preparation above awaits remote config/context. Another thread may
|
|
853
|
+
// have exhausted the shared account meanwhile. No awaits before spawn.
|
|
854
|
+
if (!runtime?.account && isQuotaPaused())
|
|
855
|
+
throw new QuotaPausedError();
|
|
856
|
+
if (runtime?.shouldStop())
|
|
857
|
+
throw new PoolDeferredError();
|
|
858
|
+
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
859
|
// teamvibe.ai#293 bug 2: decides plain-text vs. stream-json stdin. Empty
|
|
790
860
|
// `extraArgs`/single-chunk `stdinChunks` (the overwhelming majority of
|
|
791
861
|
// sessions — no inline images) reproduces the exact prior behavior.
|
|
792
|
-
const spawnStdinPlan = buildSpawnStdinPlan(prompt, msg.inlineImages);
|
|
862
|
+
const spawnStdinPlan = buildSpawnStdinPlan(prompt, runtime?.resumePending ? undefined : msg.inlineImages);
|
|
793
863
|
// MCP server config passed via --mcp-config flag (no temp files needed)
|
|
794
864
|
const mcpServerPath = join(getBaseBrainPath(), 'mcp', 'slack.mjs');
|
|
795
865
|
const teamvibeApiServerPath = join(getBaseBrainPath(), 'mcp', 'teamvibe-api.mjs');
|
|
@@ -819,10 +889,14 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
819
889
|
// > CLAUDE_MODEL env default. See resolveModel's docstring for why
|
|
820
890
|
// settings.json stays on top (known, accepted blind spot).
|
|
821
891
|
const brainSettingsModel = typeof brainSettings['model'] === 'string' ? brainSettings['model'] : undefined;
|
|
822
|
-
const { model, modelSource } = resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
|
|
892
|
+
const { model, modelSource } = runtime?.resolved ?? resolveModel(brainSettingsModel, ddbModelConfig, config.CLAUDE_MODEL);
|
|
823
893
|
if (model) {
|
|
824
894
|
args.push('--model', model);
|
|
825
|
-
|
|
895
|
+
// The service normalized the scoped config; only describe that mapping
|
|
896
|
+
// when this tier actually supplied the flag (brain settings stay verbatim).
|
|
897
|
+
const normalizedFrom = runtime?.modelNormalizedFrom ?? (model === ddbModelConfig.model && modelSource === ddbModelConfig.modelSource
|
|
898
|
+
? ddbModelConfig.modelNormalizedFrom : undefined);
|
|
899
|
+
sessionLog.info(`Using model: ${model} (source: ${modelSource ?? 'unknown'})${normalizedFrom ? ` (normalized from ${normalizedFrom})` : ''}`);
|
|
826
900
|
}
|
|
827
901
|
// Inbox protocol: instruct Claude to check for mid-task messages (thread-scoped)
|
|
828
902
|
const inboxNewDir = `.inbox/${msg.thread_id}/new`;
|
|
@@ -870,7 +944,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
870
944
|
spawnSecretsEnv: spawnSecrets.env.unwrap(),
|
|
871
945
|
processEnv: process.env,
|
|
872
946
|
envPath,
|
|
873
|
-
claudeConfigDir: getBaseBrainPath(),
|
|
947
|
+
claudeConfigDir: runtime?.claudeConfigDir ?? getBaseBrainPath(),
|
|
874
948
|
persistentStoragePath: config.PERSISTENT_STORAGE_PATH,
|
|
875
949
|
slackContext,
|
|
876
950
|
teamvibeApiUrl: config.TEAMVIBE_API_URL,
|
|
@@ -881,6 +955,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
881
955
|
teamvibePollerId: getPollerId() || '',
|
|
882
956
|
inboxThreadId: msg.thread_id,
|
|
883
957
|
brainPath,
|
|
958
|
+
subscriptionAccount: runtime?.account,
|
|
884
959
|
}),
|
|
885
960
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
886
961
|
// teamvibeai/teamvibe.ai#73: run `claude` (via setsid) as the leader of
|
|
@@ -903,7 +978,11 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
903
978
|
let stdout = '';
|
|
904
979
|
let stderr = '';
|
|
905
980
|
let stdoutBuffer = '';
|
|
981
|
+
// Retain CLI error/result evidence, not the full parsed assistant/tool stream.
|
|
982
|
+
const failureEvents = [];
|
|
983
|
+
let rateLimitInfo;
|
|
906
984
|
let metrics;
|
|
985
|
+
let lastAssistant;
|
|
907
986
|
// teamvibe.ai#284 (poller-brain#235): end-of-turn guard state — tracked
|
|
908
987
|
// across the whole run so the `close` handler can tell poller.ts whether
|
|
909
988
|
// a successful turn actually delivered anything to Slack.
|
|
@@ -958,6 +1037,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
958
1037
|
for (const line of lines) {
|
|
959
1038
|
try {
|
|
960
1039
|
const event = JSON.parse(line);
|
|
1040
|
+
if (event?.type === 'rate_limit_event') {
|
|
1041
|
+
rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
|
|
1042
|
+
}
|
|
1043
|
+
if (event?.type === 'result' || event?.type === 'error' || (event?.type === 'assistant' && event.error)) {
|
|
1044
|
+
failureEvents.push(event);
|
|
1045
|
+
}
|
|
961
1046
|
// Capture the pre-event activity timestamp BEFORE the unconditional
|
|
962
1047
|
// overwrite below — needed to compute the "original 10min deadline
|
|
963
1048
|
// from silence start" for a compact_boundary line, since that line
|
|
@@ -993,20 +1078,12 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
993
1078
|
sessionLog.info('[Watchdog] Activity resumed after compaction, reverting to normal idle watchdog');
|
|
994
1079
|
compactionKillDeadline = null;
|
|
995
1080
|
}
|
|
1081
|
+
if (event.type === 'assistant')
|
|
1082
|
+
lastAssistant = { usage: event.message?.usage, model: event.message?.model };
|
|
996
1083
|
// Extract metrics from result event
|
|
997
1084
|
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)}, ` +
|
|
1085
|
+
metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
|
|
1086
|
+
sessionLog.info(`[Metrics] Cost: $${metrics.total_cost_usd?.toFixed(4) ?? 'unknown'}, ` +
|
|
1010
1087
|
`Duration: ${metrics.duration_ms}ms, ` +
|
|
1011
1088
|
`Turns: ${metrics.num_turns}, ` +
|
|
1012
1089
|
`Tokens: ${metrics.input_tokens}in/${metrics.output_tokens}out`);
|
|
@@ -1137,7 +1214,7 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1137
1214
|
});
|
|
1138
1215
|
// Surface a visible status so the user isn't left with a stale/generic
|
|
1139
1216
|
// indicator during long silent stretches (e.g. context compaction).
|
|
1140
|
-
typingIndicator?.updateStatus('Still working on a long response...');
|
|
1217
|
+
typingIndicator?.updateStatus('Still working on a long response...', 'default');
|
|
1141
1218
|
idleWarned = true;
|
|
1142
1219
|
}
|
|
1143
1220
|
}, 30000); // Check every 30 seconds
|
|
@@ -1169,8 +1246,29 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1169
1246
|
// not something shutdown() awaits.
|
|
1170
1247
|
void sweepProcessGroup(proc, CLOSE_SWEEP_GRACE_MS, { unref: true });
|
|
1171
1248
|
const killReason = killReasonByProc.get(proc);
|
|
1249
|
+
// Preserve a final unterminated stream-json error/result and its metrics.
|
|
1250
|
+
if (stdoutBuffer.trim()) {
|
|
1251
|
+
try {
|
|
1252
|
+
const event = JSON.parse(stdoutBuffer);
|
|
1253
|
+
if (event?.type === 'rate_limit_event') {
|
|
1254
|
+
rateLimitInfo = parseClaudeRateLimitInfo(event.rate_limit_info);
|
|
1255
|
+
}
|
|
1256
|
+
failureEvents.push(event);
|
|
1257
|
+
if (event?.type === 'assistant')
|
|
1258
|
+
lastAssistant = { usage: event.message?.usage, model: event.message?.model };
|
|
1259
|
+
if (event?.type === 'result')
|
|
1260
|
+
metrics = resultMetrics(event, !!runtime?.account, lastAssistant);
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
// Non-JSON stdout is not trusted CLI error evidence.
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
const quota = parseClaudeQuotaFailure({ exitCode: code, stderr, events: failureEvents, rateLimitInfo }, new Date());
|
|
1172
1267
|
const result = {
|
|
1173
|
-
success: code === 0,
|
|
1268
|
+
success: code === 0 && !quota,
|
|
1269
|
+
attemptedSessionId: sessionId,
|
|
1270
|
+
...(quota ? { quota } : {}),
|
|
1271
|
+
...(rateLimitInfo ? { rateLimitInfo } : {}),
|
|
1174
1272
|
output: stdout,
|
|
1175
1273
|
exitCode: code,
|
|
1176
1274
|
metrics,
|
|
@@ -1191,6 +1289,9 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1191
1289
|
else if (killReason === 'zombie') {
|
|
1192
1290
|
result.error = 'Killed as zombie process (stale session lock)';
|
|
1193
1291
|
}
|
|
1292
|
+
else if (quota) {
|
|
1293
|
+
result.error = 'Claude subscription usage limit reached.';
|
|
1294
|
+
}
|
|
1194
1295
|
else if (code !== 0) {
|
|
1195
1296
|
// `signal` matters here specifically: a process terminated by a
|
|
1196
1297
|
// signal reports `code: null`, not a numeric exit code — e.g. a
|
|
@@ -1220,6 +1321,8 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1220
1321
|
output: stdout,
|
|
1221
1322
|
exitCode: null,
|
|
1222
1323
|
error: error.message,
|
|
1324
|
+
...(rateLimitInfo ? { rateLimitInfo } : {}),
|
|
1325
|
+
attemptedSessionId: sessionId,
|
|
1223
1326
|
sawSendMessageCall,
|
|
1224
1327
|
lastPlainTextContent,
|
|
1225
1328
|
});
|
|
@@ -1246,9 +1349,11 @@ function shouldIsolateInWorktree(msg) {
|
|
|
1246
1349
|
* createSession() placeholder forever and the thread's next message does
|
|
1247
1350
|
* `--resume <id-claude-code-never-used>`, which fails 100% of the time.
|
|
1248
1351
|
* (teamvibe.ai#77, DevGuru review round)
|
|
1352
|
+
* Quota failures also report the attempted ID, even on the first turn, so
|
|
1353
|
+
* later pool orchestration can continue that same session (#401).
|
|
1249
1354
|
*/
|
|
1250
1355
|
export function finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessionId) {
|
|
1251
|
-
if (isFirstMessage && retryResult.success && effectiveSessionId) {
|
|
1356
|
+
if (isFirstMessage && (retryResult.success || retryResult.quota) && effectiveSessionId) {
|
|
1252
1357
|
return { ...retryResult, newSessionId: effectiveSessionId };
|
|
1253
1358
|
}
|
|
1254
1359
|
return retryResult;
|
|
@@ -1282,14 +1387,14 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
1282
1387
|
const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
|
|
1283
1388
|
// If --resume failed, retry as a fresh session (session files may have been lost on container restart)
|
|
1284
1389
|
let retryResult = result;
|
|
1285
|
-
if (!result.success && effectiveSessionId && !isFirstMessage) {
|
|
1390
|
+
if (!result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
|
|
1286
1391
|
sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
|
|
1287
1392
|
retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
|
|
1288
1393
|
}
|
|
1289
1394
|
// If session ID is "already in use" (stale lock file), retry immediately with a
|
|
1290
1395
|
// fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
|
|
1291
1396
|
// retry's (teamvibe.ai#77 acceptance criteria: "no zombie kill needed first").
|
|
1292
|
-
if (!retryResult.success && effectiveSessionId && retryResult.error?.includes('already in use')) {
|
|
1397
|
+
if (!retryResult.success && !retryResult.quota && effectiveSessionId && retryResult.error?.includes('already in use')) {
|
|
1293
1398
|
sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
|
|
1294
1399
|
const { randomUUID } = await import('crypto');
|
|
1295
1400
|
const freshId = randomUUID();
|
|
@@ -1306,6 +1411,182 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
|
|
|
1306
1411
|
}
|
|
1307
1412
|
}
|
|
1308
1413
|
}
|
|
1414
|
+
// Configured-pool orchestration is deliberately separate from legacy spawns.
|
|
1415
|
+
export async function spawnPoolClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, poolRuntime) {
|
|
1416
|
+
// Worktree isolation: spawned (non-foreground) sessions run in
|
|
1417
|
+
// <brain>/.worktrees/<thread_id>/ so the sync-watchdog's reset --hard on
|
|
1418
|
+
// the main working dir can't trample in-flight writes or pre-push commits.
|
|
1419
|
+
// See teamvibe.ai#120. cwd remains the main brain path on fallback / failure.
|
|
1420
|
+
let effectiveCwd = cwd;
|
|
1421
|
+
let retainWorktree = poolRuntime?.pin?.resumePending === true;
|
|
1422
|
+
const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
|
|
1423
|
+
let worktreeOwner;
|
|
1424
|
+
if (poolRuntime?.pin?.resumePending && poolRuntime.pin.effectiveCwd) {
|
|
1425
|
+
const pinnedCwd = poolRuntime.pin.effectiveCwd;
|
|
1426
|
+
if (pinnedCwd !== cwd && pinnedCwd !== join(cwd, '.worktrees', msg.thread_id))
|
|
1427
|
+
throw new PoolDeferredError();
|
|
1428
|
+
if (!existsSync(pinnedCwd)) {
|
|
1429
|
+
sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${pinnedCwd} for pending session ${sessionId ?? 'unknown'}`);
|
|
1430
|
+
try {
|
|
1431
|
+
if (pinnedCwd === cwd)
|
|
1432
|
+
await mkdir(cwd, { recursive: true });
|
|
1433
|
+
else
|
|
1434
|
+
await createWorktree(cwd, msg.thread_id);
|
|
1435
|
+
}
|
|
1436
|
+
catch {
|
|
1437
|
+
sessionLog.warn('[Worktree] Pinned worktree recreation failed; pending work retained for the next recheck');
|
|
1438
|
+
throw new PoolDeferredError();
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
effectiveCwd = pinnedCwd;
|
|
1442
|
+
if (effectiveCwd !== cwd)
|
|
1443
|
+
worktreeOwner = { brainPath: cwd, id: msg.thread_id };
|
|
1444
|
+
}
|
|
1445
|
+
else if (shouldIsolateInWorktree(msg)) {
|
|
1446
|
+
try {
|
|
1447
|
+
effectiveCwd = await createWorktree(cwd, msg.thread_id);
|
|
1448
|
+
worktreeOwner = { brainPath: cwd, id: msg.thread_id };
|
|
1449
|
+
sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
|
|
1450
|
+
}
|
|
1451
|
+
catch (err) {
|
|
1452
|
+
sessionLog.warn(`[Worktree] Failed to create worktree, falling back to main brain dir: ${err instanceof Error ? err.message : err}`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
try {
|
|
1456
|
+
// First-message spawns always mint a fresh session ID rather than
|
|
1457
|
+
// recycling whatever placeholder DynamoDB currently holds for this
|
|
1458
|
+
// thread (teamvibe.ai#77) — --resume on non-first messages is unchanged.
|
|
1459
|
+
let effectiveSessionId = sessionId;
|
|
1460
|
+
if (isFirstMessage && !poolRuntime?.pin?.resumePending) {
|
|
1461
|
+
const { randomUUID } = await import('crypto');
|
|
1462
|
+
effectiveSessionId = randomUUID();
|
|
1463
|
+
}
|
|
1464
|
+
const fetchedPool = poolRuntime ? poolRuntime.pool ?? await fetchRuntimePool(msg) : undefined;
|
|
1465
|
+
const scoped = poolRuntime ? await fetchConfigForSpawn({ pollerId: getPollerId() || '', workspaceId: msg.teamvibe.workspaceId, channelId: msg.teamvibe.channelId }) : undefined;
|
|
1466
|
+
const settings = readBrainSettings(effectiveCwd);
|
|
1467
|
+
const resolved = fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime?.pin?.workspaceId === msg.teamvibe.workspaceId
|
|
1468
|
+
? { model: poolRuntime.pin.model, modelSource: poolRuntime.pin.modelSource }
|
|
1469
|
+
: resolveModel(typeof settings['model'] === 'string' ? settings['model'] : undefined, scoped ?? {}, config.CLAUDE_MODEL);
|
|
1470
|
+
const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool) : undefined;
|
|
1471
|
+
const claudeConfigDir = poolRuntime?.pin?.resumePending ? poolRuntime.pin.claudeConfigDir ?? getBaseBrainPath() : getBaseBrainPath();
|
|
1472
|
+
const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
|
|
1473
|
+
? scoped?.modelNormalizedFrom : undefined;
|
|
1474
|
+
const runtime = poolRuntime ? { resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, shouldStop: poolRuntime.shouldStop, resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true } : undefined;
|
|
1475
|
+
let sawSendMessageCall = poolRuntime?.pin?.resumePending === true && poolRuntime.pin.sawSendMessageCall === true;
|
|
1476
|
+
const attempt = async (id, first) => {
|
|
1477
|
+
if (prepared?.account && id && poolRuntime) {
|
|
1478
|
+
try {
|
|
1479
|
+
await poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts);
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
throw new PoolDeferredError();
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
// Only a quota continuation shares delivery history. A fresh-session
|
|
1486
|
+
// fallback must report the fresh attempt's delivery flags independently.
|
|
1487
|
+
if (!runtime?.resumePending)
|
|
1488
|
+
sawSendMessageCall = false;
|
|
1489
|
+
const result = await runClaudeCode(msg, sessionLog, effectiveCwd, cwd, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
|
|
1490
|
+
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
1491
|
+
if (prepared?.account) {
|
|
1492
|
+
if (poolAttempts.length >= 1000)
|
|
1493
|
+
poolAttempts.shift();
|
|
1494
|
+
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 });
|
|
1495
|
+
emitEvent('pool.account_attempted', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime?.quotaDeferEpisodes) });
|
|
1496
|
+
if (result.success && id && poolRuntime) {
|
|
1497
|
+
// Complete the inbox before any fallible post-inference store writes.
|
|
1498
|
+
// The loop retains bookkeeping independently of the original prompt.
|
|
1499
|
+
await poolRuntime.complete(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: false }, poolAttempts, result.rateLimitInfo);
|
|
1500
|
+
await emitCandidateUsage(msg, [prepared.account], prepared.store);
|
|
1501
|
+
retainWorktree = false;
|
|
1502
|
+
return result;
|
|
1503
|
+
}
|
|
1504
|
+
if (result.quota && !result.killedByPoller) {
|
|
1505
|
+
runtime.resumePending = true;
|
|
1506
|
+
retainWorktree = true;
|
|
1507
|
+
}
|
|
1508
|
+
// A failure in one store must not prevent the other stores from
|
|
1509
|
+
// retaining the attempt. Selection waits for all writes before switching.
|
|
1510
|
+
const writes = [];
|
|
1511
|
+
if (result.quota && !result.killedByPoller)
|
|
1512
|
+
writes.push(prepared.store.recordQuota(prepared.account, result.quota, Date.now(), { expireFutureObservations: true }));
|
|
1513
|
+
if (result.rateLimitInfo)
|
|
1514
|
+
writes.push(prepared.store.recordPassive(prepared.account, result.rateLimitInfo, Date.now(), { expireFutureObservations: true }));
|
|
1515
|
+
if (id && poolRuntime)
|
|
1516
|
+
writes.push(poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts));
|
|
1517
|
+
const outcomes = await Promise.allSettled(writes);
|
|
1518
|
+
if (outcomes.some(outcome => outcome.status === 'rejected'))
|
|
1519
|
+
throw new PoolDeferredError();
|
|
1520
|
+
}
|
|
1521
|
+
return result;
|
|
1522
|
+
};
|
|
1523
|
+
const attemptedAccounts = new Set();
|
|
1524
|
+
let switched = false;
|
|
1525
|
+
const finishQuota = async (initial) => {
|
|
1526
|
+
let result = initial;
|
|
1527
|
+
while (prepared?.account && result.quota && !result.killedByPoller && poolRuntime && runtime) {
|
|
1528
|
+
attemptedAccounts.add(prepared.account.accountId);
|
|
1529
|
+
if (poolRuntime.shouldStop() || attemptedAccounts.size >= 1000)
|
|
1530
|
+
throw new PoolDeferredError();
|
|
1531
|
+
prepared.pool = await fetchRuntimePool(msg);
|
|
1532
|
+
if (prepared.pool.kind === 'ready')
|
|
1533
|
+
await emitCandidateUsage(msg, prepared.pool.accounts, prepared.store);
|
|
1534
|
+
// Reactive evidence was persisted by attempt() before choosing another account.
|
|
1535
|
+
const remaining = prepared.pool.kind === 'ready' ? prepared.pool.accounts.filter(a => !attemptedAccounts.has(a.accountId)) : [];
|
|
1536
|
+
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: remaining.length ? { kind: 'ready', accounts: remaining } : prepared.pool, store: prepared.store, now: Date.now() });
|
|
1537
|
+
if (selected.kind !== 'selected' || attemptedAccounts.has(selected.account.accountId))
|
|
1538
|
+
throw await poolDeferredError(prepared.pool, resolved, prepared.store);
|
|
1539
|
+
effectiveSessionId = result.attemptedSessionId ?? effectiveSessionId;
|
|
1540
|
+
if (!effectiveSessionId)
|
|
1541
|
+
throw new PoolDeferredError();
|
|
1542
|
+
const fromAccount = prepared.account;
|
|
1543
|
+
prepared.account = selected.account;
|
|
1544
|
+
runtime.account = selected.account;
|
|
1545
|
+
runtime.resumePending = true;
|
|
1546
|
+
switched = true;
|
|
1547
|
+
const selectedUsage = await prepared.store.read(selected.account).catch(() => ({ reactive: [], passive: undefined }));
|
|
1548
|
+
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 });
|
|
1549
|
+
result = await attempt(effectiveSessionId, false);
|
|
1550
|
+
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
1551
|
+
}
|
|
1552
|
+
return { ...result, sawSendMessageCall, ...(prepared?.account ? { poolAttempts } : {}) };
|
|
1553
|
+
};
|
|
1554
|
+
const result = await finishQuota(await attempt(effectiveSessionId, isFirstMessage));
|
|
1555
|
+
let replacedSessionId = false;
|
|
1556
|
+
// If --resume failed, retry as a fresh session (session files may have been lost on container restart)
|
|
1557
|
+
let retryResult = result;
|
|
1558
|
+
if (!runtime?.resumePending && !switched && !result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
|
|
1559
|
+
sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
|
|
1560
|
+
retryResult = await finishQuota(await attempt(effectiveSessionId, true));
|
|
1561
|
+
}
|
|
1562
|
+
// If session ID is "already in use" (stale lock file), retry immediately with a
|
|
1563
|
+
// fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
|
|
1564
|
+
// retry's (teamvibe.ai#77 acceptance criteria: "no zombie kill needed first").
|
|
1565
|
+
if (!runtime?.resumePending && !switched && !retryResult.success && !retryResult.quota && effectiveSessionId && retryResult.error?.includes('already in use')) {
|
|
1566
|
+
sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
|
|
1567
|
+
const { randomUUID } = await import('crypto');
|
|
1568
|
+
const freshId = randomUUID();
|
|
1569
|
+
effectiveSessionId = freshId;
|
|
1570
|
+
replacedSessionId = true;
|
|
1571
|
+
retryResult = await finishQuota(await attempt(freshId, true));
|
|
1572
|
+
}
|
|
1573
|
+
return finalizeSpawnResult(retryResult, isFirstMessage || replacedSessionId, effectiveSessionId);
|
|
1574
|
+
}
|
|
1575
|
+
catch (error) {
|
|
1576
|
+
if (error instanceof PoolDeferredError) {
|
|
1577
|
+
retainWorktree = true;
|
|
1578
|
+
error.attempts = poolAttempts;
|
|
1579
|
+
}
|
|
1580
|
+
throw error;
|
|
1581
|
+
}
|
|
1582
|
+
finally {
|
|
1583
|
+
if (worktreeOwner && !retainWorktree) {
|
|
1584
|
+
removeWorktree(worktreeOwner.brainPath, worktreeOwner.id).catch((err) => {
|
|
1585
|
+
sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1309
1590
|
const activeProcesses = new Map();
|
|
1310
1591
|
const activeProcessesByThread = new Map();
|
|
1311
1592
|
// 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/config.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
18
18
|
IDLE_WATCHDOG_COMPACTION_GRACE_MS: z.ZodDefault<z.ZodNumber>;
|
|
19
19
|
NETWORK_ERROR_KILL_MS: z.ZodDefault<z.ZodNumber>;
|
|
20
20
|
MAX_CONSECUTIVE_NET_FAILURES: z.ZodDefault<z.ZodNumber>;
|
|
21
|
+
TYPING_COMMENT_MIN_HOLD_MS: z.ZodDefault<z.ZodNumber>;
|
|
21
22
|
TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
|
|
22
23
|
BRAINS_PATH: z.ZodDefault<z.ZodString>;
|
|
23
24
|
DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
|
|
@@ -60,6 +61,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
60
61
|
IDLE_WATCHDOG_COMPACTION_GRACE_MS: number;
|
|
61
62
|
NETWORK_ERROR_KILL_MS: number;
|
|
62
63
|
MAX_CONSECUTIVE_NET_FAILURES: number;
|
|
64
|
+
TYPING_COMMENT_MIN_HOLD_MS: number;
|
|
63
65
|
TEAMVIBE_DATA_DIR: string;
|
|
64
66
|
BRAINS_PATH: string;
|
|
65
67
|
DEFAULT_BRAIN_PATH: string;
|
|
@@ -108,6 +110,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
108
110
|
IDLE_WATCHDOG_COMPACTION_GRACE_MS?: number | undefined;
|
|
109
111
|
NETWORK_ERROR_KILL_MS?: number | undefined;
|
|
110
112
|
MAX_CONSECUTIVE_NET_FAILURES?: number | undefined;
|
|
113
|
+
TYPING_COMMENT_MIN_HOLD_MS?: number | undefined;
|
|
111
114
|
TEAMVIBE_DATA_DIR?: string | undefined;
|
|
112
115
|
BRAINS_PATH?: string | undefined;
|
|
113
116
|
DEFAULT_BRAIN_PATH?: string | undefined;
|
|
@@ -153,6 +156,7 @@ export declare const config: {
|
|
|
153
156
|
IDLE_WATCHDOG_COMPACTION_GRACE_MS: number;
|
|
154
157
|
NETWORK_ERROR_KILL_MS: number;
|
|
155
158
|
MAX_CONSECUTIVE_NET_FAILURES: number;
|
|
159
|
+
TYPING_COMMENT_MIN_HOLD_MS: number;
|
|
156
160
|
TEAMVIBE_DATA_DIR: string;
|
|
157
161
|
BRAINS_PATH: string;
|
|
158
162
|
DEFAULT_BRAIN_PATH: string;
|
package/dist/config.js
CHANGED
|
@@ -27,6 +27,7 @@ const configSchema = z.object({
|
|
|
27
27
|
IDLE_WATCHDOG_COMPACTION_GRACE_MS: z.coerce.number().default(300000), // 5 minutes — fixed grace from compact_boundary arrival, not a large speculative ceiling (compact_boundary only proves compaction STARTED, never that it finishes in bounded time)
|
|
28
28
|
NETWORK_ERROR_KILL_MS: z.coerce.number().default(120000), // Per-session: kill stuck session after 2 min of network errors
|
|
29
29
|
MAX_CONSECUTIVE_NET_FAILURES: z.coerce.number().default(5), // Poll loop: DNS flush at N, exit(1) at 2×N consecutive failures
|
|
30
|
+
TYPING_COMMENT_MIN_HOLD_MS: z.coerce.number().default(4500), // Minimum time to display agent narration before a canned status
|
|
30
31
|
// Paths
|
|
31
32
|
TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
|
|
32
33
|
BRAINS_PATH: z.string().default(''),
|
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;
|