@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.
Files changed (43) hide show
  1. package/dist/classify-failed-iteration.d.ts +7 -0
  2. package/dist/classify-failed-iteration.js +8 -0
  3. package/dist/claude-quota.d.ts +15 -0
  4. package/dist/claude-quota.js +188 -0
  5. package/dist/claude-rate-limit.d.ts +19 -0
  6. package/dist/claude-rate-limit.js +44 -0
  7. package/dist/claude-spawner.d.ts +15 -0
  8. package/dist/claude-spawner.js +307 -26
  9. package/dist/config-client.d.ts +2 -0
  10. package/dist/config-client.js +3 -0
  11. package/dist/config.d.ts +4 -0
  12. package/dist/config.js +1 -0
  13. package/dist/inbox-manager.d.ts +22 -1
  14. package/dist/inbox-manager.js +57 -3
  15. package/dist/inbox-watcher.d.ts +1 -1
  16. package/dist/inbox-watcher.js +42 -20
  17. package/dist/poller.js +262 -51
  18. package/dist/pool-defer.d.ts +13 -0
  19. package/dist/pool-defer.js +81 -0
  20. package/dist/pool-telemetry.d.ts +44 -0
  21. package/dist/pool-telemetry.js +107 -0
  22. package/dist/quota-pause.d.ts +16 -0
  23. package/dist/quota-pause.js +96 -0
  24. package/dist/session-store.d.ts +2 -2
  25. package/dist/session-store.js +6 -3
  26. package/dist/slack-client.d.ts +4 -1
  27. package/dist/slack-client.js +100 -11
  28. package/dist/subscription-pool-client.d.ts +32 -0
  29. package/dist/subscription-pool-client.js +139 -0
  30. package/dist/subscription-pool.d.ts +18 -0
  31. package/dist/subscription-pool.js +24 -0
  32. package/dist/subscription-runtime.d.ts +53 -0
  33. package/dist/subscription-runtime.js +74 -0
  34. package/dist/subscription-selection.d.ts +75 -0
  35. package/dist/subscription-selection.js +142 -0
  36. package/dist/subscription-usage-probe.d.ts +16 -0
  37. package/dist/subscription-usage-probe.js +106 -0
  38. package/dist/subscription-usage-store.d.ts +47 -0
  39. package/dist/subscription-usage-store.js +206 -0
  40. package/dist/types.d.ts +25 -9
  41. package/dist/worktree-manager.js +4 -0
  42. package/npm-shrinkwrap.json +78 -77
  43. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync } from 'fs';
1
+ import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync, rmSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { logger } from './logger.js';
4
4
  const INBOX_DIR = '.inbox';
