kimaki 0.29.0 → 0.30.0
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-model.e2e.test.js +33 -33
- package/dist/anthropic-account-identity.js +1 -1
- package/dist/anthropic-auth-plugin.js +5 -5
- package/dist/anthropic-auth-plugin.test.js +30 -2
- package/dist/anthropic-auth-state.js +1 -1
- package/dist/bin.js +7 -3
- package/dist/bot-lifecycle.test.js +206 -0
- package/dist/cache-drift-plugin.js +25 -18
- package/dist/cache-rewrite.js +39 -0
- package/dist/cache-rewrite.test.js +41 -0
- package/dist/cli-commands/session.js +37 -6
- package/dist/cli-parsing.test.js +3 -3
- package/dist/cli-runner.js +11 -1
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +4 -4
- package/dist/commands/ask-question.js +72 -23
- package/dist/commands/queue.js +7 -7
- package/dist/database.js +73 -6
- package/dist/db.test.js +104 -1
- package/dist/discord-bot.js +212 -144
- package/dist/external-directory-permissions.e2e.test.js +3 -3
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/forum-sync/watchers.js +15 -0
- package/dist/gateway-proxy.e2e.test.js +9 -9
- package/dist/hrana-server.js +68 -14
- package/dist/markdown.js +164 -102
- package/dist/markdown.test.js +88 -20
- package/dist/message-formatting.js +20 -2
- package/dist/message-formatting.test.js +20 -1
- package/dist/opencode.js +27 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-resume.e2e.test.js +100 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/question-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +3 -3
- package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
- package/dist/queue-advanced-e2e-setup.js +63 -0
- package/dist/queue-advanced-footer.e2e.test.js +18 -18
- package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
- package/dist/queue-delete-message.e2e.test.js +4 -4
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
- package/dist/queue-interrupt-drain.e2e.test.js +10 -10
- package/dist/queue-question-select-drain.e2e.test.js +5 -5
- package/dist/runtime-lifecycle.e2e.test.js +95 -11
- package/dist/session-handler/event-stream-state.js +187 -1
- package/dist/session-handler/event-stream-state.test.js +764 -1
- package/dist/session-handler/thread-session-runtime.js +185 -16
- package/dist/sleep.e2e.test.js +7 -7
- package/dist/store.js +1 -1
- package/dist/system-message.js +9 -10
- package/dist/system-message.test.js +19 -10
- package/dist/task-runner.js +7 -0
- package/dist/task-schedule.test.js +1 -1
- package/dist/test-utils.js +5 -3
- package/dist/thread-message-queue.e2e.test.js +30 -31
- package/dist/undo-redo.e2e.test.js +3 -3
- package/dist/voice-message.e2e.test.js +64 -26
- package/dist/voice-routing-input.e2e.test.js +4 -4
- package/dist/voice-routing-question.e2e.test.js +4 -4
- package/dist/voice.js +107 -21
- package/dist/voice.test.js +52 -1
- package/dist/wait-session.js +24 -2
- package/dist/worktree-lifecycle.e2e.test.js +8 -8
- package/package.json +5 -5
- package/skills/egaki/SKILL.md +14 -0
- package/skills/holocron/SKILL.md +125 -42
- package/skills/opensrc/SKILL.md +20 -3
- package/skills/spiceflow/SKILL.md +53 -172
- package/src/agent-model.e2e.test.ts +33 -33
- package/src/anthropic-account-identity.ts +1 -1
- package/src/anthropic-auth-plugin.test.ts +37 -2
- package/src/anthropic-auth-plugin.ts +9 -7
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bin.ts +7 -3
- package/src/bot-lifecycle.test.ts +252 -0
- package/src/cache-drift-plugin.ts +34 -24
- package/src/cache-rewrite.test.ts +49 -0
- package/src/cache-rewrite.ts +96 -0
- package/src/cli-commands/session.ts +48 -8
- package/src/cli-parsing.test.ts +3 -3
- package/src/cli-runner.ts +11 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +6 -6
- package/src/commands/ask-question.ts +88 -25
- package/src/commands/queue.ts +7 -7
- package/src/database.ts +105 -6
- package/src/db.test.ts +124 -0
- package/src/discord-bot.ts +261 -183
- package/src/external-directory-permissions.e2e.test.ts +3 -3
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/forum-sync/watchers.ts +20 -0
- package/src/gateway-proxy.e2e.test.ts +9 -9
- package/src/hrana-server.ts +96 -16
- package/src/markdown.test.ts +108 -22
- package/src/markdown.ts +216 -103
- package/src/message-formatting.test.ts +24 -1
- package/src/message-formatting.ts +21 -2
- package/src/opencode.ts +28 -13
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-resume.e2e.test.ts +121 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/question-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +3 -3
- package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
- package/src/queue-advanced-e2e-setup.ts +65 -0
- package/src/queue-advanced-footer.e2e.test.ts +18 -18
- package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
- package/src/queue-delete-message.e2e.test.ts +4 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
- package/src/queue-interrupt-drain.e2e.test.ts +10 -10
- package/src/queue-question-select-drain.e2e.test.ts +5 -5
- package/src/runtime-lifecycle.e2e.test.ts +104 -10
- package/src/session-handler/event-stream-state.test.ts +825 -0
- package/src/session-handler/event-stream-state.ts +265 -3
- package/src/session-handler/thread-session-runtime.ts +217 -14
- package/src/sleep.e2e.test.ts +7 -7
- package/src/store.ts +2 -2
- package/src/system-message.test.ts +20 -10
- package/src/system-message.ts +9 -10
- package/src/task-runner.ts +8 -0
- package/src/task-schedule.test.ts +1 -1
- package/src/test-utils.ts +5 -3
- package/src/thread-message-queue.e2e.test.ts +30 -31
- package/src/undo-redo.e2e.test.ts +3 -3
- package/src/voice-message.e2e.test.ts +70 -26
- package/src/voice-routing-input.e2e.test.ts +4 -4
- package/src/voice-routing-question.e2e.test.ts +4 -4
- package/src/voice.test.ts +56 -0
- package/src/voice.ts +135 -25
- package/src/wait-session.ts +25 -2
- package/src/worktree-lifecycle.e2e.test.ts +8 -8
package/dist/db.test.js
CHANGED
|
@@ -7,7 +7,7 @@ import { afterAll, describe, expect, test } from 'vitest';
|
|
|
7
7
|
import { closeDb, getDb } from './db.js';
|
|
8
8
|
import * as orm from 'drizzle-orm';
|
|
9
9
|
import * as schema from './schema.js';
|
|
10
|
-
import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, deleteThreadQueueItem, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setSessionModel, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
|
|
10
|
+
import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
|
|
11
11
|
import { createClient } from '@libsql/client';
|
|
12
12
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
13
13
|
import { chooseLockPort } from './test-utils.js';
|
|
@@ -60,6 +60,109 @@ describe('getDb', () => {
|
|
|
60
60
|
expect(JSON.parse(remaining[0].payload_json).prompt).toBe('first-edited');
|
|
61
61
|
await db.delete(schema.thread_queue_items).where(orm.eq(schema.thread_queue_items.thread_id, threadId));
|
|
62
62
|
});
|
|
63
|
+
test('cancels channel tasks before deleting a channel mapping', async () => {
|
|
64
|
+
const channelId = `channel-${crypto.randomUUID()}`;
|
|
65
|
+
await setChannelDirectory({
|
|
66
|
+
channelId,
|
|
67
|
+
directory: `/tmp/${channelId}`,
|
|
68
|
+
channelType: 'text',
|
|
69
|
+
});
|
|
70
|
+
await setChannelVerbosity(channelId, 'text_only');
|
|
71
|
+
const taskId = await createScheduledTask({
|
|
72
|
+
scheduleKind: 'at',
|
|
73
|
+
nextRunAt: new Date('2099-01-01T00:00:00.000Z'),
|
|
74
|
+
payloadJson: JSON.stringify({ kind: 'channel', channelId, prompt: 'later' }),
|
|
75
|
+
promptPreview: 'later',
|
|
76
|
+
channelId,
|
|
77
|
+
});
|
|
78
|
+
expect(await deleteChannelDirectoryById(channelId)).toBe(true);
|
|
79
|
+
const db = await getDb();
|
|
80
|
+
expect({
|
|
81
|
+
mapping: await getChannelDirectory(channelId),
|
|
82
|
+
task: await getScheduledTask(taskId),
|
|
83
|
+
verbosity: await db.query.channel_verbosity.findFirst({
|
|
84
|
+
where: { channel_id: channelId },
|
|
85
|
+
}),
|
|
86
|
+
}).toMatchObject({
|
|
87
|
+
mapping: undefined,
|
|
88
|
+
task: {
|
|
89
|
+
status: 'cancelled',
|
|
90
|
+
last_error: 'Discord channel was deleted',
|
|
91
|
+
},
|
|
92
|
+
verbosity: undefined,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
test('cleans pending work when a Discord thread is deleted', async () => {
|
|
96
|
+
const threadId = `thread-${crypto.randomUUID()}`;
|
|
97
|
+
const sessionId = `session-${crypto.randomUUID()}`;
|
|
98
|
+
const queueId = `queue-${crypto.randomUUID()}`;
|
|
99
|
+
await setThreadSession(threadId, sessionId);
|
|
100
|
+
await insertThreadQueueItem({
|
|
101
|
+
queueId,
|
|
102
|
+
threadId,
|
|
103
|
+
payloadJson: JSON.stringify({ prompt: 'queued' }),
|
|
104
|
+
});
|
|
105
|
+
await upsertSessionSleep({
|
|
106
|
+
sessionId,
|
|
107
|
+
wakeAt: new Date('2099-01-01T00:00:00.000Z'),
|
|
108
|
+
reason: 'later',
|
|
109
|
+
});
|
|
110
|
+
const ipcRequest = await createIpcRequest({
|
|
111
|
+
type: 'file_upload',
|
|
112
|
+
sessionId,
|
|
113
|
+
threadId,
|
|
114
|
+
payload: '{}',
|
|
115
|
+
});
|
|
116
|
+
const taskId = await createScheduledTask({
|
|
117
|
+
scheduleKind: 'at',
|
|
118
|
+
nextRunAt: new Date('2099-01-01T00:00:00.000Z'),
|
|
119
|
+
payloadJson: JSON.stringify({ kind: 'thread', threadId, prompt: 'later' }),
|
|
120
|
+
promptPreview: 'later',
|
|
121
|
+
threadId,
|
|
122
|
+
sessionId,
|
|
123
|
+
});
|
|
124
|
+
await cleanupDeletedThread(threadId);
|
|
125
|
+
await completeIpcRequest({ id: ipcRequest.id, response: 'late response' });
|
|
126
|
+
expect({
|
|
127
|
+
ipc: await getIpcRequestById({ id: ipcRequest.id }),
|
|
128
|
+
queue: await listThreadQueueItems(threadId),
|
|
129
|
+
session: await getThreadSession(threadId),
|
|
130
|
+
sleep: await getSessionSleep({ sessionId }),
|
|
131
|
+
task: await getScheduledTask(taskId),
|
|
132
|
+
}).toMatchObject({
|
|
133
|
+
ipc: { status: 'cancelled' },
|
|
134
|
+
queue: [],
|
|
135
|
+
session: sessionId,
|
|
136
|
+
sleep: { status: 'cancelled' },
|
|
137
|
+
task: {
|
|
138
|
+
status: 'cancelled',
|
|
139
|
+
last_error: 'Discord thread was deleted',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
test('deleting an old resumed thread preserves the current session sleep', async () => {
|
|
144
|
+
const db = await getDb();
|
|
145
|
+
const sessionId = `resumed-session-${crypto.randomUUID()}`;
|
|
146
|
+
const oldThreadId = `old-thread-${crypto.randomUUID()}`;
|
|
147
|
+
const currentThreadId = `current-thread-${crypto.randomUUID()}`;
|
|
148
|
+
await setThreadSession(oldThreadId, sessionId);
|
|
149
|
+
await setThreadSession(currentThreadId, sessionId);
|
|
150
|
+
await db.update(schema.thread_sessions)
|
|
151
|
+
.set({ updated_at: new Date('2020-01-01T00:00:00.000Z') })
|
|
152
|
+
.where(orm.eq(schema.thread_sessions.thread_id, oldThreadId));
|
|
153
|
+
await db.update(schema.thread_sessions)
|
|
154
|
+
.set({ updated_at: new Date('2021-01-01T00:00:00.000Z') })
|
|
155
|
+
.where(orm.eq(schema.thread_sessions.thread_id, currentThreadId));
|
|
156
|
+
await upsertSessionSleep({
|
|
157
|
+
sessionId,
|
|
158
|
+
wakeAt: new Date('2099-01-01T00:00:00.000Z'),
|
|
159
|
+
reason: 'still current',
|
|
160
|
+
});
|
|
161
|
+
await cleanupDeletedThread(oldThreadId);
|
|
162
|
+
expect(await getSessionSleep({ sessionId })).toMatchObject({
|
|
163
|
+
status: 'planned',
|
|
164
|
+
});
|
|
165
|
+
});
|
|
63
166
|
test('rebuilds thread_queue_items that still use queue_id as the primary key', async () => {
|
|
64
167
|
await closeDb();
|
|
65
168
|
const previousDbUrl = process.env['KIMAKI_DB_URL'];
|
package/dist/discord-bot.js
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
// suppresses these when shuttingDown is already set, and we removeAllListeners()
|
|
9
9
|
// before destroying the client to prevent late events from becoming uncaught.
|
|
10
10
|
import { DiscordOperationError } from './errors.js';
|
|
11
|
-
import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, consumeSessionSleepWake, deleteChannelDirectoryById, createPendingWorkspace, setWorkspaceReady, } from './database.js';
|
|
11
|
+
import { initDatabase, closeDatabase, getThreadWorktreeOrWorkspace, getThreadSession, getChannelWorktreesEnabled, getChannelMentionMode, getChannelDirectory, cancelAllPendingIpcRequests, cleanupDeletedThread, clearGuildCategoryByChannelId, consumeSessionSleepWake, deleteForumSyncConfig, deleteChannelDirectoryById, findChannelsByDirectory, isCurrentThreadSessionBinding, createPendingWorkspace, setWorkspaceReady, } from './database.js';
|
|
12
12
|
import { stopOpencodeServer, } from './opencode.js';
|
|
13
13
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
14
14
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
|
|
15
15
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
16
|
-
import { STATUS_PREFIX } from './message-formatting.js';
|
|
16
|
+
import { asSystemLine, STATUS_PREFIX } from './message-formatting.js';
|
|
17
17
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
|
|
18
18
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
19
19
|
import YAML from 'yaml';
|
|
@@ -30,7 +30,7 @@ import { cancelHtmlActionsForThread } from './html-actions.js';
|
|
|
30
30
|
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
31
31
|
import { voiceConnections, cleanupVoiceConnection, registerVoiceStateHandler, } from './voice-handler.js';
|
|
32
32
|
import {} from './session-handler/model-utils.js';
|
|
33
|
-
import { getRuntime, getOrCreateRuntime, disposeRuntime, restorePersistedLocalQueues, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
|
|
33
|
+
import { getRuntime, getOrCreateRuntime, disposeRuntime, getRuntimeThreadIdsForChannel, restorePersistedLocalQueues, reserveThreadIngress, runInThreadIngressSlot, } from './session-handler/thread-session-runtime.js';
|
|
34
34
|
import { runShellCommand } from './commands/run-command.js';
|
|
35
35
|
import { registerInteractionHandler } from './interaction-handler.js';
|
|
36
36
|
import { getDiscordRestApiUrl } from './discord-urls.js';
|
|
@@ -42,6 +42,7 @@ import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js';
|
|
|
42
42
|
import { getDefaultKimakiDirectory, getUserProjectCount, } from './channel-management.js';
|
|
43
43
|
import { store } from './store.js';
|
|
44
44
|
import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
|
|
45
|
+
import { stopForumSyncForChannel } from './forum-sync/watchers.js';
|
|
45
46
|
export { initDatabase, closeDatabase, getChannelDirectory, } from './database.js';
|
|
46
47
|
export { initializeOpencodeForDirectory, assertCompatibleOpencodeVersion } from './opencode.js';
|
|
47
48
|
export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
@@ -182,7 +183,56 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
182
183
|
discordClient = await createDiscordClient();
|
|
183
184
|
}
|
|
184
185
|
store.setState({ useWorktrees: Boolean(useWorktrees) });
|
|
186
|
+
activeDiscordClient = discordClient;
|
|
187
|
+
registerBotLifecycleHandlers();
|
|
185
188
|
let currentAppId = appId;
|
|
189
|
+
const cleanupDeletedDiscordThread = async (threadId) => {
|
|
190
|
+
const abortActiveRun = await isCurrentThreadSessionBinding(threadId);
|
|
191
|
+
disposeRuntime(threadId, { abortActiveRun });
|
|
192
|
+
await cleanupDeletedThread(threadId);
|
|
193
|
+
};
|
|
194
|
+
const cleanupDeletedDiscordChannel = async (channelId) => {
|
|
195
|
+
const mapping = await getChannelDirectory(channelId);
|
|
196
|
+
const preserveMapping = mapping?.directory === getDefaultKimakiDirectory();
|
|
197
|
+
const threadIds = getRuntimeThreadIdsForChannel(channelId);
|
|
198
|
+
await Promise.all(threadIds.map(cleanupDeletedDiscordThread));
|
|
199
|
+
await stopForumSyncForChannel(channelId);
|
|
200
|
+
await clearGuildCategoryByChannelId(channelId);
|
|
201
|
+
if (currentAppId) {
|
|
202
|
+
await deleteForumSyncConfig({
|
|
203
|
+
appId: currentAppId,
|
|
204
|
+
forumChannelId: channelId,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const deleted = await deleteChannelDirectoryById(channelId, {
|
|
208
|
+
preserveMapping,
|
|
209
|
+
});
|
|
210
|
+
if (preserveMapping) {
|
|
211
|
+
discordLogger.log(`Preserved deleted default channel ${channelId} as a tombstone`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (deleted) {
|
|
215
|
+
discordLogger.log(`Cleaned up deleted channel ${channelId}`);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const reconcileDeletedDiscordChannels = async (c) => {
|
|
219
|
+
const mappings = await findChannelsByDirectory({});
|
|
220
|
+
for (const mapping of mappings) {
|
|
221
|
+
const channel = await c.channels
|
|
222
|
+
.fetch(mapping.channel_id, { force: true })
|
|
223
|
+
.catch((error) => {
|
|
224
|
+
const code = error instanceof Error ? Reflect.get(error, 'code') : undefined;
|
|
225
|
+
const status = error instanceof Error ? Reflect.get(error, 'status') : undefined;
|
|
226
|
+
if (code === 10003 || status === 404)
|
|
227
|
+
return null;
|
|
228
|
+
discordLogger.warn(`Could not verify channel ${mapping.channel_id}; preserving its SQLite mapping: ${formatErrorWithStack(error)}`);
|
|
229
|
+
return undefined;
|
|
230
|
+
});
|
|
231
|
+
if (channel !== null)
|
|
232
|
+
continue;
|
|
233
|
+
await cleanupDeletedDiscordChannel(mapping.channel_id);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
186
236
|
const setupHandlers = async (c) => {
|
|
187
237
|
discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
|
|
188
238
|
discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`);
|
|
@@ -213,6 +263,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
213
263
|
})();
|
|
214
264
|
registerInteractionHandler({ discordClient: c, appId: currentAppId });
|
|
215
265
|
registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
|
|
266
|
+
await reconcileDeletedDiscordChannels(c);
|
|
216
267
|
startExternalOpencodeSessionSync({ discordClient: c });
|
|
217
268
|
await restorePersistedLocalQueues({
|
|
218
269
|
discordClient: c,
|
|
@@ -236,18 +287,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
236
287
|
discordLogger.warn(`Background guild channel scan failed: ${error instanceof Error ? error.stack : String(error)}`);
|
|
237
288
|
});
|
|
238
289
|
};
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
void setupHandlers(readyClient).catch((error) => {
|
|
247
|
-
discordLogger.error(`[GATEWAY] ClientReady handler failed: ${formatErrorWithStack(error)}`);
|
|
290
|
+
// Keep startup ordered so stale targets are removed before queues and
|
|
291
|
+
// scheduled tasks can deliver work to them.
|
|
292
|
+
const setupPromise = discordClient.isReady()
|
|
293
|
+
? setupHandlers(discordClient)
|
|
294
|
+
: new Promise((resolve, reject) => {
|
|
295
|
+
discordClient.once(Events.ClientReady, (readyClient) => {
|
|
296
|
+
void setupHandlers(readyClient).then(resolve, reject);
|
|
248
297
|
});
|
|
249
298
|
});
|
|
250
|
-
}
|
|
251
299
|
discordClient.on(Events.Error, (error) => {
|
|
252
300
|
discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error));
|
|
253
301
|
});
|
|
@@ -694,7 +742,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
694
742
|
});
|
|
695
743
|
// Notify when a voice message was queued instead of sent immediately
|
|
696
744
|
if (enqueueResult.queued && enqueueResult.position) {
|
|
697
|
-
await sendThreadMessage(thread, `Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`);
|
|
745
|
+
await sendThreadMessage(thread, asSystemLine(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`));
|
|
698
746
|
}
|
|
699
747
|
}
|
|
700
748
|
if (channel.type === ChannelType.GuildText) {
|
|
@@ -1150,25 +1198,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1150
1198
|
// Dispose runtime when a thread is deleted so memory is freed immediately
|
|
1151
1199
|
// instead of waiting for the idle sweeper (1 hour default).
|
|
1152
1200
|
discordClient.on(Events.ThreadDelete, (thread) => {
|
|
1153
|
-
|
|
1201
|
+
void cleanupDeletedDiscordThread(thread.id).catch((error) => {
|
|
1202
|
+
notifyError(error instanceof Error ? error : new Error(String(error)), `Failed to clean up deleted thread ${thread.id}`);
|
|
1203
|
+
});
|
|
1154
1204
|
});
|
|
1155
|
-
// Clean up SQLite
|
|
1156
|
-
//
|
|
1157
|
-
// channel are disposed by their own ThreadDelete events from Discord.
|
|
1205
|
+
// Clean up SQLite and active child runtimes without relying on Discord to
|
|
1206
|
+
// send separate ThreadDelete events for every child.
|
|
1158
1207
|
discordClient.on(Events.ChannelDelete, async (channel) => {
|
|
1159
1208
|
try {
|
|
1160
|
-
|
|
1161
|
-
// channel_directories row as a tombstone so we don't recreate it.
|
|
1162
|
-
const mapping = await getChannelDirectory(channel.id);
|
|
1163
|
-
const defaultDir = getDefaultKimakiDirectory();
|
|
1164
|
-
if (mapping && mapping.directory === defaultDir) {
|
|
1165
|
-
discordLogger.log(`Preserving channel_directories row for deleted default channel ${channel.id} as tombstone`);
|
|
1166
|
-
return;
|
|
1167
|
-
}
|
|
1168
|
-
const deleted = await deleteChannelDirectoryById(channel.id);
|
|
1169
|
-
if (deleted) {
|
|
1170
|
-
discordLogger.log(`Cleaned up channel_directories for deleted channel ${channel.id}`);
|
|
1171
|
-
}
|
|
1209
|
+
await cleanupDeletedDiscordChannel(channel.id);
|
|
1172
1210
|
}
|
|
1173
1211
|
catch (error) {
|
|
1174
1212
|
notifyError(error instanceof Error ? error : new Error(String(error)), `Failed to clean up channel_directories for deleted channel ${channel.id}`);
|
|
@@ -1180,10 +1218,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1180
1218
|
if (!discordClient.isReady()) {
|
|
1181
1219
|
await discordClient.login(token);
|
|
1182
1220
|
}
|
|
1221
|
+
await setupPromise;
|
|
1222
|
+
// A stop signal during setup: do not start workers against closing resources.
|
|
1223
|
+
if (global.shuttingDown) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1183
1226
|
startHeapMonitor();
|
|
1184
1227
|
startStdinCpuProfListener();
|
|
1185
1228
|
const stopTaskRunner = startTaskRunner({ token });
|
|
1186
1229
|
const stopRuntimeIdleSweeper = startRuntimeIdleSweeper();
|
|
1230
|
+
stopBackgroundWorkers = async () => {
|
|
1231
|
+
await stopRuntimeIdleSweeper();
|
|
1232
|
+
await stopTaskRunner();
|
|
1233
|
+
};
|
|
1187
1234
|
// Prevent discord.js from permanently killing the REST token on 401.
|
|
1188
1235
|
// @discordjs/rest calls setToken(null) whenever it receives a 401 response.
|
|
1189
1236
|
// The gateway proxy now returns 503 for stale-DB rejections (not 401), but
|
|
@@ -1198,117 +1245,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1198
1245
|
}
|
|
1199
1246
|
return originalSetToken(newToken);
|
|
1200
1247
|
};
|
|
1201
|
-
const handleShutdown = async (signal, { skipExit = false } = {}) => {
|
|
1202
|
-
discordLogger.log(`Received ${signal}, cleaning up...`);
|
|
1203
|
-
if (global.shuttingDown) {
|
|
1204
|
-
discordLogger.log('Already shutting down, ignoring duplicate signal');
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
global.shuttingDown = true;
|
|
1208
|
-
try {
|
|
1209
|
-
stopStdinCpuProfListener();
|
|
1210
|
-
const flushed = await flushCpuProfiling();
|
|
1211
|
-
if (flushed instanceof Error) {
|
|
1212
|
-
discordLogger.warn('Failed to flush CPU profile on shutdown:', flushed.message);
|
|
1213
|
-
}
|
|
1214
|
-
await stopRuntimeIdleSweeper();
|
|
1215
|
-
await stopTaskRunner();
|
|
1216
|
-
await flushDebouncedProcessCallbacks().catch((error) => {
|
|
1217
|
-
discordLogger.warn('Failed to flush debounced process callbacks:', error instanceof Error ? error.stack : String(error));
|
|
1218
|
-
});
|
|
1219
|
-
await flushAnalytics();
|
|
1220
|
-
// Cancel pending IPC requests so plugin tools don't hang
|
|
1221
|
-
await cancelAllPendingIpcRequests().catch((e) => {
|
|
1222
|
-
discordLogger.warn('Failed to cancel pending IPC requests:', e.message);
|
|
1223
|
-
});
|
|
1224
|
-
const cleanupPromises = [];
|
|
1225
|
-
for (const [guildId] of voiceConnections) {
|
|
1226
|
-
voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
|
|
1227
|
-
cleanupPromises.push(cleanupVoiceConnection(guildId));
|
|
1228
|
-
}
|
|
1229
|
-
if (cleanupPromises.length > 0) {
|
|
1230
|
-
voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
|
|
1231
|
-
await Promise.allSettled(cleanupPromises);
|
|
1232
|
-
discordLogger.log(`All voice connections cleaned up`);
|
|
1233
|
-
}
|
|
1234
|
-
voiceLogger.log('[SHUTDOWN] Stopping OpenCode server');
|
|
1235
|
-
stopExternalOpencodeSessionSync();
|
|
1236
|
-
await stopOpencodeServer();
|
|
1237
|
-
discordLogger.log('Closing database...');
|
|
1238
|
-
await closeDatabase();
|
|
1239
|
-
discordLogger.log('Stopping hrana server...');
|
|
1240
|
-
await stopHranaServer();
|
|
1241
|
-
discordLogger.log('Destroying Discord client...');
|
|
1242
|
-
// Remove all listeners before destroy to prevent late-arriving shard
|
|
1243
|
-
// errors (from pending DNS lookups, WebSocket frames) from becoming
|
|
1244
|
-
// uncaught exceptions after the client's internal handlers are torn down.
|
|
1245
|
-
discordClient.removeAllListeners();
|
|
1246
|
-
void discordClient.destroy();
|
|
1247
|
-
discordLogger.log('Cleanup complete.');
|
|
1248
|
-
if (!skipExit) {
|
|
1249
|
-
process.exit(0);
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
catch (error) {
|
|
1253
|
-
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
|
|
1254
|
-
if (!skipExit) {
|
|
1255
|
-
process.exit(1);
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
};
|
|
1259
|
-
process.on('SIGTERM', async () => {
|
|
1260
|
-
try {
|
|
1261
|
-
await handleShutdown('SIGTERM');
|
|
1262
|
-
}
|
|
1263
|
-
catch (error) {
|
|
1264
|
-
voiceLogger.error('[SIGTERM] Error during shutdown:', error);
|
|
1265
|
-
process.exit(1);
|
|
1266
|
-
}
|
|
1267
|
-
});
|
|
1268
|
-
process.on('SIGINT', async () => {
|
|
1269
|
-
try {
|
|
1270
|
-
await handleShutdown('SIGINT');
|
|
1271
|
-
}
|
|
1272
|
-
catch (error) {
|
|
1273
|
-
voiceLogger.error('[SIGINT] Error during shutdown:', error);
|
|
1274
|
-
process.exit(1);
|
|
1275
|
-
}
|
|
1276
|
-
});
|
|
1277
|
-
process.on('SIGUSR1', () => {
|
|
1278
|
-
discordLogger.log('Received SIGUSR1, writing heap snapshot...');
|
|
1279
|
-
writeHeapSnapshot().catch((e) => {
|
|
1280
|
-
discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
|
|
1281
|
-
});
|
|
1282
|
-
});
|
|
1283
|
-
// Self-restart: die so the bin.ts wrapper restarts us with exponential
|
|
1284
|
-
// backoff and crash-loop detection. process.exit() can hang joining native
|
|
1285
|
-
// worker threads after Discord gateway failures, so SIGKILL instead.
|
|
1286
|
-
// When running without the wrapper (e.g. `tsx src/cli.ts`), the process
|
|
1287
|
-
// just dies — use `tsx src/bin.ts` for auto-restart support.
|
|
1288
|
-
let selfRestarting = false;
|
|
1289
|
-
async function selfRestart(reason) {
|
|
1290
|
-
if (selfRestarting) {
|
|
1291
|
-
discordLogger.log(`Self-restart already in progress, ignoring duplicate reason: ${reason}`);
|
|
1292
|
-
return;
|
|
1293
|
-
}
|
|
1294
|
-
selfRestarting = true;
|
|
1295
|
-
discordLogger.log(`Self-restarting (reason: ${reason})...`);
|
|
1296
|
-
setTimeout(() => process.kill(process.pid, 'SIGKILL'), 15_000);
|
|
1297
|
-
try {
|
|
1298
|
-
await handleShutdown(reason, { skipExit: true });
|
|
1299
|
-
}
|
|
1300
|
-
catch (error) {
|
|
1301
|
-
voiceLogger.error(`[${reason}] Error during shutdown:`, error);
|
|
1302
|
-
}
|
|
1303
|
-
if (!process.env.__KIMAKI_CHILD) {
|
|
1304
|
-
discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
|
|
1305
|
-
}
|
|
1306
|
-
process.kill(process.pid, 'SIGKILL');
|
|
1307
|
-
}
|
|
1308
|
-
process.on('SIGUSR2', () => {
|
|
1309
|
-
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
|
1310
|
-
void selfRestart('SIGUSR2');
|
|
1311
|
-
});
|
|
1312
1248
|
process.on('uncaughtException', (error) => {
|
|
1313
1249
|
// During self-restart or shutdown, discord.js can still fire errors from
|
|
1314
1250
|
// pending async operations (DNS lookups, WebSocket frames) after the client
|
|
@@ -1320,9 +1256,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1320
1256
|
}
|
|
1321
1257
|
discordLogger.error('Uncaught exception:', formatErrorWithStack(error));
|
|
1322
1258
|
notifyError(error, 'Uncaught exception in bot process');
|
|
1323
|
-
void
|
|
1324
|
-
discordLogger.error('[uncaughtException] shutdown failed:', formatErrorWithStack(shutdownError));
|
|
1325
|
-
});
|
|
1259
|
+
void shutdownBot('uncaughtException', { skipExit: true });
|
|
1326
1260
|
setTimeout(() => {
|
|
1327
1261
|
process.exit(1);
|
|
1328
1262
|
}, 250).unref();
|
|
@@ -1339,3 +1273,137 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1339
1273
|
void notifyError(error, 'Unhandled rejection in bot process');
|
|
1340
1274
|
});
|
|
1341
1275
|
}
|
|
1276
|
+
// ── Process lifecycle ────────────────────────────────────────────────
|
|
1277
|
+
// cli-runner installs these handlers right after binding the hrana lock port,
|
|
1278
|
+
// long before Discord is ready. Before this, SIGINT/SIGTERM during startup only
|
|
1279
|
+
// hit the opencode.ts fallback listener, which does not exit, so the process
|
|
1280
|
+
// kept the lock port forever and every next start failed with EADDRINUSE.
|
|
1281
|
+
// Every cleanup step below must therefore be safe at any startup phase.
|
|
1282
|
+
const SHUTDOWN_DEADLINE_MS = 15_000;
|
|
1283
|
+
let activeDiscordClient = null;
|
|
1284
|
+
let stopBackgroundWorkers = null;
|
|
1285
|
+
let selfRestarting = false;
|
|
1286
|
+
let lifecycleHandlersRegistered = false;
|
|
1287
|
+
async function shutdownBot(reason, { skipExit = false } = {}) {
|
|
1288
|
+
discordLogger.log(`Received ${reason}, cleaning up...`);
|
|
1289
|
+
if (global.shuttingDown) {
|
|
1290
|
+
discordLogger.log('Already shutting down, ignoring duplicate signal');
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
global.shuttingDown = true;
|
|
1294
|
+
// Cleanup or process.exit() can hang (native worker threads after gateway
|
|
1295
|
+
// failures). Never let a stuck shutdown hold the lock port. Kept ref'd so a
|
|
1296
|
+
// drained event loop cannot exit with code 0 and skip the wrapper restart.
|
|
1297
|
+
setTimeout(() => {
|
|
1298
|
+
process.kill(process.pid, 'SIGKILL');
|
|
1299
|
+
}, SHUTDOWN_DEADLINE_MS);
|
|
1300
|
+
try {
|
|
1301
|
+
stopStdinCpuProfListener();
|
|
1302
|
+
const flushed = await flushCpuProfiling();
|
|
1303
|
+
if (flushed instanceof Error) {
|
|
1304
|
+
discordLogger.warn('Failed to flush CPU profile on shutdown:', flushed.message);
|
|
1305
|
+
}
|
|
1306
|
+
await stopBackgroundWorkers?.();
|
|
1307
|
+
await flushDebouncedProcessCallbacks().catch((error) => {
|
|
1308
|
+
discordLogger.warn('Failed to flush debounced process callbacks:', error instanceof Error ? error.stack : String(error));
|
|
1309
|
+
});
|
|
1310
|
+
await flushAnalytics();
|
|
1311
|
+
// Cancel pending IPC requests so plugin tools don't hang
|
|
1312
|
+
await cancelAllPendingIpcRequests().catch((e) => {
|
|
1313
|
+
discordLogger.warn('Failed to cancel pending IPC requests:', e.message);
|
|
1314
|
+
});
|
|
1315
|
+
const cleanupPromises = [];
|
|
1316
|
+
for (const [guildId] of voiceConnections) {
|
|
1317
|
+
voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
|
|
1318
|
+
cleanupPromises.push(cleanupVoiceConnection(guildId));
|
|
1319
|
+
}
|
|
1320
|
+
if (cleanupPromises.length > 0) {
|
|
1321
|
+
voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
|
|
1322
|
+
await Promise.allSettled(cleanupPromises);
|
|
1323
|
+
discordLogger.log(`All voice connections cleaned up`);
|
|
1324
|
+
}
|
|
1325
|
+
voiceLogger.log('[SHUTDOWN] Stopping OpenCode server');
|
|
1326
|
+
stopExternalOpencodeSessionSync();
|
|
1327
|
+
await stopOpencodeServer();
|
|
1328
|
+
discordLogger.log('Closing database...');
|
|
1329
|
+
await closeDatabase();
|
|
1330
|
+
discordLogger.log('Stopping hrana server...');
|
|
1331
|
+
await stopHranaServer();
|
|
1332
|
+
if (activeDiscordClient) {
|
|
1333
|
+
discordLogger.log('Destroying Discord client...');
|
|
1334
|
+
// Remove all listeners before destroy to prevent late-arriving shard
|
|
1335
|
+
// errors (from pending DNS lookups, WebSocket frames) from becoming
|
|
1336
|
+
// uncaught exceptions after the client's internal handlers are torn down.
|
|
1337
|
+
activeDiscordClient.removeAllListeners();
|
|
1338
|
+
void activeDiscordClient.destroy();
|
|
1339
|
+
}
|
|
1340
|
+
discordLogger.log('Cleanup complete.');
|
|
1341
|
+
if (!skipExit) {
|
|
1342
|
+
process.exit(0);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
catch (error) {
|
|
1346
|
+
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
|
|
1347
|
+
if (!skipExit) {
|
|
1348
|
+
process.exit(1);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
// Self-restart: die so the bin.ts wrapper restarts us with exponential
|
|
1353
|
+
// backoff and crash-loop detection. process.exit() can hang joining native
|
|
1354
|
+
// worker threads after Discord gateway failures, so SIGKILL instead.
|
|
1355
|
+
// When running without the wrapper (e.g. `tsx src/cli.ts`), the process
|
|
1356
|
+
// just dies — use `tsx src/bin.ts` for auto-restart support.
|
|
1357
|
+
async function selfRestart(reason) {
|
|
1358
|
+
if (selfRestarting) {
|
|
1359
|
+
discordLogger.log(`Self-restart already in progress, ignoring duplicate reason: ${reason}`);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
selfRestarting = true;
|
|
1363
|
+
discordLogger.log(`Self-restarting (reason: ${reason})...`);
|
|
1364
|
+
await shutdownBot(reason, { skipExit: true });
|
|
1365
|
+
if (!process.env.__KIMAKI_CHILD) {
|
|
1366
|
+
discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
|
|
1367
|
+
}
|
|
1368
|
+
process.kill(process.pid, 'SIGKILL');
|
|
1369
|
+
}
|
|
1370
|
+
/** Idempotent. Call as soon as the process owns the lock port. */
|
|
1371
|
+
export function registerBotLifecycleHandlers() {
|
|
1372
|
+
if (lifecycleHandlersRegistered) {
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
lifecycleHandlersRegistered = true;
|
|
1376
|
+
// SIGHUP: closing the terminal must stop the bot, not leave it orphaned.
|
|
1377
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
1378
|
+
process.on(signal, () => {
|
|
1379
|
+
void shutdownBot(signal);
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
process.on('SIGUSR1', () => {
|
|
1383
|
+
discordLogger.log('Received SIGUSR1, writing heap snapshot...');
|
|
1384
|
+
writeHeapSnapshot().catch((e) => {
|
|
1385
|
+
discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
process.on('SIGUSR2', () => {
|
|
1389
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
|
1390
|
+
void selfRestart('SIGUSR2');
|
|
1391
|
+
});
|
|
1392
|
+
// bin.ts spawns us with an IPC channel. The OS closes it when the wrapper
|
|
1393
|
+
// dies for any reason (including SIGKILL), so an orphaned child exits instead
|
|
1394
|
+
// of holding the lock port. Guarded by __KIMAKI_CHILD so vitest workers,
|
|
1395
|
+
// which also have an IPC channel, are not affected.
|
|
1396
|
+
if (!process.env.__KIMAKI_CHILD) {
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
// The wrapper can die before we get here (e.g. during the eviction wait);
|
|
1400
|
+
// 'disconnect' is not replayed for late listeners.
|
|
1401
|
+
if (!process.connected) {
|
|
1402
|
+
void shutdownBot('wrapper-disconnect');
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
process.on('disconnect', () => {
|
|
1406
|
+
void shutdownBot('wrapper-disconnect');
|
|
1407
|
+
});
|
|
1408
|
+
process.channel?.unref();
|
|
1409
|
+
}
|
|
@@ -64,13 +64,13 @@ describe('external directory permissions', () => {
|
|
|
64
64
|
"--- from: user (external-directory-tester)
|
|
65
65
|
EXTERNAL_DIRECTORY_PROBE_MARKER first
|
|
66
66
|
--- from: assistant (TestBot)
|
|
67
|
-
|
|
67
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
68
68
|
> reading external directory
|
|
69
69
|
|
|
70
70
|
┣ read *probe.txt*
|
|
71
71
|
|
|
72
72
|
> external-directory-probe-done
|
|
73
|
-
|
|
73
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
74
74
|
--- from: user (external-directory-tester)
|
|
75
75
|
EXTERNAL_DIRECTORY_PROBE_MARKER followup
|
|
76
76
|
--- from: assistant (TestBot)
|
|
@@ -79,7 +79,7 @@ describe('external directory permissions', () => {
|
|
|
79
79
|
┣ read *probe.txt*
|
|
80
80
|
|
|
81
81
|
> external-directory-probe-done
|
|
82
|
-
|
|
82
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
83
83
|
`);
|
|
84
84
|
expect(text).not.toContain('Permission Required');
|
|
85
85
|
});
|
|
@@ -54,7 +54,7 @@ describe('external directory project deny', () => {
|
|
|
54
54
|
"--- from: user (external-directory-deny-tester)
|
|
55
55
|
EXTERNAL_DIRECTORY_PROBE_MARKER denied
|
|
56
56
|
--- from: assistant (TestBot)
|
|
57
|
-
|
|
57
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
58
58
|
> reading external directory
|
|
59
59
|
|
|
60
60
|
┣ read *probe.txt*
|
|
@@ -251,6 +251,21 @@ export async function stopConfiguredForumSync() {
|
|
|
251
251
|
});
|
|
252
252
|
}));
|
|
253
253
|
}
|
|
254
|
+
export async function stopForumSyncForChannel(forumChannelId) {
|
|
255
|
+
const runtimeState = forumStateById.get(forumChannelId);
|
|
256
|
+
if (runtimeState?.discordDebounceTimer) {
|
|
257
|
+
clearTimeout(runtimeState.discordDebounceTimer);
|
|
258
|
+
}
|
|
259
|
+
if (runtimeState?.fileDebounceTimer) {
|
|
260
|
+
clearTimeout(runtimeState.fileDebounceTimer);
|
|
261
|
+
}
|
|
262
|
+
forumStateById.delete(forumChannelId);
|
|
263
|
+
const unsubscribe = watcherUnsubscribeByForumId.get(forumChannelId);
|
|
264
|
+
watcherUnsubscribeByForumId.delete(forumChannelId);
|
|
265
|
+
await unsubscribe?.().catch((cause) => {
|
|
266
|
+
forumLogger.warn(`Failed to unsubscribe deleted forum ${forumChannelId}:`, cause);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
254
269
|
export async function startConfiguredForumSync({ discordClient, appId, }) {
|
|
255
270
|
const loadedConfig = await readForumSyncConfig({ appId });
|
|
256
271
|
if (loadedConfig instanceof Error)
|