Skip to content

Commit b669257

Browse files
fix(channel): survive agent restart — reconcile channels after S3 pull (#7)
Chat-configured Telegram bots died on every restart: channel configs were resolved from local disk at construction time, BEFORE restoreState() pulled data/channels/* from S3, so on a fresh container fs the file-sourced config was invisible and polling never started (channel_list then showed source: file, connected: false). - ChannelModule.reconcileFromDisk(): re-resolves file/env configs after the S3 pull and diffs against the registered set — registers missing channels, replaces on token change, deregisters on tombstone. Wired into connectChannels() before service.start(); mock harnesses untouched. - removeChannel() writes a tombstone ({removed: true}) instead of deleting the file — the pod env keeps the old token until the next redeploy, and a missing file would let the env fallback resurrect a removed channel. resolveBootConfigs()/listInfo() honor the tombstone; a groups-only file (env-configured bot) still falls through to env. - Runtime now writes data/channels/status.json (connected/error/updatedAt per channel) on start results, channel set/replace, and removal — the platform merges it into GET /agents/:id/channels so the admin UI shows live state instead of guessing from config presence. - ChannelService.start() returns per-channel outcomes (allSettled isolation kept) so failures carry a reason instead of stopping at logs. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8db7b34 commit b669257

10 files changed

Lines changed: 413 additions & 22 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cleanslice/runtime",
3-
"version": "0.27.1",
3+
"version": "0.28.0",
44
"license": "MIT",
55
"type": "module",
66
"bin": {

src/slices/runtime/runtime/runtime.module.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,16 @@ export class AgentRuntime {
305305
this.session.setActivityReporter({
306306
report: activity => this.channel.reportSessionActivity(activity),
307307
})
308+
// Channel configs were resolved before restoreState() pulled S3 state —
309+
// on a fresh container fs the per-channel files weren't on disk yet, so
310+
// file-configured channels (chat-configured Telegram) resolved to nothing
311+
// and stayed dead after every restart. Reconcile against the pulled state
312+
// before starting; a reconcile failure degrades to the pre-pull set.
313+
try {
314+
await this.channel.reconcileFromDisk()
315+
} catch (err) {
316+
s3Log.warn(`channel reconcile after pull failed — starting with boot-time channel set. Error: ${(err as Error).message}`)
317+
}
308318
await this.channel.start()
309319
}
310320

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2+
import { mkdtempSync, mkdirSync } from "fs"
3+
import { tmpdir } from "os"
4+
import { join } from "path"
5+
import { ChannelModule } from "./channel.module"
6+
import { saveTelegramFile } from "./data/repositories/telegram/telegramFile"
7+
import type { IChannelGateway } from "./domain/channel.gateway"
8+
9+
function agentDir(): string {
10+
const dir = mkdtempSync(join(tmpdir(), "channel-module-"))
11+
mkdirSync(join(dir, "data"), { recursive: true })
12+
return dir
13+
}
14+
15+
function mockGateway(name = "mock"): IChannelGateway {
16+
return {
17+
name,
18+
start: () => Promise.resolve(),
19+
stop: () => Promise.resolve(),
20+
send: () => Promise.resolve(),
21+
onMessage: () => {},
22+
}
23+
}
24+
25+
// resolveBootConfigs / reconcileFromDisk mutate the env mirror — isolate it.
26+
const ENV_KEYS = [
27+
"TELEGRAM_BOT_TOKEN", "TELEGRAM_BOT_NAME", "TELEGRAM_BOT_ADMIN_IDS",
28+
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "BRIDLE_URL",
29+
] as const
30+
let envSnapshot: Record<string, string | undefined>
31+
32+
beforeEach(() => {
33+
envSnapshot = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
34+
for (const k of ENV_KEYS) delete process.env[k]
35+
})
36+
37+
afterEach(() => {
38+
for (const k of ENV_KEYS) {
39+
if (envSnapshot[k] === undefined) delete process.env[k]
40+
else process.env[k] = envSnapshot[k]
41+
}
42+
})
43+
44+
describe("resolveBootConfigs", () => {
45+
test("file with credentials wins over env", async () => {
46+
const dir = agentDir()
47+
await saveTelegramFile(dir, { botToken: "file:token" })
48+
process.env.TELEGRAM_BOT_TOKEN = "env:token"
49+
50+
const configs = await ChannelModule.resolveBootConfigs(dir)
51+
52+
const telegram = configs.find(c => c.type === "telegram")
53+
expect(telegram).toEqual({ type: "telegram", token: "file:token" })
54+
expect(process.env.TELEGRAM_BOT_TOKEN).toBe("file:token")
55+
})
56+
57+
test("tombstone (removed: true) suppresses the env fallback", async () => {
58+
const dir = agentDir()
59+
await saveTelegramFile(dir, { removed: true })
60+
process.env.TELEGRAM_BOT_TOKEN = "env:token"
61+
62+
const configs = await ChannelModule.resolveBootConfigs(dir)
63+
64+
expect(configs.find(c => c.type === "telegram")).toBeUndefined()
65+
})
66+
67+
test("groups-only file (env-configured bot) keeps the env fallback", async () => {
68+
const dir = agentDir()
69+
// The group tracker persists groups into telegram.json even when the bot
70+
// config came from env — that file is NOT a tombstone.
71+
await saveTelegramFile(dir, {
72+
groups: { "-100": { id: "-100", type: "group", status: "member", addedAt: 1, lastSeenAt: 2 } },
73+
})
74+
process.env.TELEGRAM_BOT_TOKEN = "env:token"
75+
76+
const configs = await ChannelModule.resolveBootConfigs(dir)
77+
78+
expect(configs.find(c => c.type === "telegram")).toEqual({ type: "telegram", token: "env:token" })
79+
})
80+
81+
test("no file and no env → no telegram config", async () => {
82+
const dir = agentDir()
83+
const configs = await ChannelModule.resolveBootConfigs(dir)
84+
expect(configs.find(c => c.type === "telegram")).toBeUndefined()
85+
})
86+
})
87+
88+
describe("reconcileFromDisk", () => {
89+
test("config file that appeared after construction registers the channel", async () => {
90+
const dir = agentDir()
91+
// Simulates the k8s boot race: empty disk at construction, S3 pull lands
92+
// telegram.json before connectChannels().
93+
const module = new ChannelModule([], dir)
94+
await saveTelegramFile(dir, { botToken: "123:pulled" })
95+
96+
await module.reconcileFromDisk()
97+
98+
const info = await module.listInfo()
99+
const telegram = info.find(i => i.type === "telegram")
100+
expect(telegram?.source).toBe("file")
101+
expect(telegram?.connected).toBe(true) // registered in the service (starts with service.start())
102+
})
103+
104+
test("token change on disk replaces the registered gateway", async () => {
105+
const dir = agentDir()
106+
const module = new ChannelModule([{ type: "telegram", token: "env:old" }], dir)
107+
await saveTelegramFile(dir, { botToken: "file:new" })
108+
109+
await module.reconcileFromDisk()
110+
111+
expect(process.env.TELEGRAM_BOT_TOKEN).toBe("file:new")
112+
const telegram = (await module.listInfo()).find(i => i.type === "telegram")
113+
expect(telegram?.connected).toBe(true)
114+
})
115+
116+
test("unchanged config is left alone", async () => {
117+
const dir = agentDir()
118+
await saveTelegramFile(dir, { botToken: "123:same" })
119+
const module = new ChannelModule(
120+
await ChannelModule.resolveBootConfigs(dir),
121+
dir,
122+
)
123+
124+
await module.reconcileFromDisk()
125+
126+
const telegram = (await module.listInfo()).find(i => i.type === "telegram")
127+
expect(telegram?.connected).toBe(true)
128+
})
129+
130+
test("tombstone deregisters a previously registered channel", async () => {
131+
const dir = agentDir()
132+
const module = new ChannelModule([{ type: "telegram", token: "env:old" }], dir)
133+
await saveTelegramFile(dir, { removed: true })
134+
process.env.TELEGRAM_BOT_TOKEN = "env:old" // pod env survives until redeploy
135+
136+
await module.reconcileFromDisk()
137+
138+
expect((await module.listInfo()).find(i => i.type === "telegram")).toBeUndefined()
139+
expect(module.send("telegram", "1", "hi")).rejects.toThrow("Channel not found")
140+
})
141+
142+
test("mock-only setups are untouched (paddock/test harness)", async () => {
143+
const dir = agentDir()
144+
const module = new ChannelModule([{ type: "mock", instance: mockGateway() }], dir)
145+
await saveTelegramFile(dir, { botToken: "123:abc" })
146+
147+
await module.reconcileFromDisk()
148+
149+
expect(module.send("telegram", "1", "hi")).rejects.toThrow("Channel not found")
150+
})
151+
152+
test("no agentDir → no-op", async () => {
153+
const module = new ChannelModule([])
154+
await module.reconcileFromDisk() // must not throw
155+
})
156+
})

0 commit comments

Comments
 (0)