@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.
@@ -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
@@ -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 { TypingIndicator } from './slack-client.js';
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;