claude-slack-channel-bots 0.8.0 → 0.8.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/package.json +1 -1
- package/src/session-manager.ts +349 -153
package/package.json
CHANGED
package/src/session-manager.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
ErrNoSessionId,
|
|
32
32
|
ErrSpawnNotFound,
|
|
33
33
|
ErrSpawnNotResumable,
|
|
34
|
+
ErrTmuxSessionCreate,
|
|
34
35
|
} from 'agent-director'
|
|
35
36
|
import type { ListRow, SpawnParams } from 'agent-director'
|
|
36
37
|
import type { WebClient } from '@slack/web-api'
|
|
@@ -206,7 +207,7 @@ export async function reconnectMcp(
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
// ---------------------------------------------------------------------------
|
|
209
|
-
//
|
|
210
|
+
// approvePreSessionDialogs — auto-approve pre-SessionStart dialogs on spawn
|
|
210
211
|
// ---------------------------------------------------------------------------
|
|
211
212
|
|
|
212
213
|
/**
|
|
@@ -216,19 +217,19 @@ export async function reconnectMcp(
|
|
|
216
217
|
*/
|
|
217
218
|
export const DEV_CHANNELS_DIALOG_NEEDLE = 'I am using this for local development'
|
|
218
219
|
|
|
219
|
-
/**
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Verified against Claude Code 2.1.120 (2026-06-02). If this stops matching,
|
|
222
|
+
* the folder-trust dialog has drifted — see b.k54 / b.uhv. Match the option
|
|
223
|
+
* label (semantic, stable) rather than the header (cosmetic, drifts).
|
|
224
|
+
*/
|
|
225
|
+
export const TRUST_DIALOG_NEEDLE = 'Yes, I trust this folder'
|
|
224
226
|
|
|
225
|
-
/**
|
|
226
|
-
export const
|
|
227
|
+
/** Default poll interval while watching for pre-session dialogs. */
|
|
228
|
+
export const DIALOG_POLL_INTERVAL_MS = 500
|
|
227
229
|
|
|
228
230
|
let _dialogPollIntervalMs = DIALOG_POLL_INTERVAL_MS
|
|
229
|
-
let _dialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
|
|
230
231
|
|
|
231
|
-
/** Test-only seam: override the
|
|
232
|
+
/** Test-only seam: override the dialog poll interval. */
|
|
232
233
|
export function _setDialogPollIntervalMs(ms: number): void {
|
|
233
234
|
_dialogPollIntervalMs = ms
|
|
234
235
|
}
|
|
@@ -238,182 +239,256 @@ export function _resetDialogPollIntervalMs(): void {
|
|
|
238
239
|
_dialogPollIntervalMs = DIALOG_POLL_INTERVAL_MS
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
/** Hard cap: how long to wait for a fresh spawn to leave `pending` (reach a
|
|
243
|
+
* live SessionStart state) while auto-dismissing pre-session dialogs. */
|
|
244
|
+
export const DIALOG_READY_TIMEOUT_MS = 5 * 60_000
|
|
245
|
+
|
|
246
|
+
let _dialogReadyTimeoutMs = DIALOG_READY_TIMEOUT_MS
|
|
247
|
+
|
|
248
|
+
/** Test-only seam: override the ready cap. */
|
|
249
|
+
export function _setDialogReadyTimeoutMs(ms: number): void {
|
|
250
|
+
_dialogReadyTimeoutMs = ms
|
|
244
251
|
}
|
|
245
252
|
|
|
246
|
-
/** Test-only seam: restore the default
|
|
247
|
-
export function
|
|
248
|
-
|
|
253
|
+
/** Test-only seam: restore the default ready cap. */
|
|
254
|
+
export function _resetDialogReadyTimeoutMs(): void {
|
|
255
|
+
_dialogReadyTimeoutMs = DIALOG_READY_TIMEOUT_MS
|
|
249
256
|
}
|
|
250
257
|
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Raw-tmux dialog fallback (b.vub) — bypass agent-director when the row is in
|
|
260
|
+
// a non-interactive state (missing/ended) but the pane still shows the dialog.
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
251
263
|
/**
|
|
252
|
-
*
|
|
253
|
-
* dialog appears, send Enter to accept the pre-selected
|
|
254
|
-
* "I am using this for local development" option, then confirm the dialog
|
|
255
|
-
* has cleared. All errors caught locally — never throws to the caller.
|
|
264
|
+
* b.vub — the critical mechanism the ticket's manual mitigation used.
|
|
256
265
|
*
|
|
257
|
-
*
|
|
258
|
-
* `
|
|
266
|
+
* On the RESUME lap, `client.resume` re-creates the tmux session but the AD row
|
|
267
|
+
* stays `missing`/`ended` until SessionStart re-fires — and SessionStart can't
|
|
268
|
+
* fire because the dev-channels dialog blocks it. Crucially, agent-director's
|
|
269
|
+
* `read-pane` / `send-keys` REFUSE to touch a `missing`/`ended` row
|
|
270
|
+
* (`ErrSpawnNotInteractive`, even with allow_pending), so the approver cannot
|
|
271
|
+
* clear the dialog through AD. A raw `tmux capture-pane` + `tmux send-keys …
|
|
272
|
+
* Enter` DOES clear it (verified against real AD/tmux; this is exactly the
|
|
273
|
+
* `tmux send-keys -t <session> Enter` the operator ran by hand in the ticket),
|
|
274
|
+
* after which AD flips to `waiting`.
|
|
259
275
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* fresh-spawn approval:
|
|
263
|
-
* - `dev-channels-approve-no-dialog` — needle never observed
|
|
264
|
-
* - `dev-channels-approve-still-visible` — needle persists after Enter
|
|
276
|
+
* These two seams shell out to tmux directly, keyed on the deterministic
|
|
277
|
+
* per-channel session name. Injectable so unit tests stay hermetic.
|
|
265
278
|
*/
|
|
266
|
-
export
|
|
267
|
-
|
|
268
|
-
web: WebClient | undefined,
|
|
269
|
-
isStartup: boolean,
|
|
270
|
-
normalizedName?: string,
|
|
271
|
-
): Promise<void> {
|
|
272
|
-
void web
|
|
273
|
-
const claude_instance_id = instanceIdFor(channelId, normalizedName)
|
|
274
|
-
const deadline = Date.now() + _dialogPollTimeoutMs
|
|
279
|
+
export type TmuxPaneReader = (sessionName: string) => Promise<string>
|
|
280
|
+
export type TmuxEnterSender = (sessionName: string) => Promise<void>
|
|
275
281
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
function defaultTmuxCapturePane(sessionName: string): Promise<string> {
|
|
283
|
+
return (async () => {
|
|
284
|
+
const { spawn } = await import('child_process')
|
|
285
|
+
return await new Promise<string>((resolve) => {
|
|
286
|
+
try {
|
|
287
|
+
const child = spawn('tmux', ['capture-pane', '-p', '-t', sessionName])
|
|
288
|
+
let out = ''
|
|
289
|
+
child.stdout?.on('data', (d: Buffer) => { out += d.toString('utf8') })
|
|
290
|
+
child.on('error', () => resolve(''))
|
|
291
|
+
child.on('close', () => resolve(out))
|
|
292
|
+
} catch {
|
|
293
|
+
resolve('')
|
|
284
294
|
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
})
|
|
296
|
+
})()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function defaultTmuxSendEnter(sessionName: string): Promise<void> {
|
|
300
|
+
return (async () => {
|
|
301
|
+
const { spawn } = await import('child_process')
|
|
302
|
+
await new Promise<void>((resolve) => {
|
|
303
|
+
try {
|
|
304
|
+
const child = spawn('tmux', ['send-keys', '-t', sessionName, 'Enter'], { stdio: 'ignore' })
|
|
305
|
+
child.on('error', () => resolve())
|
|
306
|
+
child.on('close', () => resolve())
|
|
307
|
+
} catch {
|
|
308
|
+
resolve()
|
|
290
309
|
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
310
|
+
})
|
|
311
|
+
})()
|
|
312
|
+
}
|
|
294
313
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
console.error(`[slack] approveDevChannelsDialog: ${msg}`)
|
|
298
|
-
if (isStartup) recordStartupError('dev-channels-approve-no-dialog', msg)
|
|
299
|
-
return
|
|
300
|
-
}
|
|
314
|
+
let _tmuxCapturePane: TmuxPaneReader = defaultTmuxCapturePane
|
|
315
|
+
let _tmuxSendEnter: TmuxEnterSender = defaultTmuxSendEnter
|
|
301
316
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
console.error(`[slack] approveDevChannelsDialog: ${msg}`)
|
|
315
|
-
if (isStartup) recordStartupError('dev-channels-approve-still-visible', msg)
|
|
316
|
-
}
|
|
317
|
+
/** Test-only seam: override the raw tmux pane reader. */
|
|
318
|
+
export function _setTmuxCapturePane(fn: TmuxPaneReader): void {
|
|
319
|
+
_tmuxCapturePane = fn
|
|
320
|
+
}
|
|
321
|
+
/** Test-only seam: override the raw tmux Enter sender. */
|
|
322
|
+
export function _setTmuxSendEnter(fn: TmuxEnterSender): void {
|
|
323
|
+
_tmuxSendEnter = fn
|
|
324
|
+
}
|
|
325
|
+
/** Test-only seam: restore the default raw tmux helpers. */
|
|
326
|
+
export function _resetTmuxDialogHelpers(): void {
|
|
327
|
+
_tmuxCapturePane = defaultTmuxCapturePane
|
|
328
|
+
_tmuxSendEnter = defaultTmuxSendEnter
|
|
317
329
|
}
|
|
318
330
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
331
|
+
/** Live (post-SessionStart) states: the dialog is gone, the session is ready. */
|
|
332
|
+
const DIALOG_READY_STATES = new Set(['waiting', 'working', 'ask_user', 'check_permission'])
|
|
333
|
+
/** Terminal states: the spawn died before becoming ready. */
|
|
334
|
+
const DIALOG_DEAD_STATES = new Set(['ended', 'missing'])
|
|
335
|
+
/** Every pre-SessionStart dialog we can auto-approve (option 1 pre-selected; Enter accepts). */
|
|
336
|
+
const PRE_SESSION_DIALOG_NEEDLES = [TRUST_DIALOG_NEEDLE, DEV_CHANNELS_DIALOG_NEEDLE]
|
|
322
337
|
|
|
323
338
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
339
|
+
* b.vub: consecutive dead-state-with-no-needle polls required before treating a
|
|
340
|
+
* spawn as genuinely dead. A freshly resumed (or freshly spawned) row is
|
|
341
|
+
* legitimately `missing`/`ended` for a brief window: after `client.resume`, the
|
|
342
|
+
* tmux pane needs a moment to render the dev-channels dialog, and AD keeps
|
|
343
|
+
* reporting the prior terminal state until SessionStart re-fires. Bailing on the
|
|
344
|
+
* FIRST such poll (as the pre-b.vub code did) races the dialog and re-hangs the
|
|
345
|
+
* resume. Requiring a short grace streak lets the dialog appear (needle → Enter,
|
|
346
|
+
* which resets the streak) while still fast-failing a truly dead spawn without
|
|
347
|
+
* waiting the full 5-minute cap. Reset to 0 on any live state or needle sighting.
|
|
327
348
|
*/
|
|
328
|
-
export const
|
|
349
|
+
export const DIALOG_DEAD_GRACE_POLLS = 20
|
|
329
350
|
|
|
330
|
-
let
|
|
331
|
-
let _trustDialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
|
|
332
|
-
|
|
333
|
-
/** Test-only seam: override the trust-folder poll interval. */
|
|
334
|
-
export function _setTrustDialogPollIntervalMs(ms: number): void {
|
|
335
|
-
_trustDialogPollIntervalMs = ms
|
|
336
|
-
}
|
|
351
|
+
let _dialogDeadGracePolls = DIALOG_DEAD_GRACE_POLLS
|
|
337
352
|
|
|
338
|
-
/** Test-only seam:
|
|
339
|
-
export function
|
|
340
|
-
|
|
353
|
+
/** Test-only seam: override the dead-state grace streak. */
|
|
354
|
+
export function _setDialogDeadGracePolls(n: number): void {
|
|
355
|
+
_dialogDeadGracePolls = n
|
|
341
356
|
}
|
|
342
357
|
|
|
343
|
-
/** Test-only seam:
|
|
344
|
-
export function
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Test-only seam: restore the default trust-folder poll timeout. */
|
|
349
|
-
export function _resetTrustDialogPollTimeoutMs(): void {
|
|
350
|
-
_trustDialogPollTimeoutMs = DIALOG_POLL_TIMEOUT_MS
|
|
358
|
+
/** Test-only seam: restore the default dead-state grace streak. */
|
|
359
|
+
export function _resetDialogDeadGracePolls(): void {
|
|
360
|
+
_dialogDeadGracePolls = DIALOG_DEAD_GRACE_POLLS
|
|
351
361
|
}
|
|
352
362
|
|
|
353
363
|
/**
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
364
|
+
* Drive a freshly-spawned bot past its pre-SessionStart dialogs (folder-trust
|
|
365
|
+
* and/or dev-channels) by watching agent-director's state machine: while the
|
|
366
|
+
* spawn is `pending` (SessionStart not yet fired — a dialog may be blocking),
|
|
367
|
+
* poll the pane and press Enter whenever a known dialog needle is on screen.
|
|
368
|
+
* Exit the instant AD reports a live state (the dialog is gone). 5-minute hard
|
|
369
|
+
* cap; failure to reach ready is surfaced loudly — never a silent early quit.
|
|
358
370
|
*
|
|
359
|
-
* readPane/sendKeys
|
|
360
|
-
*
|
|
371
|
+
* status/readPane/sendKeys are wrapped in withOutageDetection so AD-outage
|
|
372
|
+
* flags keep working (b.en2). readPane/sendKeys use allow_pending:true because
|
|
373
|
+
* the bot is `pending` here (b.98w). The needle-gate guarantees Enter is sent
|
|
374
|
+
* ONLY when a dialog is actually displayed, so we never inject a stray Enter
|
|
375
|
+
* into a live prompt. Uses the composed instance id (b.ben).
|
|
361
376
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
377
|
+
* Replaces the former two-function pair (trust-folder + dev-channels approvers):
|
|
378
|
+
* one loop from spawn+0 (no wasted 30s trust window) that self-heals a missed
|
|
379
|
+
* Enter (state stays pending, needle reappears, next iteration presses again)
|
|
380
|
+
* and never gives up silently at 30s.
|
|
381
|
+
*
|
|
382
|
+
* b.vub: pane-first / state-tolerant, with a RAW-TMUX fallback for dead rows.
|
|
383
|
+
* A *resumed* bot faces the same `--dangerously-load-development-channels`
|
|
384
|
+
* dialog, but its AD row is `missing`/`ended` from the prior life while it sits
|
|
385
|
+
* blocked at the dialog (SessionStart never re-fires). Two compounding facts the
|
|
386
|
+
* pre-b.vub code missed: (1) it early-returned on DIALOG_DEAD_STATES before ever
|
|
387
|
+
* reading the pane; (2) even if it had tried, agent-director's read-pane/
|
|
388
|
+
* send-keys REFUSE a `missing`/`ended` row (ErrSpawnNotInteractive), so the
|
|
389
|
+
* dialog can only be cleared by talking to tmux directly. Fix: within the
|
|
390
|
+
* pre-SessionStart window, for a dead row read+Enter via raw tmux (keyed on the
|
|
391
|
+
* deterministic session name), for a pending row via AD; press Enter whenever a
|
|
392
|
+
* needle is visible regardless of AD state; treat missing/ended as terminal only
|
|
393
|
+
* after a short no-needle grace streak (DIALOG_DEAD_GRACE_POLLS), tolerating the
|
|
394
|
+
* brief post-resume window before the dialog renders. Still needle-gated (no
|
|
395
|
+
* stray Enter into a live session) and bounded by the hard cap.
|
|
367
396
|
*/
|
|
368
|
-
export async function
|
|
397
|
+
export async function approvePreSessionDialogs(
|
|
369
398
|
channelId: string,
|
|
370
399
|
web: WebClient | undefined,
|
|
371
400
|
isStartup: boolean,
|
|
372
401
|
normalizedName?: string,
|
|
373
402
|
): Promise<void> {
|
|
374
|
-
void web
|
|
375
403
|
const claude_instance_id = instanceIdFor(channelId, normalizedName)
|
|
376
|
-
const deadline = Date.now() +
|
|
404
|
+
const deadline = Date.now() + _dialogReadyTimeoutMs
|
|
405
|
+
let deadStreak = 0
|
|
377
406
|
|
|
378
|
-
let approved = false
|
|
379
407
|
while (Date.now() < deadline) {
|
|
408
|
+
// 1) Readiness oracle.
|
|
409
|
+
let state: string
|
|
380
410
|
try {
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
await withOutageDetection(channelId, undefined, (client) => client.sendKeys({ claude_instance_id, text: '', allow_pending: true }))
|
|
384
|
-
approved = true
|
|
385
|
-
break
|
|
386
|
-
}
|
|
411
|
+
const r = await withOutageDetection(channelId, undefined, (client) => client.status({ claude_instance_id }))
|
|
412
|
+
state = r.state
|
|
387
413
|
} catch (err) {
|
|
388
|
-
if (err instanceof
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
414
|
+
if (err instanceof ErrSpawnNotFound) {
|
|
415
|
+
console.error(`[slack] approvePreSessionDialogs: spawn not found for channel=${channelId} — aborting`)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
// Transient (incl. AD-outage errors already flagged by withOutageDetection) — keep polling.
|
|
419
|
+
console.error(`[slack] approvePreSessionDialogs: status error channel=${channelId}: ${String(err)}`)
|
|
420
|
+
await new Promise((r) => setTimeout(r, _dialogPollIntervalMs))
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
if (DIALOG_READY_STATES.has(state)) return // dialog cleared, session live
|
|
424
|
+
|
|
425
|
+
// 2) Pane-first (b.vub): read the pane and dismiss any visible pre-session
|
|
426
|
+
// dialog BEFORE deciding whether a dead state is terminal.
|
|
427
|
+
//
|
|
428
|
+
// Two paths, because agent-director's read-pane/send-keys REFUSE a
|
|
429
|
+
// `missing`/`ended` row (ErrSpawnNotInteractive) even with allow_pending:
|
|
430
|
+
// - Fresh spawn (state=pending): drive via AD read-pane/send-keys.
|
|
431
|
+
// - Resumed row (state=missing/ended): AD won't touch the pane, but the
|
|
432
|
+
// dialog IS on screen and blocking SessionStart. Fall back to RAW tmux
|
|
433
|
+
// (capture-pane + send-keys Enter) keyed on the deterministic session
|
|
434
|
+
// name — exactly the `tmux send-keys -t <session> Enter` the operator
|
|
435
|
+
// ran by hand in the ticket. Once Enter lands, SessionStart fires and
|
|
436
|
+
// AD flips to a live state on the next poll.
|
|
437
|
+
let needleVisible = false
|
|
438
|
+
if (DIALOG_DEAD_STATES.has(state)) {
|
|
439
|
+
// Raw-tmux fallback (agent-director cannot interact with a dead row).
|
|
440
|
+
const sessionName = tmuxSessionNameFor(channelId, normalizedName)
|
|
441
|
+
try {
|
|
442
|
+
const pane = await _tmuxCapturePane(sessionName)
|
|
443
|
+
needleVisible = PRE_SESSION_DIALOG_NEEDLES.some((n) => pane.includes(n))
|
|
444
|
+
if (needleVisible) {
|
|
445
|
+
await _tmuxSendEnter(sessionName)
|
|
446
|
+
}
|
|
447
|
+
} catch (err) {
|
|
448
|
+
console.error(`[slack] approvePreSessionDialogs: raw-tmux fallback error channel=${channelId}: ${String(err)}`)
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
// Interactive (pending) — drive via agent-director.
|
|
452
|
+
try {
|
|
453
|
+
const { pane } = await withOutageDetection(channelId, undefined, (client) => client.readPane({ claude_instance_id, n_lines: 40, allow_pending: true }))
|
|
454
|
+
needleVisible = PRE_SESSION_DIALOG_NEEDLES.some((n) => pane.includes(n))
|
|
455
|
+
if (needleVisible) {
|
|
456
|
+
await withOutageDetection(channelId, undefined, (client) => client.sendKeys({ claude_instance_id, text: '', allow_pending: true })) // Enter
|
|
457
|
+
}
|
|
458
|
+
} catch (err) {
|
|
459
|
+
console.error(`[slack] approvePreSessionDialogs: readPane/sendKeys error channel=${channelId}: ${String(err)}`)
|
|
392
460
|
}
|
|
393
461
|
}
|
|
394
|
-
await new Promise((r) => setTimeout(r, _trustDialogPollIntervalMs))
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (!approved) {
|
|
398
|
-
// Needle never appeared — silent return (expected happy path with Fix A)
|
|
399
|
-
return
|
|
400
|
-
}
|
|
401
462
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
463
|
+
// 3) Dead-state handling (b.vub). A needle means "dialog-blocked, not dead":
|
|
464
|
+
// reset the streak and keep pressing Enter. Only treat missing/ended as
|
|
465
|
+
// terminal after it persists with NO needle for DIALOG_DEAD_GRACE_POLLS
|
|
466
|
+
// consecutive polls — this tolerates the brief post-spawn/post-resume window
|
|
467
|
+
// where the row is still terminal and the pane hasn't rendered the dialog
|
|
468
|
+
// yet, without racing the dialog and re-hanging the resume.
|
|
469
|
+
if (needleVisible) {
|
|
470
|
+
deadStreak = 0
|
|
471
|
+
} else if (DIALOG_DEAD_STATES.has(state)) {
|
|
472
|
+
deadStreak += 1
|
|
473
|
+
if (deadStreak >= _dialogDeadGracePolls) {
|
|
474
|
+
const msg = `spawn reached ${state} before clearing dev-channels dialog for channel=${channelId} (no needle for ${deadStreak} polls)`
|
|
475
|
+
console.error(`[slack] approvePreSessionDialogs: ${msg}`)
|
|
476
|
+
if (isStartup) recordStartupError('dev-channels-approve-spawn-died', msg)
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
// pending (or any other non-dead, non-ready state) — reset the streak.
|
|
481
|
+
deadStreak = 0
|
|
410
482
|
}
|
|
483
|
+
|
|
484
|
+
await new Promise((r) => setTimeout(r, _dialogPollIntervalMs))
|
|
411
485
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
486
|
+
|
|
487
|
+
// 3) Hard cap hit — genuine failure, surfaced loudly (no silent give-up).
|
|
488
|
+
const msg = `spawn never reached a live state within ${_dialogReadyTimeoutMs}ms for channel=${channelId} — dialog unrecognized or session hung (dev-needle='${DEV_CHANNELS_DIALOG_NEEDLE}')`
|
|
489
|
+
console.error(`[slack] approvePreSessionDialogs: ${msg}`)
|
|
490
|
+
if (isStartup) recordStartupError('dev-channels-approve-not-ready', msg)
|
|
491
|
+
postSpawnFailureToChannel(channelId, new AgentDirectorError('status', 'DialogApprovalTimeout', msg), web, isStartup)
|
|
417
492
|
}
|
|
418
493
|
|
|
419
494
|
// ---------------------------------------------------------------------------
|
|
@@ -530,6 +605,78 @@ async function tryKill(channelId: string, normalizedName: string | undefined): P
|
|
|
530
605
|
}
|
|
531
606
|
}
|
|
532
607
|
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// Orphan-tmux self-heal (b.vub)
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Kill a tmux session by its exact name. Injectable seam so unit tests can
|
|
614
|
+
* assert the self-heal path without spawning real processes. Default impl
|
|
615
|
+
* runs `tmux kill-session -t <name>` best-effort.
|
|
616
|
+
*
|
|
617
|
+
* b.vub: the field failure is an orphan tmux session that survives the AD row
|
|
618
|
+
* going `missing` — the AD `client.kill` verb does NOT reap it (observed across
|
|
619
|
+
* dozens of restart cycles). Killing the session directly by its deterministic,
|
|
620
|
+
* per-channel name (`tmuxSessionNameFor`) is the only reliable reap, and the
|
|
621
|
+
* name can only ever belong to this channel's spawn, so it is safe.
|
|
622
|
+
*/
|
|
623
|
+
export type TmuxSessionKiller = (sessionName: string) => Promise<void>
|
|
624
|
+
|
|
625
|
+
let _killTmuxSession: TmuxSessionKiller = async (sessionName: string): Promise<void> => {
|
|
626
|
+
const { spawn } = await import('child_process')
|
|
627
|
+
await new Promise<void>((resolve) => {
|
|
628
|
+
try {
|
|
629
|
+
const child = spawn('tmux', ['kill-session', '-t', sessionName], { stdio: 'ignore' })
|
|
630
|
+
child.on('error', () => resolve()) // tmux missing / session absent — best-effort
|
|
631
|
+
child.on('close', () => resolve())
|
|
632
|
+
} catch {
|
|
633
|
+
resolve()
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Test-only seam: override the tmux-session killer. */
|
|
639
|
+
export function _setTmuxSessionKiller(fn: TmuxSessionKiller): void {
|
|
640
|
+
_killTmuxSession = fn
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Test-only seam: restore the default tmux-session killer. */
|
|
644
|
+
export function _resetTmuxSessionKiller(): void {
|
|
645
|
+
_killTmuxSession = async (sessionName: string): Promise<void> => {
|
|
646
|
+
const { spawn } = await import('child_process')
|
|
647
|
+
await new Promise<void>((resolve) => {
|
|
648
|
+
try {
|
|
649
|
+
const child = spawn('tmux', ['kill-session', '-t', sessionName], { stdio: 'ignore' })
|
|
650
|
+
child.on('error', () => resolve())
|
|
651
|
+
child.on('close', () => resolve())
|
|
652
|
+
} catch {
|
|
653
|
+
resolve()
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Self-heal an `ErrTmuxSessionCreate` collision (b.vub): the deterministic tmux
|
|
661
|
+
* session name is still held by an orphaned session while the AD row is
|
|
662
|
+
* terminal/gone, so a fresh spawn/resume cannot create the session. Kill the
|
|
663
|
+
* orphan by name, then retry `client.spawn` ONCE. Returns the spawn result on
|
|
664
|
+
* success, or rethrows the retry's error (caller surfaces it).
|
|
665
|
+
*/
|
|
666
|
+
async function selfHealTmuxCollisionAndRespawn(
|
|
667
|
+
channelId: string,
|
|
668
|
+
route: { cwd: string },
|
|
669
|
+
params: SpawnParams,
|
|
670
|
+
normalizedName: string | undefined,
|
|
671
|
+
): Promise<{ claude_instance_id: string }> {
|
|
672
|
+
const sessionName = tmuxSessionNameFor(channelId, normalizedName)
|
|
673
|
+
console.error(
|
|
674
|
+
`[slack] spawnForRoute: ErrTmuxSessionCreate for channel=${channelId} — killing orphan tmux session "${sessionName}" and retrying spawn once`,
|
|
675
|
+
)
|
|
676
|
+
await _killTmuxSession(sessionName)
|
|
677
|
+
return withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
|
|
678
|
+
}
|
|
679
|
+
|
|
533
680
|
/** Delete the spawn row; surface failures. Returns whether the delete succeeded. */
|
|
534
681
|
async function tryDelete(
|
|
535
682
|
channelId: string,
|
|
@@ -585,13 +732,36 @@ export async function spawnForRoute(
|
|
|
585
732
|
try {
|
|
586
733
|
const r = await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
|
|
587
734
|
console.error(`[slack] spawnForRoute: spawned channel=${channelId} instanceId=${r.claude_instance_id}`)
|
|
588
|
-
await
|
|
589
|
-
await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
|
|
735
|
+
await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
|
|
590
736
|
return { channelId, action: 'spawned' }
|
|
591
737
|
} catch (err) {
|
|
592
738
|
if (err instanceof ErrInstanceIdCollision) {
|
|
593
739
|
// Collision → fall through to get-then-act
|
|
594
740
|
console.error(`[slack] spawnForRoute: ErrInstanceIdCollision for channel=${channelId} — fetching current state`)
|
|
741
|
+
} else if (err instanceof ErrTmuxSessionCreate) {
|
|
742
|
+
// b.vub: fresh spawn collided on the deterministic tmux session name held
|
|
743
|
+
// by an orphan session (no instance-id collision → no AD row to resolve).
|
|
744
|
+
// Self-heal: kill the orphan by name, retry spawn once.
|
|
745
|
+
try {
|
|
746
|
+
const r = await selfHealTmuxCollisionAndRespawn(channelId, route, params, normalizedName)
|
|
747
|
+
console.error(`[slack] spawnForRoute: self-heal spawn succeeded after ErrTmuxSessionCreate for channel=${channelId} instanceId=${r.claude_instance_id}`)
|
|
748
|
+
await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
|
|
749
|
+
return { channelId, action: 'spawned' }
|
|
750
|
+
} catch (err2) {
|
|
751
|
+
if (
|
|
752
|
+
err2 instanceof ErrSystemInstallDisappeared ||
|
|
753
|
+
err2 instanceof ErrTmuxNotAvailable ||
|
|
754
|
+
err2 instanceof ErrCwdNotFound ||
|
|
755
|
+
err2 instanceof ErrCwdNotADirectory
|
|
756
|
+
) {
|
|
757
|
+
return { channelId, action: 'failed' }
|
|
758
|
+
}
|
|
759
|
+
const e = err2 instanceof AgentDirectorError ? err2 : new AgentDirectorError('spawn', 'UnknownError', String(err2))
|
|
760
|
+
console.error(`[slack] spawnForRoute: self-heal spawn after ErrTmuxSessionCreate failed for channel=${channelId}: ${e.errName}`)
|
|
761
|
+
if (isStartup) recordStartupError('spawn-failed', `self-heal spawn after ErrTmuxSessionCreate failed for channel=${channelId}: ${e.errName}`, e)
|
|
762
|
+
postSpawnFailureToChannel(channelId, e, web, isStartup)
|
|
763
|
+
return { channelId, action: 'failed' }
|
|
764
|
+
}
|
|
595
765
|
} else if (
|
|
596
766
|
err instanceof ErrSystemInstallDisappeared ||
|
|
597
767
|
err instanceof ErrTmuxNotAvailable ||
|
|
@@ -620,8 +790,7 @@ export async function spawnForRoute(
|
|
|
620
790
|
try {
|
|
621
791
|
const r = await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
|
|
622
792
|
console.error(`[slack] spawnForRoute: retry-spawn succeeded for channel=${channelId} instanceId=${r.claude_instance_id}`)
|
|
623
|
-
await
|
|
624
|
-
await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
|
|
793
|
+
await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
|
|
625
794
|
return { channelId, action: 'spawned' }
|
|
626
795
|
} catch (err2) {
|
|
627
796
|
if (
|
|
@@ -658,8 +827,7 @@ export async function spawnForRoute(
|
|
|
658
827
|
try {
|
|
659
828
|
await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
|
|
660
829
|
console.error(`[slack] spawnForRoute: fresh-spawned (after kill+delete) for channel=${channelId}`)
|
|
661
|
-
await
|
|
662
|
-
await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
|
|
830
|
+
await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
|
|
663
831
|
return { channelId, action: 'spawned' }
|
|
664
832
|
} catch (err) {
|
|
665
833
|
if (
|
|
@@ -683,16 +851,45 @@ export async function spawnForRoute(
|
|
|
683
851
|
try {
|
|
684
852
|
await withSpawnDetection(channelId, route.cwd, (client) => client.resume({ claude_instance_id: instanceIdFor(channelId, normalizedName) }))
|
|
685
853
|
console.error(`[slack] spawnForRoute: resumed channel=${channelId}`)
|
|
854
|
+
// b.vub: a resumed bot faces the same --dangerously-load-development-channels
|
|
855
|
+
// dialog. Its AD row is still `missing`/`ended` while blocked at the dialog
|
|
856
|
+
// (SessionStart hasn't re-fired), so the pane-first approver drives it past.
|
|
857
|
+
await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
|
|
686
858
|
return { channelId, action: 'resumed' }
|
|
687
859
|
} catch (err) {
|
|
860
|
+
if (err instanceof ErrTmuxSessionCreate) {
|
|
861
|
+
// b.vub: the deterministic tmux session name is still held by an orphan
|
|
862
|
+
// session while the AD row is terminal — resume cannot re-create it.
|
|
863
|
+
// This is the observed field failure (resume throws ErrTmuxSessionCreate
|
|
864
|
+
// every ~2 min). Self-heal: kill the orphan by name, retry spawn once.
|
|
865
|
+
try {
|
|
866
|
+
const r = await selfHealTmuxCollisionAndRespawn(channelId, route, params, normalizedName)
|
|
867
|
+
console.error(`[slack] spawnForRoute: self-heal spawn succeeded after ErrTmuxSessionCreate for channel=${channelId} instanceId=${r.claude_instance_id}`)
|
|
868
|
+
await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
|
|
869
|
+
return { channelId, action: 'spawned' }
|
|
870
|
+
} catch (err2) {
|
|
871
|
+
if (
|
|
872
|
+
err2 instanceof ErrSystemInstallDisappeared ||
|
|
873
|
+
err2 instanceof ErrTmuxNotAvailable ||
|
|
874
|
+
err2 instanceof ErrCwdNotFound ||
|
|
875
|
+
err2 instanceof ErrCwdNotADirectory
|
|
876
|
+
) {
|
|
877
|
+
return { channelId, action: 'failed' }
|
|
878
|
+
}
|
|
879
|
+
const e = err2 instanceof AgentDirectorError ? err2 : new AgentDirectorError('spawn', 'UnknownError', String(err2))
|
|
880
|
+
console.error(`[slack] spawnForRoute: self-heal spawn after ErrTmuxSessionCreate failed for channel=${channelId}: ${e.errName}`)
|
|
881
|
+
if (isStartup) recordStartupError('spawn-failed', `self-heal spawn after ErrTmuxSessionCreate failed for channel=${channelId}: ${e.errName}`, e)
|
|
882
|
+
postSpawnFailureToChannel(channelId, e, web, isStartup)
|
|
883
|
+
return { channelId, action: 'failed' }
|
|
884
|
+
}
|
|
885
|
+
}
|
|
688
886
|
if (err instanceof ErrNoSessionId || err instanceof ErrJsonlMissing) {
|
|
689
887
|
console.error(`[slack] spawnForRoute: ${err.errName} on resume for channel=${channelId} — delete+fresh`)
|
|
690
888
|
if (!(await tryDelete(channelId, normalizedName, web, isStartup))) return { channelId, action: 'failed' }
|
|
691
889
|
try {
|
|
692
890
|
await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
|
|
693
891
|
console.error(`[slack] spawnForRoute: fresh-spawned (after delete) for channel=${channelId}`)
|
|
694
|
-
await
|
|
695
|
-
await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
|
|
892
|
+
await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
|
|
696
893
|
return { channelId, action: 'spawned' }
|
|
697
894
|
} catch (err2) {
|
|
698
895
|
if (
|
|
@@ -717,8 +914,7 @@ export async function spawnForRoute(
|
|
|
717
914
|
if (!(await tryDelete(channelId, normalizedName, web, isStartup))) return { channelId, action: 'failed' }
|
|
718
915
|
try {
|
|
719
916
|
await withSpawnDetection(channelId, route.cwd, (client) => client.spawn(params))
|
|
720
|
-
await
|
|
721
|
-
await approveDevChannelsDialog(channelId, web, isStartup, normalizedName)
|
|
917
|
+
await approvePreSessionDialogs(channelId, web, isStartup, normalizedName)
|
|
722
918
|
return { channelId, action: 'spawned' }
|
|
723
919
|
} catch (err2) {
|
|
724
920
|
if (
|