switchroom 0.14.20 → 0.14.22
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/agent-scheduler/index.js +2 -3
- package/dist/auth-broker/index.js +2 -3
- package/dist/cli/notion-write-pretool.mjs +2 -3
- package/dist/cli/switchroom.js +16 -8
- package/dist/host-control/main.js +2 -3
- package/dist/vault/approvals/kernel-server.js +2 -3
- package/dist/vault/broker/server.js +2 -3
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +11 -24
- package/profiles/_shared/telegram-style.md.hbs +2 -2
- package/profiles/default/CLAUDE.md.hbs +4 -1
- package/skills/switchroom-runtime/SKILL.md +6 -16
- package/telegram-plugin/agent-dir.ts +15 -0
- package/telegram-plugin/dist/gateway/gateway.js +655 -514
- package/telegram-plugin/gateway/coalesce-attachments.ts +9 -0
- package/telegram-plugin/gateway/gateway.ts +246 -83
- package/telegram-plugin/gateway/inbound-spool.ts +15 -0
- package/telegram-plugin/gateway/interrupt-defer.ts +6 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
- package/telegram-plugin/registry/turns-schema.ts +138 -33
- package/telegram-plugin/stream-reply-handler.ts +1 -11
- package/telegram-plugin/tests/agent-dir.test.ts +25 -0
- package/telegram-plugin/tests/coalesce-attachments.test.ts +24 -6
- package/telegram-plugin/tests/e2e.test.ts +2 -77
- package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
- package/telegram-plugin/tests/interrupt-defer.test.ts +13 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
- package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
- package/telegram-plugin/tests/races.test.ts +0 -26
- package/telegram-plugin/tests/registry-turns.test.ts +106 -29
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
- package/telegram-plugin/tests/status-accent.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
- package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
- package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tests/turns-writer.test.ts +16 -6
- package/telegram-plugin/tests/worker-activity-feed.test.ts +14 -0
- package/telegram-plugin/tool-activity-summary.ts +55 -0
- package/telegram-plugin/uat/assertions.ts +53 -0
- package/telegram-plugin/uat/driver.ts +30 -0
- package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
- package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
- package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
- package/telegram-plugin/worker-activity-feed.ts +11 -5
- package/telegram-plugin/handoff-continuity.ts +0 -206
- package/telegram-plugin/tests/handoff-continuity.test.ts +0 -262
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builders for the synthetic inbounds the gateway injects at boot
|
|
3
|
+
* when it inherits an interrupted turn from the previous process.
|
|
4
|
+
*
|
|
5
|
+
* Two shapes, selected by how the prior turn ended (see
|
|
6
|
+
* `selectResumeBuilder`):
|
|
7
|
+
*
|
|
8
|
+
* - `resume_interrupted` — the turn was cut off mid-flight by an
|
|
9
|
+
* operator restart / SIGTERM / crash while it was still making
|
|
10
|
+
* progress. The agent should pick the work back up and tell the user
|
|
11
|
+
* it's resuming. Blanket resume regardless of how long ago — the
|
|
12
|
+
* elapsed time rides along so the model can frame it ("picking up the
|
|
13
|
+
* X you asked ~3h ago").
|
|
14
|
+
*
|
|
15
|
+
* - `resume_watchdog_timeout` — the turn stalled with no tool progress
|
|
16
|
+
* for the full hang-watchdog window and was (or would have been)
|
|
17
|
+
* killed as a hang. The agent must NOT silently resume; it reports
|
|
18
|
+
* what happened honestly and asks whether to retry or take a
|
|
19
|
+
* different angle. The honest cause is "no observable progress for N
|
|
20
|
+
* minutes" — the framework deliberately does not invent a deeper root
|
|
21
|
+
* cause, and neither should the model.
|
|
22
|
+
*
|
|
23
|
+
* Why a separate module (mirrors `vault-grant-inbound-builders.ts`): the
|
|
24
|
+
* InboundMessage shape is load-bearing. `meta.source` is what the bridge
|
|
25
|
+
* forwards verbatim and Claude Code renders as `<channel source="…">`, so
|
|
26
|
+
* the model keys on it to know this is a boot-resume turn rather than a
|
|
27
|
+
* human message. `meta.resume_turn_key` is the dedup anchor the spool
|
|
28
|
+
* uses (see `spoolId`) so a multi-restart sequence resumes a given turn
|
|
29
|
+
* exactly once. Pinning the builders against fixture tests keeps that
|
|
30
|
+
* contract honest without booting a real gateway.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
34
|
+
import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
|
|
35
|
+
|
|
36
|
+
/** Render an elapsed duration as a coarse, human-friendly approximation
|
|
37
|
+
* the model can drop straight into prose ("~3h ago"). Deliberately
|
|
38
|
+
* coarse — minute/hour/day buckets, never "2h 47m" precision the user
|
|
39
|
+
* doesn't care about on a resume. */
|
|
40
|
+
export function humanizeElapsed(ms: number): string {
|
|
41
|
+
if (!Number.isFinite(ms) || ms < 0) return 'an unknown amount of time'
|
|
42
|
+
const sec = Math.round(ms / 1000)
|
|
43
|
+
if (sec < 45) return 'moments'
|
|
44
|
+
const min = Math.round(sec / 60)
|
|
45
|
+
if (min < 60) return `~${min} min`
|
|
46
|
+
const hr = Math.round(min / 60)
|
|
47
|
+
if (hr < 24) return `~${hr}h`
|
|
48
|
+
const days = Math.round(hr / 24)
|
|
49
|
+
return `~${days} day${days === 1 ? '' : 's'}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ResumeInboundContext {
|
|
53
|
+
/** The interrupted turn, straight from the registry. */
|
|
54
|
+
turn: Turn
|
|
55
|
+
/** Wall-clock ms. Drives `ts`, `messageId`, and the elapsed framing.
|
|
56
|
+
* Defaults to Date.now(). */
|
|
57
|
+
nowMs?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function threadIdNum(turn: Turn): number | undefined {
|
|
61
|
+
if (turn.thread_id == null) return undefined
|
|
62
|
+
const n = Number(turn.thread_id)
|
|
63
|
+
return Number.isFinite(n) ? n : undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function promptClause(turn: Turn): string {
|
|
67
|
+
const p = turn.user_prompt_preview?.trim()
|
|
68
|
+
if (!p) return ''
|
|
69
|
+
// Quote-trim so a long preview doesn't bloat the channel body.
|
|
70
|
+
const snippet = p.length > 160 ? p.slice(0, 160) + '…' : p
|
|
71
|
+
return ` The request was: "${snippet}".`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the `resume_interrupted` inbound — a clean mid-flight interrupt
|
|
76
|
+
* the agent should pick back up.
|
|
77
|
+
*/
|
|
78
|
+
export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): InboundMessage {
|
|
79
|
+
const ts = ctx.nowMs ?? Date.now()
|
|
80
|
+
const elapsed = humanizeElapsed(ts - ctx.turn.started_at)
|
|
81
|
+
const meta: Record<string, string> = {
|
|
82
|
+
source: 'resume_interrupted',
|
|
83
|
+
resume_turn_key: ctx.turn.turn_key,
|
|
84
|
+
interrupted_via: ctx.turn.ended_via ?? 'restart',
|
|
85
|
+
started_at: String(ctx.turn.started_at),
|
|
86
|
+
}
|
|
87
|
+
if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
|
|
88
|
+
const threadId = threadIdNum(ctx.turn)
|
|
89
|
+
return {
|
|
90
|
+
type: 'inbound',
|
|
91
|
+
chatId: ctx.turn.chat_id,
|
|
92
|
+
...(threadId != null ? { threadId } : {}),
|
|
93
|
+
messageId: ts,
|
|
94
|
+
user: 'switchroom',
|
|
95
|
+
userId: 0,
|
|
96
|
+
ts,
|
|
97
|
+
text:
|
|
98
|
+
`You just restarted. Your previous turn was interrupted ${elapsed} ago, ` +
|
|
99
|
+
`before it finished — it was cut off by a restart, not completed.` +
|
|
100
|
+
promptClause(ctx.turn) +
|
|
101
|
+
` Pick that work back up now and continue it through to completion. ` +
|
|
102
|
+
`In your first message, briefly let the user know you're resuming what ` +
|
|
103
|
+
`was interrupted (mention roughly how long ago in plain language) so ` +
|
|
104
|
+
`they're not left wondering — then carry on with the actual task. Do ` +
|
|
105
|
+
`not ask whether to resume; just resume. If you genuinely can't tell ` +
|
|
106
|
+
`what the work was, say so and ask.`,
|
|
107
|
+
meta,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the `resume_watchdog_timeout` inbound — a stalled turn the agent
|
|
113
|
+
* must report (not silently resume).
|
|
114
|
+
*
|
|
115
|
+
* `idleMs` is the no-progress duration the boot classifier measured (the
|
|
116
|
+
* marker age). It is passed explicitly rather than read off the turn so
|
|
117
|
+
* the caller can recover it from the persisted `interrupt_reason` on a
|
|
118
|
+
* later boot when the live marker is gone.
|
|
119
|
+
*/
|
|
120
|
+
export function buildResumeWatchdogReportInbound(
|
|
121
|
+
ctx: ResumeInboundContext & { idleMs: number },
|
|
122
|
+
): InboundMessage {
|
|
123
|
+
const ts = ctx.nowMs ?? Date.now()
|
|
124
|
+
const idle = humanizeElapsed(ctx.idleMs)
|
|
125
|
+
const since = humanizeElapsed(ts - ctx.turn.started_at)
|
|
126
|
+
const toolClause =
|
|
127
|
+
ctx.turn.tool_call_count != null && ctx.turn.tool_call_count > 0
|
|
128
|
+
? ` You'd run ${ctx.turn.tool_call_count} tool call${ctx.turn.tool_call_count === 1 ? '' : 's'} before it stalled.`
|
|
129
|
+
: ''
|
|
130
|
+
const meta: Record<string, string> = {
|
|
131
|
+
source: 'resume_watchdog_timeout',
|
|
132
|
+
resume_turn_key: ctx.turn.turn_key,
|
|
133
|
+
interrupted_via: 'timeout',
|
|
134
|
+
idle_ms: String(ctx.idleMs),
|
|
135
|
+
started_at: String(ctx.turn.started_at),
|
|
136
|
+
}
|
|
137
|
+
if (ctx.turn.tool_call_count != null) meta.tool_call_count = String(ctx.turn.tool_call_count)
|
|
138
|
+
if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
|
|
139
|
+
const threadId = threadIdNum(ctx.turn)
|
|
140
|
+
return {
|
|
141
|
+
type: 'inbound',
|
|
142
|
+
chatId: ctx.turn.chat_id,
|
|
143
|
+
...(threadId != null ? { threadId } : {}),
|
|
144
|
+
messageId: ts,
|
|
145
|
+
user: 'switchroom',
|
|
146
|
+
userId: 0,
|
|
147
|
+
ts,
|
|
148
|
+
text:
|
|
149
|
+
`You just restarted. Your previous turn (started ${since} ago) was ` +
|
|
150
|
+
`killed by the hang-watchdog: it made no observable progress for ${idle} ` +
|
|
151
|
+
`and the watchdog restarts a turn that goes that long without activity.` +
|
|
152
|
+
toolClause +
|
|
153
|
+
promptClause(ctx.turn) +
|
|
154
|
+
` Do NOT silently resume it — it may hang again the same way. Instead, ` +
|
|
155
|
+
`tell the user plainly what happened: that your last turn was killed ` +
|
|
156
|
+
`after ${idle} of no progress, and roughly what it was doing. Then ask ` +
|
|
157
|
+
`whether they want you to retry it or take a different angle. Report ` +
|
|
158
|
+
`only the honest cause — no observable progress for that long — don't ` +
|
|
159
|
+
`speculate about a deeper root cause you can't see.`,
|
|
160
|
+
meta,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Decide which resume inbound (if any) a given interrupt warrants. Pure —
|
|
166
|
+
* the gateway calls this with the classified `ended_via` so the
|
|
167
|
+
* report-vs-resume policy lives in one testable place.
|
|
168
|
+
*
|
|
169
|
+
* - 'timeout' → 'report' (watchdog kill)
|
|
170
|
+
* - 'restart' | 'sigterm' | 'unknown' → 'resume' (clean interrupt)
|
|
171
|
+
* - 'stop' → null (finished; nothing to do)
|
|
172
|
+
*/
|
|
173
|
+
export function selectResumeBuilder(
|
|
174
|
+
endedVia: TurnEndedVia | null,
|
|
175
|
+
): 'resume' | 'report' | null {
|
|
176
|
+
if (endedVia === 'timeout') return 'report'
|
|
177
|
+
if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') return 'resume'
|
|
178
|
+
if (endedVia == null) return 'resume' // still-open at boot = killed mid-flight
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
* updated_at INTEGER NOT NULL
|
|
29
29
|
*
|
|
30
30
|
* Boot-time usage:
|
|
31
|
-
* On every gateway boot, call `
|
|
32
|
-
* after opening the DB. Any turn with `ended_at IS NULL` was
|
|
33
|
-
* mid-flight (SIGKILL, OOM, power loss) — it never
|
|
34
|
-
* a clean-shutdown marker.
|
|
35
|
-
*
|
|
31
|
+
* On every gateway boot, call `markOrphanedWithTimeoutClassification(db, …)`
|
|
32
|
+
* immediately after opening the DB. Any turn with `ended_at IS NULL` was
|
|
33
|
+
* killed mid-flight (SIGKILL, OOM, power loss, operator restart) — it never
|
|
34
|
+
* got a chance to write a clean-shutdown marker. The classifier stamps the
|
|
35
|
+
* in-flight turn `'timeout'` when its hang-marker is stale and `'restart'`
|
|
36
|
+
* otherwise; the gateway then resumes or reports accordingly.
|
|
36
37
|
*/
|
|
37
38
|
|
|
38
39
|
import { chmodSync, mkdirSync } from 'fs'
|
|
@@ -98,6 +99,15 @@ export interface Turn {
|
|
|
98
99
|
user_prompt_preview: string | null
|
|
99
100
|
assistant_reply_preview: string | null
|
|
100
101
|
tool_call_count: number | null
|
|
102
|
+
/**
|
|
103
|
+
* Forensic snapshot persisted by the boot-time classifier when a turn is
|
|
104
|
+
* stamped `ended_via='timeout'` (the hang-watchdog window elapsed with no
|
|
105
|
+
* tool progress). Carries the idle duration so a *later* boot can rebuild
|
|
106
|
+
* the watchdog-report inbound after the on-disk turn-active marker — the
|
|
107
|
+
* only live source of the idle age — has already been swept. Null for
|
|
108
|
+
* cleanly-restarted (`'restart'`) orphans.
|
|
109
|
+
*/
|
|
110
|
+
interrupt_reason: string | null
|
|
101
111
|
created_at: number
|
|
102
112
|
updated_at: number
|
|
103
113
|
}
|
|
@@ -137,6 +147,7 @@ const SCHEMA_SQL = `
|
|
|
137
147
|
user_prompt_preview TEXT,
|
|
138
148
|
assistant_reply_preview TEXT,
|
|
139
149
|
tool_call_count INTEGER,
|
|
150
|
+
interrupt_reason TEXT,
|
|
140
151
|
created_at INTEGER NOT NULL,
|
|
141
152
|
updated_at INTEGER NOT NULL
|
|
142
153
|
);
|
|
@@ -151,13 +162,21 @@ const PHASE1_MIGRATIONS = [
|
|
|
151
162
|
`ALTER TABLE turns ADD COLUMN tool_call_count INTEGER`,
|
|
152
163
|
]
|
|
153
164
|
|
|
165
|
+
// Column added for honest-restart-resume. Persists the idle snapshot the
|
|
166
|
+
// boot classifier captures when stamping a turn 'timeout' (see
|
|
167
|
+
// `markOrphanedWithTimeoutClassification`).
|
|
168
|
+
const PHASE2_MIGRATIONS = [
|
|
169
|
+
`ALTER TABLE turns ADD COLUMN interrupt_reason TEXT`,
|
|
170
|
+
]
|
|
171
|
+
|
|
154
172
|
function applySchema(db: SqliteDatabase): void {
|
|
155
173
|
db.exec('PRAGMA journal_mode = WAL')
|
|
156
174
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
157
175
|
db.exec(SCHEMA_SQL)
|
|
158
|
-
// Run migrations
|
|
159
|
-
//
|
|
160
|
-
|
|
176
|
+
// Run migrations. SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so
|
|
177
|
+
// we swallow the "duplicate column" error to stay idempotent on
|
|
178
|
+
// pre-existing registry.db files.
|
|
179
|
+
for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS]) {
|
|
161
180
|
try {
|
|
162
181
|
db.exec(sql)
|
|
163
182
|
} catch (err) {
|
|
@@ -225,6 +244,7 @@ interface RawTurnRow {
|
|
|
225
244
|
user_prompt_preview: string | null
|
|
226
245
|
assistant_reply_preview: string | null
|
|
227
246
|
tool_call_count: number | null
|
|
247
|
+
interrupt_reason: string | null
|
|
228
248
|
created_at: number
|
|
229
249
|
updated_at: number
|
|
230
250
|
}
|
|
@@ -244,6 +264,7 @@ function mapRow(row: RawTurnRow): Turn {
|
|
|
244
264
|
user_prompt_preview: row.user_prompt_preview,
|
|
245
265
|
assistant_reply_preview: row.assistant_reply_preview,
|
|
246
266
|
tool_call_count: row.tool_call_count,
|
|
267
|
+
interrupt_reason: row.interrupt_reason,
|
|
247
268
|
created_at: row.created_at,
|
|
248
269
|
updated_at: row.updated_at,
|
|
249
270
|
}
|
|
@@ -283,7 +304,7 @@ export function recordTurnStart(db: SqliteDatabase, args: RecordTurnStartArgs):
|
|
|
283
304
|
* tool-call count.
|
|
284
305
|
*
|
|
285
306
|
* No-ops gracefully if `turnKey` is not found (turn may have already been
|
|
286
|
-
* swept by `
|
|
307
|
+
* swept by `markOrphanedWithTimeoutClassification` on a prior boot).
|
|
287
308
|
*/
|
|
288
309
|
export function recordTurnEnd(db: SqliteDatabase, args: RecordTurnEndArgs): void {
|
|
289
310
|
const now = Date.now()
|
|
@@ -327,27 +348,96 @@ export function findOrphanedTurns(db: SqliteDatabase, chatId: string): Turn[] {
|
|
|
327
348
|
return rows.map(mapRow)
|
|
328
349
|
}
|
|
329
350
|
|
|
351
|
+
export interface OrphanClassifyOpts {
|
|
352
|
+
/**
|
|
353
|
+
* `turnKey` from the on-disk `turn-active.json` marker — the single
|
|
354
|
+
* in-flight turn the hang-watchdog tracks. Null when no marker is
|
|
355
|
+
* present at boot (the previous process exited cleanly between turns).
|
|
356
|
+
*/
|
|
357
|
+
markerTurnKey?: string | null
|
|
358
|
+
/**
|
|
359
|
+
* Age in ms of the `turn-active.json` marker's mtime at boot, or null
|
|
360
|
+
* when no marker is present. The marker's mtime is bumped on every
|
|
361
|
+
* tool_use, so this is "ms since the last observable progress" of the
|
|
362
|
+
* in-flight turn.
|
|
363
|
+
*/
|
|
364
|
+
markerAgeMs?: number | null
|
|
365
|
+
/**
|
|
366
|
+
* Hang-watchdog threshold in ms (`TURN_HANG_SECS * 1000`, default
|
|
367
|
+
* 300_000). A marker older than this means the in-flight turn made no
|
|
368
|
+
* tool progress for at least the watchdog window — i.e. it was (or,
|
|
369
|
+
* under Docker where the watchdog is disabled, *would have been*)
|
|
370
|
+
* killed as a hang rather than cleanly restarted. That distinction is
|
|
371
|
+
* the whole point: a hung turn is reported, a live one is resumed.
|
|
372
|
+
*/
|
|
373
|
+
hangThresholdMs: number
|
|
374
|
+
/**
|
|
375
|
+
* Opaque snapshot persisted to `interrupt_reason` for the
|
|
376
|
+
* timeout-classified turn so a later boot can rebuild the watchdog
|
|
377
|
+
* report after the marker has been swept.
|
|
378
|
+
*/
|
|
379
|
+
reasonSnapshot?: string | null
|
|
380
|
+
/** Injectable clock for tests. */
|
|
381
|
+
now?: number
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface OrphanClassifyResult {
|
|
385
|
+
/** Total rows stamped (timeout + restart). */
|
|
386
|
+
reaped: number
|
|
387
|
+
/** turn_key stamped 'timeout', or null if none qualified as a hang. */
|
|
388
|
+
timeoutTurnKey: string | null
|
|
389
|
+
}
|
|
390
|
+
|
|
330
391
|
/**
|
|
331
|
-
* Boot-time reaper. Sweeps ALL turns
|
|
332
|
-
*
|
|
333
|
-
*
|
|
392
|
+
* Boot-time reaper + classifier. Sweeps ALL turns with `ended_at IS NULL`
|
|
393
|
+
* (killed mid-flight: SIGKILL / OOM / hard reboot / operator restart) and
|
|
394
|
+
* stamps an `ended_via`:
|
|
334
395
|
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
396
|
+
* - the in-flight turn (matched by `markerTurnKey`) is stamped
|
|
397
|
+
* `'timeout'` IFF its marker is older than `hangThresholdMs` — it
|
|
398
|
+
* stalled with no tool progress for the full watchdog window, so it's
|
|
399
|
+
* reported-not-resumed; its `interrupt_reason` carries `reasonSnapshot`.
|
|
400
|
+
* - every other open turn (and the in-flight one when it was making
|
|
401
|
+
* progress) is stamped `'restart'` — a clean interrupt, eligible for
|
|
402
|
+
* blanket resume.
|
|
338
403
|
*
|
|
339
|
-
*
|
|
404
|
+
* Call this once immediately after `openTurnsDb`, BEFORE any new turns are
|
|
405
|
+
* recorded for the current boot, and BEFORE the turn-active marker is
|
|
406
|
+
* swept (the classifier needs the marker's mtime).
|
|
340
407
|
*/
|
|
341
|
-
export function
|
|
342
|
-
|
|
343
|
-
|
|
408
|
+
export function markOrphanedWithTimeoutClassification(
|
|
409
|
+
db: SqliteDatabase,
|
|
410
|
+
opts: OrphanClassifyOpts,
|
|
411
|
+
): OrphanClassifyResult {
|
|
412
|
+
const now = opts.now ?? Date.now()
|
|
413
|
+
const isHang =
|
|
414
|
+
opts.markerAgeMs != null &&
|
|
415
|
+
opts.markerAgeMs >= opts.hangThresholdMs &&
|
|
416
|
+
opts.markerTurnKey != null &&
|
|
417
|
+
opts.markerTurnKey.length > 0
|
|
418
|
+
|
|
419
|
+
let timeoutTurnKey: string | null = null
|
|
420
|
+
if (isHang) {
|
|
421
|
+
const r = db.prepare(`
|
|
422
|
+
UPDATE turns
|
|
423
|
+
SET ended_at = ?,
|
|
424
|
+
ended_via = 'timeout',
|
|
425
|
+
interrupt_reason = ?,
|
|
426
|
+
updated_at = ?
|
|
427
|
+
WHERE turn_key = ? AND ended_at IS NULL
|
|
428
|
+
`).run(now, opts.reasonSnapshot ?? null, now, opts.markerTurnKey) as { changes: number }
|
|
429
|
+
if (r.changes > 0) timeoutTurnKey = opts.markerTurnKey ?? null
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const rest = db.prepare(`
|
|
344
433
|
UPDATE turns
|
|
345
434
|
SET ended_at = ?,
|
|
346
435
|
ended_via = 'restart',
|
|
347
436
|
updated_at = ?
|
|
348
437
|
WHERE ended_at IS NULL
|
|
349
438
|
`).run(now, now) as { changes: number }
|
|
350
|
-
|
|
439
|
+
|
|
440
|
+
return { reaped: (timeoutTurnKey ? 1 : 0) + rest.changes, timeoutTurnKey }
|
|
351
441
|
}
|
|
352
442
|
|
|
353
443
|
/**
|
|
@@ -392,26 +482,41 @@ export function listTurnsForAgent(
|
|
|
392
482
|
return rows.map(mapRow)
|
|
393
483
|
}
|
|
394
484
|
|
|
485
|
+
/** ended_via values that mean "this turn did not finish on its own". */
|
|
486
|
+
const INTERRUPTED_VIA: ReadonlySet<TurnEndedVia> = new Set<TurnEndedVia>([
|
|
487
|
+
'restart',
|
|
488
|
+
'sigterm',
|
|
489
|
+
'timeout',
|
|
490
|
+
'unknown',
|
|
491
|
+
])
|
|
492
|
+
|
|
395
493
|
/**
|
|
396
|
-
*
|
|
397
|
-
* (`
|
|
398
|
-
*
|
|
399
|
-
*
|
|
494
|
+
* Return the single most-recently-started turn IFF it was interrupted
|
|
495
|
+
* (`ended_at IS NULL`, or `ended_via` in {restart, sigterm, timeout,
|
|
496
|
+
* unknown}). Returns null when the latest turn ended cleanly (`'stop'`)
|
|
497
|
+
* or there are no turns at all.
|
|
400
498
|
*
|
|
401
|
-
*
|
|
499
|
+
* This is the resume gate. Keying on the *latest* turn (not "latest
|
|
500
|
+
* interrupted turn anywhere in history") is deliberate: once the agent
|
|
501
|
+
* resumes and that follow-up turn ends `'stop'`, the latest turn is clean
|
|
502
|
+
* and this returns null — so a completed resume is never re-fired on the
|
|
503
|
+
* next restart. The older `findMostRecentInterruptedTurn` had the inverse
|
|
504
|
+
* bug: a clean latest turn didn't shadow a stale interrupted one, so it
|
|
505
|
+
* would resurface already-handled work indefinitely.
|
|
402
506
|
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
* the user remembers, and that's `started_at`.
|
|
507
|
+
* Ordering uses `started_at DESC` (not `updated_at`) so the boot reaper,
|
|
508
|
+
* which mass-stamps orphans with identical timestamps, can't reorder the
|
|
509
|
+
* temporal "last turn" the user actually remembers.
|
|
407
510
|
*/
|
|
408
|
-
export function
|
|
511
|
+
export function findLatestTurnIfInterrupted(db: SqliteDatabase): Turn | null {
|
|
409
512
|
const row = db.prepare(`
|
|
410
513
|
SELECT * FROM turns
|
|
411
|
-
WHERE ended_at IS NULL
|
|
412
|
-
OR ended_via IN ('restart', 'sigterm', 'timeout')
|
|
413
514
|
ORDER BY started_at DESC
|
|
414
515
|
LIMIT 1
|
|
415
516
|
`).get() as RawTurnRow | undefined
|
|
416
|
-
|
|
517
|
+
if (!row) return null
|
|
518
|
+
const turn = mapRow(row)
|
|
519
|
+
if (turn.ended_at == null) return turn
|
|
520
|
+
if (turn.ended_via != null && INTERRUPTED_VIA.has(turn.ended_via)) return turn
|
|
521
|
+
return null
|
|
417
522
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Contract:
|
|
10
10
|
* - First call for a chat+thread: creates a stream via
|
|
11
|
-
* createStreamController
|
|
11
|
+
* createStreamController.
|
|
12
12
|
* - Subsequent calls: reuse the existing stream, push the new text.
|
|
13
13
|
* - `done=true`: finalize, delete the map entry, fire status-reaction
|
|
14
14
|
* completion, and (if history enabled) record the final message.
|
|
@@ -171,8 +171,6 @@ export interface StreamReplyDeps {
|
|
|
171
171
|
escapeMarkdownV2: (text: string) => string
|
|
172
172
|
/** Whitespace repair applied to the raw caller text. */
|
|
173
173
|
repairEscapedWhitespace: (text: string) => string
|
|
174
|
-
/** Resolves the handoff prefix for a first-chunk stream. Empty string if none. */
|
|
175
|
-
takeHandoffPrefix: (format: 'html' | 'markdownv2' | 'text') => string
|
|
176
174
|
/** Validates the chat id against the access list. Throws on deny. */
|
|
177
175
|
assertAllowedChat: (chatId: string) => void
|
|
178
176
|
/** Resolves the effective thread id (explicit, last-inbound, or undefined). */
|
|
@@ -445,14 +443,6 @@ export async function handleStreamReply(
|
|
|
445
443
|
streamExisted,
|
|
446
444
|
})
|
|
447
445
|
|
|
448
|
-
// First chunk of a session: consume any pending handoff prefix.
|
|
449
|
-
if (!stream) {
|
|
450
|
-
const prefix = deps.takeHandoffPrefix(
|
|
451
|
-
format === 'html' ? 'html' : format === 'markdownv2' ? 'markdownv2' : 'text',
|
|
452
|
-
)
|
|
453
|
-
if (prefix.length > 0) effectiveText = prefix + effectiveText
|
|
454
|
-
}
|
|
455
|
-
|
|
456
446
|
if (!stream) {
|
|
457
447
|
// Resolve the effective quote-reply target. Explicit `reply_to` wins;
|
|
458
448
|
// otherwise (unless the caller opted out with `quote:false`) fall back
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { resolveAgentDirFromEnv } from "../agent-dir.js";
|
|
3
|
+
|
|
4
|
+
describe("resolveAgentDirFromEnv", () => {
|
|
5
|
+
const prior = process.env.TELEGRAM_STATE_DIR;
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
if (prior === undefined) delete process.env.TELEGRAM_STATE_DIR;
|
|
8
|
+
else process.env.TELEGRAM_STATE_DIR = prior;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns dirname of TELEGRAM_STATE_DIR", () => {
|
|
12
|
+
process.env.TELEGRAM_STATE_DIR = "/foo/bar/agent/telegram";
|
|
13
|
+
expect(resolveAgentDirFromEnv()).toBe("/foo/bar/agent");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns null when env unset", () => {
|
|
17
|
+
delete process.env.TELEGRAM_STATE_DIR;
|
|
18
|
+
expect(resolveAgentDirFromEnv()).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns null when env is empty string", () => {
|
|
22
|
+
process.env.TELEGRAM_STATE_DIR = " ";
|
|
23
|
+
expect(resolveAgentDirFromEnv()).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -2,22 +2,40 @@
|
|
|
2
2
|
* Unit tests for the A2 multi-attachment helpers
|
|
3
3
|
* (telegram-plugin/gateway/coalesce-attachments.ts).
|
|
4
4
|
*
|
|
5
|
-
* These pin the
|
|
5
|
+
* These pin the pure pieces of the multi-attachment fold-in that live
|
|
6
6
|
* outside gateway.ts so they can be exercised without loadAccess()/IPC:
|
|
7
|
-
* 1.
|
|
8
|
-
* 2.
|
|
7
|
+
* 1. resolveCoalesceMaxAttachments — the runtime cap default (10).
|
|
8
|
+
* 2. splitCoalescedAttachments — primary + capped extras, arrival order.
|
|
9
|
+
* 3. buildExtraAttachmentMeta — numbered meta fields starting at _2.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* A cap of 1 reproduces the historical single-attachment shape: primary
|
|
12
|
+
* only, no extras, no numbered meta.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { describe, expect, it } from 'vitest'
|
|
15
16
|
import {
|
|
16
17
|
splitCoalescedAttachments,
|
|
17
18
|
buildExtraAttachmentMeta,
|
|
19
|
+
resolveCoalesceMaxAttachments,
|
|
20
|
+
DEFAULT_MAX_ATTACHMENTS,
|
|
18
21
|
type ResolvedExtraAttachment,
|
|
19
22
|
} from '../gateway/coalesce-attachments.js'
|
|
20
23
|
|
|
24
|
+
describe('resolveCoalesceMaxAttachments (default 10 = full album)', () => {
|
|
25
|
+
it('defaults to 10 when unset', () => {
|
|
26
|
+
expect(resolveCoalesceMaxAttachments(undefined)).toBe(10)
|
|
27
|
+
expect(DEFAULT_MAX_ATTACHMENTS).toBe(10)
|
|
28
|
+
})
|
|
29
|
+
it('honours an explicit operator cap', () => {
|
|
30
|
+
expect(resolveCoalesceMaxAttachments(1)).toBe(1)
|
|
31
|
+
expect(resolveCoalesceMaxAttachments(25)).toBe(25)
|
|
32
|
+
})
|
|
33
|
+
it('floors a 0 / negative cap at 1 (never strips the only attachment)', () => {
|
|
34
|
+
expect(resolveCoalesceMaxAttachments(0)).toBe(1)
|
|
35
|
+
expect(resolveCoalesceMaxAttachments(-5)).toBe(1)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
21
39
|
interface Entry {
|
|
22
40
|
text: string
|
|
23
41
|
att?: string
|
|
@@ -26,7 +44,7 @@ interface Entry {
|
|
|
26
44
|
const has = (e: Entry): boolean => e.att != null
|
|
27
45
|
|
|
28
46
|
describe('splitCoalescedAttachments', () => {
|
|
29
|
-
it('
|
|
47
|
+
it('cap 1: keeps only the first attachment as primary, no extras', () => {
|
|
30
48
|
const entries: Entry[] = [
|
|
31
49
|
{ text: 'a', att: 'photo-1' },
|
|
32
50
|
{ text: 'b', att: 'photo-2' },
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* of this test file and brittle w.r.t. upstream churn.
|
|
11
11
|
*
|
|
12
12
|
* Instead, following the existing project convention
|
|
13
|
-
* (see steering.test.ts
|
|
13
|
+
* (see steering.test.ts), we exercise each
|
|
14
14
|
* specified scenario through the same pure helper modules that server.ts
|
|
15
15
|
* calls. Where a scenario lives inside server.ts's in-memory state
|
|
16
16
|
* (activeTurnStartedAt, activeStatusReactions, suppressPtyPreview), we
|
|
@@ -18,23 +18,13 @@
|
|
|
18
18
|
* server.ts uses. The helpers and the state shape are the contract —
|
|
19
19
|
* if they don't regress, the integrated behaviour doesn't regress.
|
|
20
20
|
*/
|
|
21
|
-
import { describe, it, expect, beforeEach, afterEach
|
|
22
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
23
|
-
import { tmpdir } from 'node:os'
|
|
24
|
-
import { join } from 'node:path'
|
|
21
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
25
22
|
|
|
26
23
|
import {
|
|
27
24
|
parseQueuePrefix,
|
|
28
25
|
formatPriorAssistantPreview,
|
|
29
26
|
buildChannelMetaAttributes,
|
|
30
27
|
} from '../steering.js'
|
|
31
|
-
import {
|
|
32
|
-
consumeHandoffTopic,
|
|
33
|
-
readHandoffTopic,
|
|
34
|
-
formatHandoffLine,
|
|
35
|
-
shouldShowHandoffLine,
|
|
36
|
-
HANDOFF_TOPIC_FILENAME,
|
|
37
|
-
} from '../handoff-continuity.js'
|
|
38
28
|
import {
|
|
39
29
|
isContextExhaustionText,
|
|
40
30
|
shouldArmOrphanedReplyTimeout,
|
|
@@ -64,7 +54,6 @@ interface PluginState {
|
|
|
64
54
|
currentSessionChatId: string | null
|
|
65
55
|
currentSessionThreadId: number | undefined
|
|
66
56
|
currentTurnStartedAt: number
|
|
67
|
-
handoffTopicUsed: boolean
|
|
68
57
|
}
|
|
69
58
|
|
|
70
59
|
function freshState(): PluginState {
|
|
@@ -75,7 +64,6 @@ function freshState(): PluginState {
|
|
|
75
64
|
currentSessionChatId: null,
|
|
76
65
|
currentSessionThreadId: undefined,
|
|
77
66
|
currentTurnStartedAt: 0,
|
|
78
|
-
handoffTopicUsed: false,
|
|
79
67
|
}
|
|
80
68
|
}
|
|
81
69
|
|
|
@@ -271,69 +259,6 @@ describe('E2E: turn lifecycle cleanup', () => {
|
|
|
271
259
|
})
|
|
272
260
|
})
|
|
273
261
|
|
|
274
|
-
// ---------------------------------------------------------------------------
|
|
275
|
-
// Handoff continuity
|
|
276
|
-
// ---------------------------------------------------------------------------
|
|
277
|
-
|
|
278
|
-
describe('E2E: handoff continuity', () => {
|
|
279
|
-
let tmp: string
|
|
280
|
-
const priorEnv = { ...process.env }
|
|
281
|
-
|
|
282
|
-
beforeEach(() => {
|
|
283
|
-
tmp = mkdtempSync(join(tmpdir(), 'handoff-e2e-'))
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
afterEach(() => {
|
|
287
|
-
rmSync(tmp, { recursive: true, force: true })
|
|
288
|
-
process.env = { ...priorEnv }
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
it('bootstrap with sidecar + show-line=true → first reply prepends the line', () => {
|
|
292
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 'shipped the feature\n', 'utf8')
|
|
293
|
-
process.env.SWITCHROOM_HANDOFF_SHOW_LINE = 'true'
|
|
294
|
-
expect(shouldShowHandoffLine()).toBe(true)
|
|
295
|
-
const topic = consumeHandoffTopic(tmp)
|
|
296
|
-
expect(topic).toBe('shipped the feature')
|
|
297
|
-
const line = formatHandoffLine(topic!, 'html')
|
|
298
|
-
expect(line).toContain('shipped the feature')
|
|
299
|
-
expect(line).toMatch(/^<i>/)
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
it('bootstrap with sidecar + show-line=false → no prefix', () => {
|
|
303
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 'x\n', 'utf8')
|
|
304
|
-
process.env.SWITCHROOM_HANDOFF_SHOW_LINE = 'false'
|
|
305
|
-
expect(shouldShowHandoffLine()).toBe(false)
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
it('bootstrap with no sidecar → no prefix', () => {
|
|
309
|
-
expect(readHandoffTopic(tmp)).toBeNull()
|
|
310
|
-
expect(consumeHandoffTopic(tmp)).toBeNull()
|
|
311
|
-
})
|
|
312
|
-
|
|
313
|
-
it('consuming topic is one-shot — second call returns null + sidecar deleted', () => {
|
|
314
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 'topic\n', 'utf8')
|
|
315
|
-
expect(consumeHandoffTopic(tmp)).toBe('topic')
|
|
316
|
-
expect(existsSync(join(tmp, HANDOFF_TOPIC_FILENAME))).toBe(false)
|
|
317
|
-
expect(consumeHandoffTopic(tmp)).toBeNull()
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
it('stream_reply: once topic consumed, subsequent stream chunks do not re-prefix', () => {
|
|
321
|
-
// Model: the plugin tracks handoffTopicUsed after first reply/stream_reply
|
|
322
|
-
// use. The second and later stream edits on the same stream read the flag
|
|
323
|
-
// and skip prepending.
|
|
324
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), 't\n', 'utf8')
|
|
325
|
-
const s = freshState()
|
|
326
|
-
expect(s.handoffTopicUsed).toBe(false)
|
|
327
|
-
// first chunk
|
|
328
|
-
const topic = consumeHandoffTopic(tmp)
|
|
329
|
-
expect(topic).toBe('t')
|
|
330
|
-
s.handoffTopicUsed = true
|
|
331
|
-
// simulate next chunk arriving — should not consume
|
|
332
|
-
expect(consumeHandoffTopic(tmp)).toBeNull()
|
|
333
|
-
expect(s.handoffTopicUsed).toBe(true)
|
|
334
|
-
})
|
|
335
|
-
})
|
|
336
|
-
|
|
337
262
|
// ---------------------------------------------------------------------------
|
|
338
263
|
// Context exhaustion
|
|
339
264
|
// ---------------------------------------------------------------------------
|