@@ -96,7 +96,7 @@ export function markProcessed(brainPath, threadId, filename) {
96
96
  /**
97
97
  * Move a message from processing/ back to new/.
98
98
  *
99
- * Used specifically for a poller-initiated *shutdown* kill (teamvibe.ai#73,
99
+ * Used for quota-paused spawn preparation and a poller-initiated *shutdown* kill (teamvibe.ai#73,
100
100
  * PR#308 round-5): pickNextMessage() above moves a file to processing/ as
101
101
  * soon as it's picked up, not once it completes, so a message killed
102
102
  * mid-flight by shutdown() is left sitting in processing/. The inbox
@@ -126,7 +126,7 @@ export function requeueMessage(brainPath, threadId, filename) {
126
126
  try {
127
127
  if (existsSync(srcPath)) {
128
128
  renameSync(srcPath, dstPath);
129
- logger.info(`[Inbox] Requeued shutdown-killed message to new/: ${filename}`);
129
+ logger.info(`[Inbox] Requeued pending message to new/: ${filename}`);
130
130
  }
131
131
  else {
132
132
  logger.info(`[Inbox] Requeue skipped, ${filename} already gone from processing/`);
@@ -156,3 +156,57 @@ export function hasUnprocessed(brainPath, threadId) {
156
156
  return false;
157
157
  }
158
158
  }
159
+ // Context and continuation state live beside inbox files, independently of the pause marker.
160
+ function writeInboxState(brainPath, threadId, name, value) {
161
+ ensureInboxDirs(brainPath, threadId);
162
+ const path = join(getInboxPath(brainPath, threadId), name);
163
+ writeFileSync(`${path}.tmp`, JSON.stringify(value), { mode: 0o600 });
164
+ renameSync(`${path}.tmp`, path);
165
+ }
166
+ export function saveInboxContext(brainPath, msg) {
167
+ const { botToken: _token, ...teamvibe } = msg.teamvibe;
168
+ writeInboxState(brainPath, msg.thread_id, 'context.json', { ...msg, teamvibe });
169
+ }
170
+ export function readInboxContext(brainPath, threadId) {
171
+ const root = getInboxPath(brainPath, threadId);
172
+ const candidates = [];
173
+ for (const subdir of [PROCESSING_DIR, NEW_DIR]) {
174
+ try {
175
+ const first = readdirSync(join(root, subdir)).filter(name => name.endsWith('.txt')).sort()[0];
176
+ if (first)
177
+ candidates.push(join(root, subdir, first));
178
+ }
179
+ catch { }
180
+ }
181
+ candidates.push(join(root, 'context.json'));
182
+ for (const path of candidates) {
183
+ try {
184
+ const msg = JSON.parse(readFileSync(path, 'utf8'));
185
+ if (msg.thread_id !== threadId || !['slack', 'email', 'cron', 'maintenance', 'webhook'].includes(msg.source) || typeof msg.teamvibe?.workspaceId !== 'string' || typeof msg.teamvibe?.channelId !== 'string' || !msg.response_context || typeof msg.text !== 'string' || !msg.sender || !Array.isArray(msg.attachments))
186
+ continue;
187
+ return { ...msg, teamvibe: { ...msg.teamvibe, botToken: '' } };
188
+ }
189
+ catch { }
190
+ }
191
+ return undefined;
192
+ }
193
+ export function saveInboxRuntime(brainPath, threadId, state) {
194
+ writeInboxState(brainPath, threadId, 'pool-session.json', state);
195
+ }
196
+ export function readInboxRuntime(brainPath, threadId) {
197
+ try {
198
+ const state = JSON.parse(readFileSync(join(getInboxPath(brainPath, threadId), 'pool-session.json'), 'utf8'));
199
+ if (typeof state.sessionId !== 'string' || typeof state.filename !== 'string' || !state.pin?.workspaceId || !state.pin?.accountId || !state.pin?.credentialVersion)
200
+ return undefined;
201
+ return state;
202
+ }
203
+ catch {
204
+ return undefined;
205
+ }
206
+ }
207
+ export function clearInboxRuntime(brainPath, threadId) {
208
+ try {
209
+ rmSync(join(getInboxPath(brainPath, threadId), 'pool-session.json'), { force: true });
210
+ }
211
+ catch { }
212
+ }
@@ -36,7 +36,7 @@ export declare function parseSlackThreadId(threadId: string): {
36
36
  export declare function listThreadDirs(inboxRoot: string): string[];
37
37
  export declare function hasNewMessage(inboxRoot: string, threadId: string): boolean;
38
38
  /**
39
- * Scan every tracked brain's .inbox/ for a thread that has a new message but
39
+ * Scan every tracked brain's .inbox/ for a thread that has queued or retained work but
40
40
  * no active runInboxLoop, and start one. This is the only config-only path
41
41
  * that can wake a fully idle session (teamvibe.ai#242) — a file dropped into
42
42
  * .inbox/ by e.g. a background task otherwise sits inert until the next SQS
@@ -1,3 +1,5 @@
1
+ import { readInboxContext, readInboxRuntime } from './inbox-manager.js';
2
+ import { isPoolDeferred, readPoolDefer } from './pool-defer.js';
1
3
  import { existsSync, readdirSync, rmSync, watch as fsWatch } from 'fs';
2
4
  import { join } from 'path';
3
5
  import { config } from './config.js';
@@ -5,6 +7,7 @@ import { logger } from './logger.js';
5
7
  import { isAtCapacity } from './claude-spawner.js';
6
8
  import { getTrackedBrains } from './maintenance-cron.js';
7
9
  import { runInboxLoop, isInboxLoopActive } from './poller.js';
10
+ import { isQuotaPaused } from './quota-pause.js';
8
11
  const INBOX_SUBDIRS = ['new', 'processing', 'failed'];
9
12
  let brainWatchers = new Map();
10
13
  let fallbackInterval = null;
@@ -81,8 +84,8 @@ export function listThreadDirs(inboxRoot) {
81
84
  return [];
82
85
  }
83
86
  }
84
- export function hasNewMessage(inboxRoot, threadId) {
85
- const newDir = join(inboxRoot, threadId, 'new');
87
+ function hasMessage(inboxRoot, threadId, subdir) {
88
+ const newDir = join(inboxRoot, threadId, subdir);
86
89
  try {
87
90
  return existsSync(newDir) && readdirSync(newDir).some((f) => f.endsWith('.txt'));
88
91
  }
@@ -90,38 +93,55 @@ export function hasNewMessage(inboxRoot, threadId) {
90
93
  return false;
91
94
  }
92
95
  }
96
+ export function hasNewMessage(inboxRoot, threadId) {
97
+ return hasMessage(inboxRoot, threadId, 'new');
98
+ }
93
99
  /**
94
- * Scan every tracked brain's .inbox/ for a thread that has a new message but
100
+ * Scan every tracked brain's .inbox/ for a thread that has queued or retained work but
95
101
  * no active runInboxLoop, and start one. This is the only config-only path
96
102
  * that can wake a fully idle session (teamvibe.ai#242) — a file dropped into
97
103
  * .inbox/ by e.g. a background task otherwise sits inert until the next SQS
98
104
  * message for that thread, which may never come.
99
105
  */
100
106
  export function checkInboxForNewSessions(deps = defaultDeps()) {
101
- for (const [, info] of deps.getTrackedBrains()) {
102
- if (deps.isAtCapacity()) {
103
- logger.debug('[InboxWatcher] At capacity, pausing scan until next tick');
104
- return;
107
+ const brains = new Map();
108
+ for (const [, info] of deps.getTrackedBrains())
109
+ brains.set(join(deps.brainsPath, info.brainId), info);
110
+ // Durable inbox context discovers retained work even with empty bootstrap maps,
111
+ // absent/corrupt pause markers, or a non-Slack thread ID.
112
+ try {
113
+ for (const entry of readdirSync(deps.brainsPath, { withFileTypes: true })) {
114
+ if (entry.isDirectory()) {
115
+ const path = join(deps.brainsPath, entry.name);
116
+ if (existsSync(join(path, '.inbox')) && !brains.has(path))
117
+ brains.set(path, undefined);
118
+ }
105
119
  }
106
- const brainPath = join(deps.brainsPath, info.brainId);
120
+ }
121
+ catch { }
122
+ for (const [brainPath, info] of brains) {
123
+ if (deps.isAtCapacity())
124
+ return;
107
125
  const inboxRoot = join(brainPath, '.inbox');
108
- if (!existsSync(inboxRoot))
109
- continue;
110
126
  for (const threadId of listThreadDirs(inboxRoot)) {
111
- if (deps.isInboxLoopActive(threadId))
127
+ if (deps.isAtCapacity())
128
+ return;
129
+ if (deps.isInboxLoopActive(threadId) || isPoolDeferred(threadId))
112
130
  continue;
113
- if (!hasNewMessage(inboxRoot, threadId))
131
+ const completion = readInboxRuntime(brainPath, threadId)?.completed;
132
+ const retryCompletion = completion && (Date.now() < completion.observedAt || Date.now() >= completion.retryAt);
133
+ if (!hasNewMessage(inboxRoot, threadId) && !hasMessage(inboxRoot, threadId, 'processing') && !retryCompletion)
114
134
  continue;
135
+ if (isQuotaPaused() && !readInboxRuntime(brainPath, threadId) && !readPoolDefer(threadId))
136
+ continue;
137
+ const durable = readInboxContext(brainPath, threadId);
115
138
  const parsed = parseSlackThreadId(threadId);
116
- if (!parsed) {
117
- logger.debug(`[InboxWatcher] Skipping ${threadId}: not a slack-shaped threadId`);
139
+ const message = durable ?? (info && parsed ? buildSyntheticMessage(info, threadId, parsed) : undefined);
140
+ if (!message)
118
141
  continue;
119
- }
120
- logger.info(`[InboxWatcher] New message for idle thread ${threadId}, starting session`);
121
142
  const sessionLog = logger.createSession(`iw_${Math.random().toString(36).slice(2, 10)}`);
122
- const syntheticMessage = buildSyntheticMessage(info, threadId, parsed);
123
- deps.runInboxLoop(threadId, brainPath, syntheticMessage, sessionLog).catch((error) => {
124
- logger.error(`[InboxWatcher] runInboxLoop failed for ${threadId}: ${error instanceof Error ? error.message : error}`);
143
+ deps.runInboxLoop(threadId, brainPath, message, sessionLog).catch(() => {
144
+ logger.error('[InboxWatcher] Retained inbox run failed; work remains saved');
125
145
  });
126
146
  }
127
147
  }
@@ -157,7 +177,9 @@ export function pruneEmptyInboxDirs(deps = defaultDeps()) {
157
177
  if (!existsSync(inboxRoot))
158
178
  continue;
159
179
  for (const threadId of listThreadDirs(inboxRoot)) {
160
- if (deps.isInboxLoopActive(threadId))
180
+ if (deps.isInboxLoopActive(threadId) || isPoolDeferred(threadId))
181
+ continue;
182
+ if (readInboxRuntime(join(deps.brainsPath, info.brainId), threadId)?.completed)
161
183
  continue;
162
184
  if (!isThreadDirEmpty(inboxRoot, threadId))
163
185
  continue;