@teamvibe/poller 0.6.2 → 0.7.1
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/brain-manager.d.ts +43 -1
- package/dist/brain-manager.js +259 -6
- package/dist/brains-bootstrap.d.ts +45 -0
- package/dist/brains-bootstrap.js +217 -0
- package/dist/classify-failed-iteration.d.ts +1 -1
- package/dist/classify-failed-iteration.js +33 -1
- package/dist/claude-spawner.d.ts +179 -1
- package/dist/claude-spawner.js +697 -92
- package/dist/config.d.ts +40 -0
- package/dist/config.js +32 -2
- package/dist/cron-telemetry.d.ts +3 -0
- package/dist/cron-telemetry.js +16 -0
- package/dist/event-queue.d.ts +30 -0
- package/dist/event-queue.js +4 -0
- package/dist/idle-restart.d.ts +12 -0
- package/dist/idle-restart.js +200 -0
- package/dist/inbox-manager.d.ts +25 -0
- package/dist/inbox-manager.js +47 -0
- package/dist/maintenance-activity.d.ts +18 -0
- package/dist/maintenance-activity.js +28 -0
- package/dist/maintenance-cron.d.ts +73 -0
- package/dist/maintenance-cron.js +85 -1
- package/dist/metrics/collector.d.ts +2 -0
- package/dist/metrics/collector.js +7 -0
- package/dist/poller.d.ts +12 -0
- package/dist/poller.js +345 -8
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Decides how to surface a failed claude-spawner iteration to the user.
|
|
2
2
|
//
|
|
3
3
|
// Background (teamvibe.ai#193): poller-induced kills (timeout, idle watchdog,
|
|
4
|
-
// network watchdog, zombie cleanup) used to leak as generic
|
|
4
|
+
// network watchdog, zombie cleanup, poller shutdown) used to leak as generic
|
|
5
5
|
// "Error processing your request: Process exited with code 143" via
|
|
6
6
|
// sendSlackError. The explicit `killedByPoller` intent marker (set by the
|
|
7
7
|
// spawner before SIGTERM) lets us emit a neutral 1-sentence notice instead —
|
|
@@ -33,6 +33,38 @@ export function classifyFailedIteration(result) {
|
|
|
33
33
|
text: 'Network connectivity lost — send another message to retry.',
|
|
34
34
|
reaction: 'satellite_antenna',
|
|
35
35
|
};
|
|
36
|
+
case 'shutdown':
|
|
37
|
+
// PR#308 round-6 review: "will resume automatically" (round-5 wording)
|
|
38
|
+
// is still not reliably true even after the requeue-to-new/ fix in
|
|
39
|
+
// poller.ts. Requeuing correctly makes the message file findable in
|
|
40
|
+
// new/ again, but checkInboxForNewSessions() (inbox-watcher.ts) never
|
|
41
|
+
// scans the filesystem — it iterates getTrackedBrains(), an in-memory
|
|
42
|
+
// Map populated only by registerBrain() (maintenance-cron.ts), which
|
|
43
|
+
// only runs when the poller processes an SQS message for that brain
|
|
44
|
+
// (poller.ts ~line 578). On a fresh poller start, that map is empty
|
|
45
|
+
// and nothing hydrates it from disk, so a brain with no cron job and
|
|
46
|
+
// no incoming traffic can have its requeued message sit untouched
|
|
47
|
+
// until *some* SQS activity for that brain re-registers it — tracked
|
|
48
|
+
// separately as teamvibe.ai#310 (trackedBrains not hydrated from disk
|
|
49
|
+
// at startup). Deliberately not "fixed" here per DevGuru round-6: that
|
|
50
|
+
// hydration gap is a separate, deeper issue.
|
|
51
|
+
//
|
|
52
|
+
// "Some SQS activity" isn't only a human message — a user-created
|
|
53
|
+
// scheduled message (`source: 'cron'`, e.g. create_scheduled_message)
|
|
54
|
+
// also goes through the same registerBrain() call, so it re-registers
|
|
55
|
+
// the brain and would surface the requeued message just as well. This
|
|
56
|
+
// does NOT extend to the poller's own internal maintenance-cron jobs
|
|
57
|
+
// (`source: 'maintenance'`) — registerBrain() explicitly skips those
|
|
58
|
+
// (maintenance-cron.ts) — so a maintenance run alone will not unstick
|
|
59
|
+
// a requeued message. The text below names the two real paths (human
|
|
60
|
+
// message, user-scheduled run) instead of implying a human reply is
|
|
61
|
+
// the only way, while still not claiming guaranteed/automatic
|
|
62
|
+
// resumption for a brain with neither.
|
|
63
|
+
return {
|
|
64
|
+
kind: 'neutral',
|
|
65
|
+
text: "Poller is restarting — your message is saved and will resume with your next message, or this brain's next scheduled run.",
|
|
66
|
+
reaction: 'arrows_counterclockwise',
|
|
67
|
+
};
|
|
36
68
|
case 'timeout':
|
|
37
69
|
case undefined: {
|
|
38
70
|
// 'timeout' keeps the legacy ":warning: Session timed out after N
|
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { type ChildProcess } from 'child_process';
|
|
1
2
|
import type { TeamVibeQueueMessage, SessionMetrics } from './types.js';
|
|
2
3
|
import { type SessionLogger } from './logger.js';
|
|
3
|
-
import type
|
|
4
|
+
import { type TypingIndicator } from './slack-client.js';
|
|
4
5
|
import { type ResolvedModelConfig } from './config-client.js';
|
|
5
6
|
import type { KilledByPoller } from './classify-failed-iteration.js';
|
|
7
|
+
export declare function progressNotificationMarks(estimateMinutes: number): number[];
|
|
8
|
+
export declare function computeCompactionKillDeadline(previousActivityTime: number, now: number, idleKillMs: number, graceMs: number): number;
|
|
6
9
|
export interface ContextHintParams {
|
|
7
10
|
isFirstMessage: boolean;
|
|
8
11
|
lastMessageTs?: string;
|
|
@@ -22,6 +25,8 @@ export interface SpawnResult {
|
|
|
22
25
|
error?: string;
|
|
23
26
|
metrics?: SessionMetrics;
|
|
24
27
|
killedByPoller?: KilledByPoller;
|
|
28
|
+
sawSendMessageCall?: boolean;
|
|
29
|
+
lastPlainTextContent?: string;
|
|
25
30
|
modelSource?: ModelSource;
|
|
26
31
|
}
|
|
27
32
|
/**
|
|
@@ -54,11 +59,180 @@ export declare function resolveModel(brainSettingsModel: string | undefined, ddb
|
|
|
54
59
|
model: string | undefined;
|
|
55
60
|
modelSource: ModelSource;
|
|
56
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* teamvibeai/teamvibe.ai#73: `claude` is spawned with `detached: true`, which
|
|
64
|
+
* (via `setsid`) makes it the leader of its own new process group, distinct
|
|
65
|
+
* from the poller's. `claude` itself spawns MCP server child processes under
|
|
66
|
+
* that same group. Signaling `proc` alone (`proc.kill()`) only reaches
|
|
67
|
+
* `claude` — its MCP children are never signaled and can survive as orphans,
|
|
68
|
+
* leaking RAM (the Railway staircase-memory pattern that motivated #73).
|
|
69
|
+
*
|
|
70
|
+
* This sends the signal to the whole process group instead (negative pid is
|
|
71
|
+
* the `kill(2)` convention for "signal the group", see `man 2 kill`). The
|
|
72
|
+
* group id equals `claude`'s own pid because `setsid` was used to create it.
|
|
73
|
+
*
|
|
74
|
+
* Group membership survives the leader's own exit — as long as at least one
|
|
75
|
+
* process (e.g. a lingering MCP server) is still alive in the group, `-pid`
|
|
76
|
+
* remains a valid target even after `claude` itself has already exited. This
|
|
77
|
+
* is what makes it safe to call from the `close` handler too (#73 gap 2),
|
|
78
|
+
* not just from the pre-exit timeout/idle/shutdown kill paths.
|
|
79
|
+
*
|
|
80
|
+
* Unlike `ChildProcess#kill()` (a safe no-op on an already-dead process),
|
|
81
|
+
* `process.kill(-pid, ...)` throws `ESRCH` once the whole group is gone —
|
|
82
|
+
* expected and common (the group is often already fully reaped by the time
|
|
83
|
+
* the follow-up SIGKILL sweep fires), so it's caught and swallowed here
|
|
84
|
+
* rather than propagating into an uncaught exception that would crash the
|
|
85
|
+
* poller.
|
|
86
|
+
*/
|
|
87
|
+
export declare function killProcessGroup(proc: ChildProcess, signal: NodeJS.Signals): void;
|
|
88
|
+
/**
|
|
89
|
+
* DevGuru PR#308 round-2 review (item 2): the group-SIGKILL sweeps below
|
|
90
|
+
* (`killAllActiveProcesses()` and the `close`-handler sweep) used to always
|
|
91
|
+
* pay their full fixed grace period (5s / `CLOSE_SWEEP_GRACE_MS`) before
|
|
92
|
+
* resolving, even in the overwhelmingly common case where `claude` exited
|
|
93
|
+
* cleanly and took its MCP children down with it — i.e. the group is already
|
|
94
|
+
* empty and there is nothing left to sweep. That matters specifically for
|
|
95
|
+
* `killAllActiveProcesses()`: `poller.ts`'s `shutdown()` awaits it BEFORE
|
|
96
|
+
* `releaseAllActiveLocks()`, and delaying that release costs the next
|
|
97
|
+
* message on the same thread a 15-minute stale-lock wait.
|
|
98
|
+
*
|
|
99
|
+
* Cheap fast-path: probe the group with signal `0` — a pure existence check
|
|
100
|
+
* per `man 2 kill`, it never actually signals anything. `ESRCH` means the
|
|
101
|
+
* group is provably already gone, so the caller can skip the grace-period
|
|
102
|
+
* wait entirely instead of always paying it. Any other outcome (including an
|
|
103
|
+
* unexpected errno) is treated as "still alive" — fail toward the existing,
|
|
104
|
+
* safe wait+SIGKILL behavior rather than skipping a real cleanup wait.
|
|
105
|
+
*
|
|
106
|
+
* Known limitation (DevGuru PR#308 round-4 review, deliberately deferred to
|
|
107
|
+
* teamvibeai/teamvibe.ai#309 rather than fixed here): a zombie (defunct,
|
|
108
|
+
* unreaped) process still answers this signal-0 probe without `ESRCH` — the
|
|
109
|
+
* process table entry is still present, it just has no live task behind it —
|
|
110
|
+
* so a group containing only zombies is still reported "alive." That makes
|
|
111
|
+
* the fast-path above silently miss and fall back to the fixed-cost
|
|
112
|
+
* wait+SIGKILL sequence instead of resolving immediately. This is a safe
|
|
113
|
+
* fallback (no hang, just no speedup), not a correctness bug — but it does
|
|
114
|
+
* mean the fast-path quietly stops paying off on a container that has
|
|
115
|
+
* accumulated zombies. What actually removes the limitation is giving the
|
|
116
|
+
* container a real init process (e.g. `tini`) as PID 1: unlike a bare `node`
|
|
117
|
+
* entrypoint, a real init reaps orphaned zombies as they appear, so they
|
|
118
|
+
* never accumulate and confuse this probe in the first place. See #309 for
|
|
119
|
+
* the tracked Docker-bake/tini follow-up.
|
|
120
|
+
*/
|
|
121
|
+
export declare function isProcessGroupAlive(pid: number): boolean;
|
|
122
|
+
export interface SweepProcessGroupOptions {
|
|
123
|
+
onGraceExpired?: () => void;
|
|
124
|
+
unref?: boolean;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Shared group-kill grace-period sweep: SIGTERM, then either resolve
|
|
128
|
+
* immediately via the `isProcessGroupAlive()` fast-path (group already
|
|
129
|
+
* provably empty — see its doc comment) or wait up to `graceMs` before
|
|
130
|
+
* escalating to SIGKILL and resolving. Extracted so both call sites
|
|
131
|
+
* (`killAllActiveProcesses()` and the `close`-handler sweep) share one
|
|
132
|
+
* tested implementation instead of duplicating the wait+escalate logic.
|
|
133
|
+
*
|
|
134
|
+
* PR#308 round-3 review item 2: the up-front `isProcessGroupAlive()` probe
|
|
135
|
+
* above only fast-paths the "already dead before we even started" case. The
|
|
136
|
+
* case that matters most — the group IS alive right this instant, which is
|
|
137
|
+
* always true during a real shutdown with an active session, exactly when
|
|
138
|
+
* `releaseAllActiveLocks()` is waiting on this — used to still unconditionally
|
|
139
|
+
* pay the *entire* fixed `graceMs` even if `claude` died 50ms after its
|
|
140
|
+
* SIGTERM. Fixed by polling `isProcessGroupAlive()` every
|
|
141
|
+
* `PROCESS_GROUP_POLL_INTERVAL_MS` after the SIGTERM instead of relying on a
|
|
142
|
+
* single fixed-delay timer to both wait AND escalate: whichever fires first —
|
|
143
|
+
* a poll observing ESRCH, or the grace period elapsing — resolves/escalates.
|
|
144
|
+
* `graceMs` remains the hard upper bound on how long this ever waits (a
|
|
145
|
+
* safety ceiling, unchanged from before); this only shortens the *common*
|
|
146
|
+
* case where the group dies well before that ceiling.
|
|
147
|
+
*/
|
|
148
|
+
export declare function sweepProcessGroup(proc: ChildProcess, graceMs: number, options?: SweepProcessGroupOptions): Promise<void>;
|
|
149
|
+
export interface BuildSpawnEnvParams {
|
|
150
|
+
spawnSecretsEnv: NodeJS.ProcessEnv;
|
|
151
|
+
processEnv: NodeJS.ProcessEnv;
|
|
152
|
+
envPath: string | undefined;
|
|
153
|
+
claudeConfigDir: string;
|
|
154
|
+
persistentStoragePath?: string;
|
|
155
|
+
slackContext?: {
|
|
156
|
+
channel: string;
|
|
157
|
+
thread_ts: string;
|
|
158
|
+
message_ts: string;
|
|
159
|
+
};
|
|
160
|
+
teamvibeApiUrl: string;
|
|
161
|
+
teamvibePollerToken: string;
|
|
162
|
+
teamvibeWorkspaceId: string;
|
|
163
|
+
teamvibeChannelId: string;
|
|
164
|
+
teamvibeBotId: string;
|
|
165
|
+
teamvibePollerId: string;
|
|
166
|
+
inboxThreadId: string;
|
|
167
|
+
brainPath: string;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Pure construction of the env object passed to the spawned `claude` child
|
|
171
|
+
* process. Extracted out of the `spawn()` call site so the "what does the
|
|
172
|
+
* child actually inherit" logic is unit-testable without spinning up a real
|
|
173
|
+
* process tree or mocking `fetchSecretsForSpawn`/DDB config.
|
|
174
|
+
*
|
|
175
|
+
* `NODE_OPTIONS` is explicitly stripped (same convention as `CLAUDECODE`
|
|
176
|
+
* below): the poller's own Railway start command sets
|
|
177
|
+
* `NODE_OPTIONS='--max-old-space-size=128 --max-semi-space-size=2'` to cap
|
|
178
|
+
* the *poller's* own idle V8 heap — a thin SQS-listener process is fine at
|
|
179
|
+
* 128MB. That var lives in `process.env` at the poller-process level, so
|
|
180
|
+
* without this override it would leak via the `...processEnv` spread below
|
|
181
|
+
* into the spawned `claude` CLI process. `claude` needs real headroom to do
|
|
182
|
+
* actual agentic work (parsing files, holding conversation context, etc.) —
|
|
183
|
+
* the poller's own tuned-for-idle heap cap is simply the wrong number for
|
|
184
|
+
* what that process does, independent of what any specific OOM failure
|
|
185
|
+
* would look like if left uncapped.
|
|
186
|
+
*/
|
|
187
|
+
export declare function buildSpawnEnv(params: BuildSpawnEnvParams): NodeJS.ProcessEnv;
|
|
57
188
|
export declare function buildPrompt(msg: TeamVibeQueueMessage, contextHint?: string): string;
|
|
189
|
+
export interface SpawnStdinPlan {
|
|
190
|
+
/** Extra CLI args to append. Empty for the plain-text path — this is the exact pre-tv293-bug2 behavior, unconditionally. */
|
|
191
|
+
extraArgs: string[];
|
|
192
|
+
/** Exact chunks written to the child process's stdin, in order, before `.end()`. */
|
|
193
|
+
stdinChunks: string[];
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Decides how the built prompt reaches the `claude` CLI: the default
|
|
197
|
+
* plain-text stdin path (unchanged from before teamvibe.ai#293 bug 2 — this
|
|
198
|
+
* covers essentially all production traffic: every Slack message, every
|
|
199
|
+
* text-only email, cron, maintenance) or a `stream-json` structured-content
|
|
200
|
+
* path carrying inline image blocks, used only when `inlineImages` is
|
|
201
|
+
* present on the incoming message (populated by email-received.ts' Part A
|
|
202
|
+
* — see packages/modules-teamvibe/src/utils/email-inline-images.ts).
|
|
203
|
+
*
|
|
204
|
+
* Pure function — no child_process/spawn access — so the no-image path's
|
|
205
|
+
* exact output (and thus "no behavior change for non-image sessions") is
|
|
206
|
+
* directly unit-testable without mocking `spawn` at all. See
|
|
207
|
+
* claude-spawner.test.ts's "no-image path is unchanged" coverage.
|
|
208
|
+
*
|
|
209
|
+
* `now` is injectable (defaults to `Date.now()`) purely so tests can assert
|
|
210
|
+
* both sides of the `expiresAt` check deterministically — see below.
|
|
211
|
+
*/
|
|
212
|
+
export declare function buildSpawnStdinPlan(prompt: string, inlineImages: TeamVibeQueueMessage['inlineImages'], now?: number): SpawnStdinPlan;
|
|
213
|
+
export declare function extractAssistantText(event: any): string | undefined;
|
|
214
|
+
export declare function assistantCalledTool(event: any, toolName: string): boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Decides whether a spawn attempt should report its session ID back to the
|
|
217
|
+
* caller (poller.ts) for persistence to DynamoDB via updateSessionId().
|
|
218
|
+
*
|
|
219
|
+
* First-message spawns always mint a fresh session ID locally (see
|
|
220
|
+
* spawnClaudeCode below, teamvibe.ai#77) instead of recycling whatever
|
|
221
|
+
* placeholder DynamoDB currently holds — so on ANY successful first-message
|
|
222
|
+
* spawn (not just the "already in use" collision-retry path), the ID actually
|
|
223
|
+
* used by Claude Code must be surfaced here. Otherwise DynamoDB keeps the
|
|
224
|
+
* createSession() placeholder forever and the thread's next message does
|
|
225
|
+
* `--resume <id-claude-code-never-used>`, which fails 100% of the time.
|
|
226
|
+
* (teamvibe.ai#77, DevGuru review round)
|
|
227
|
+
*/
|
|
228
|
+
export declare function finalizeSpawnResult(retryResult: SpawnResult, isFirstMessage: boolean, effectiveSessionId: string | undefined): SpawnResult & {
|
|
229
|
+
newSessionId?: string;
|
|
230
|
+
};
|
|
58
231
|
export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void, typingIndicator?: TypingIndicator): Promise<SpawnResult & {
|
|
59
232
|
newSessionId?: string;
|
|
60
233
|
}>;
|
|
61
234
|
export declare function getActiveProcessCount(): number;
|
|
235
|
+
export declare function getLastSessionEndTs(): number;
|
|
62
236
|
/**
|
|
63
237
|
* Kill a zombie process for a given thread ID.
|
|
64
238
|
* Returns true if a process was found and killed.
|
|
@@ -66,3 +240,7 @@ export declare function getActiveProcessCount(): number;
|
|
|
66
240
|
export declare function killZombieForThread(threadId: string): boolean;
|
|
67
241
|
export declare function isAtCapacity(): boolean;
|
|
68
242
|
export declare function killAllActiveProcesses(): Promise<void>;
|
|
243
|
+
export declare function __setActiveProcessForTest(id: string, proc: ChildProcess): void;
|
|
244
|
+
export declare function __clearActiveProcessesForTest(): void;
|
|
245
|
+
export declare function __setKillReasonForTest(proc: ChildProcess, reason: KilledByPoller): void;
|
|
246
|
+
export declare function __getKillReasonForTest(proc: ChildProcess): KilledByPoller | undefined;
|