ompchamber 1.2.1 → 2.0.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/README.md +131 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.74ff36419f.js +2 -0
- package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.33d6779f98.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.c05535dbde.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +124 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/editor/index.tsx +11 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
|
@@ -16,67 +16,13 @@
|
|
|
16
16
|
* follow-up stranded, while a freshly started server delivered the identical
|
|
17
17
|
* item correctly.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* waits on the wall clock.
|
|
19
|
+
* This file covers the SCHEDULING contract — when a delivery runs, when it
|
|
20
|
+
* defers, and how it recovers. What a dispatch actually sends lives in
|
|
21
|
+
* `delivery-dispatch.server.test.ts`; both share `delivery-harness.ts`.
|
|
23
22
|
*/
|
|
24
23
|
|
|
25
|
-
import { afterAll, afterEach,
|
|
26
|
-
import
|
|
27
|
-
import type { QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
|
|
28
|
-
|
|
29
|
-
/** In-memory stand-in for the SQLite-backed queue. */
|
|
30
|
-
const queue = new Map<string, QueuedMessage[]>();
|
|
31
|
-
|
|
32
|
-
mock.module('@/server/lib/queue/store.server', () => ({
|
|
33
|
-
claimHeadQueueItem: (sessionId: string): QueuedMessage | null => {
|
|
34
|
-
const items = queue.get(sessionId);
|
|
35
|
-
return items?.shift() ?? null;
|
|
36
|
-
},
|
|
37
|
-
requeueHeadQueueItem: async (sessionId: string, item: QueuedMessage): Promise<void> => {
|
|
38
|
-
const items = queue.get(sessionId) ?? [];
|
|
39
|
-
items.unshift(item);
|
|
40
|
-
queue.set(sessionId, items);
|
|
41
|
-
},
|
|
42
|
-
}));
|
|
43
|
-
|
|
44
|
-
const { scheduleQueueDelivery } = await import('@/server/lib/queue/delivery.server');
|
|
45
|
-
|
|
46
|
-
const MODEL = { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max', accessMode: 'yolo' as const };
|
|
47
|
-
|
|
48
|
-
function queueItem(text: string, model: QueuedMessage['model'] = MODEL): QueuedMessage {
|
|
49
|
-
return { id: `item-${text}`, text, attachments: [], model };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Records the RPCs a delivery pushes at the session. */
|
|
53
|
-
function makeHost(sessionId: string) {
|
|
54
|
-
const sent: Record<string, unknown>[] = [];
|
|
55
|
-
const host: QueueDeliveryHost = {
|
|
56
|
-
sessionId,
|
|
57
|
-
isAlive: () => true,
|
|
58
|
-
isBusy: () => false,
|
|
59
|
-
send: async (command) => {
|
|
60
|
-
sent.push(command);
|
|
61
|
-
return {};
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
return { host, sent };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Let the delivery's promise chain settle without advancing the clock. */
|
|
68
|
-
async function flush(): Promise<void> {
|
|
69
|
-
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
let sessionId = '';
|
|
73
|
-
let counter = 0;
|
|
74
|
-
|
|
75
|
-
beforeEach(() => {
|
|
76
|
-
sessionId = `queue-test-${counter++}`;
|
|
77
|
-
queue.clear();
|
|
78
|
-
jest.useFakeTimers();
|
|
79
|
-
});
|
|
24
|
+
import { afterAll, afterEach, describe, expect, jest, mock, test } from 'bun:test';
|
|
25
|
+
import { beginQueueTest, flush, makeHost, queue, queueItem, scheduleQueueDelivery, settleDelivery } from '@/server/lib/queue/delivery-harness';
|
|
80
26
|
|
|
81
27
|
afterEach(() => {
|
|
82
28
|
jest.useRealTimers();
|
|
@@ -86,147 +32,130 @@ afterAll(() => {
|
|
|
86
32
|
mock.restore();
|
|
87
33
|
});
|
|
88
34
|
|
|
89
|
-
describe('queue delivery', () => {
|
|
90
|
-
test('
|
|
91
|
-
|
|
35
|
+
describe('queue delivery scheduling', () => {
|
|
36
|
+
test('a busy session holds the item instead of sending it', async () => {
|
|
37
|
+
const sessionId = beginQueueTest();
|
|
38
|
+
queue.set(sessionId, [queueItem('held')]);
|
|
92
39
|
const { host, sent } = makeHost(sessionId);
|
|
40
|
+
host.isBusy = () => true;
|
|
93
41
|
|
|
94
|
-
|
|
95
|
-
jest.advanceTimersByTime(600);
|
|
96
|
-
await flush();
|
|
42
|
+
await settleDelivery(host);
|
|
97
43
|
|
|
98
|
-
expect(sent).toEqual([
|
|
99
|
-
|
|
100
|
-
{ type: 'set_thinking_level', level: 'max' },
|
|
101
|
-
{ type: 'prompt', message: 'queued hello', accessMode: 'yolo' },
|
|
102
|
-
]);
|
|
103
|
-
expect(queue.get(sessionId)).toEqual([]);
|
|
44
|
+
expect(sent).toEqual([]);
|
|
45
|
+
expect(queue.get(sessionId)?.length).toBe(1);
|
|
104
46
|
});
|
|
105
47
|
|
|
106
|
-
|
|
107
|
-
|
|
48
|
+
// The regression this file exists for: a delivery that finds the session busy
|
|
49
|
+
// at its tick used to return and stay returned. The run-end that scheduled it
|
|
50
|
+
// is the LAST trigger that run produces, so nothing re-armed it — the queue
|
|
51
|
+
// stalled with no error, and even a manual nudge could not recover it because
|
|
52
|
+
// `scheduleQueueDelivery` early-returns while a fresh entry sits in the map.
|
|
53
|
+
test('a delivery that finds the session busy retries until it goes idle', async () => {
|
|
54
|
+
const sessionId = beginQueueTest();
|
|
55
|
+
queue.set(sessionId, [queueItem('held then sent')]);
|
|
108
56
|
const { host, sent } = makeHost(sessionId);
|
|
57
|
+
let busy = true;
|
|
58
|
+
host.isBusy = () => busy;
|
|
109
59
|
|
|
110
|
-
|
|
111
|
-
jest.advanceTimersByTime(600);
|
|
112
|
-
await flush();
|
|
60
|
+
await settleDelivery(host);
|
|
113
61
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
]);
|
|
118
|
-
});
|
|
62
|
+
// Held at the first tick — the item must survive untouched.
|
|
63
|
+
expect(sent).toEqual([]);
|
|
64
|
+
expect(queue.get(sessionId)?.length).toBe(1);
|
|
119
65
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
66
|
+
// Still busy across several retry intervals: the item must not be lost.
|
|
67
|
+
jest.advanceTimersByTime(6_000);
|
|
68
|
+
await flush();
|
|
69
|
+
expect(sent).toEqual([]);
|
|
70
|
+
expect(queue.get(sessionId)?.length).toBe(1);
|
|
123
71
|
|
|
124
|
-
|
|
125
|
-
|
|
72
|
+
// The session frees up on its own (the subagent finished, the dialog was
|
|
73
|
+
// answered). No new agent_end arrives; the retry is the only thing that can
|
|
74
|
+
// deliver it.
|
|
75
|
+
busy = false;
|
|
76
|
+
jest.advanceTimersByTime(2_000);
|
|
126
77
|
await flush();
|
|
127
78
|
|
|
128
|
-
expect(sent
|
|
79
|
+
expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
|
|
80
|
+
expect(queue.get(sessionId)).toEqual([]);
|
|
129
81
|
});
|
|
130
82
|
|
|
131
|
-
test('a
|
|
132
|
-
|
|
83
|
+
test('a retry stops arming once the queue has emptied elsewhere', async () => {
|
|
84
|
+
const sessionId = beginQueueTest();
|
|
85
|
+
queue.set(sessionId, [queueItem('taken by another tab')]);
|
|
133
86
|
const { host, sent } = makeHost(sessionId);
|
|
134
87
|
host.isBusy = () => true;
|
|
135
88
|
|
|
136
|
-
|
|
137
|
-
|
|
89
|
+
await settleDelivery(host);
|
|
90
|
+
|
|
91
|
+
// Another tab claimed it while this session was busy.
|
|
92
|
+
queue.set(sessionId, []);
|
|
93
|
+
jest.advanceTimersByTime(2_000);
|
|
138
94
|
await flush();
|
|
139
95
|
|
|
140
|
-
|
|
141
|
-
|
|
96
|
+
// No poll loop is left behind: the host is never asked to deliver again.
|
|
97
|
+
const probesAfterEmpty = sent.length;
|
|
98
|
+
jest.advanceTimersByTime(60_000);
|
|
99
|
+
await flush();
|
|
100
|
+
expect(sent.length).toBe(probesAfterEmpty);
|
|
142
101
|
});
|
|
143
102
|
|
|
144
103
|
test('a failed dispatch returns the item to the head', async () => {
|
|
104
|
+
const sessionId = beginQueueTest();
|
|
145
105
|
queue.set(sessionId, [queueItem('retry me')]);
|
|
146
106
|
const { host } = makeHost(sessionId);
|
|
147
107
|
host.send = async () => {
|
|
148
108
|
throw new Error('rpc down');
|
|
149
109
|
};
|
|
150
110
|
|
|
151
|
-
|
|
152
|
-
jest.advanceTimersByTime(600);
|
|
153
|
-
await flush();
|
|
111
|
+
await settleDelivery(host);
|
|
154
112
|
|
|
155
113
|
expect(queue.get(sessionId)?.map((i) => i.text)).toEqual(['retry me']);
|
|
156
114
|
});
|
|
157
115
|
|
|
158
|
-
|
|
159
|
-
|
|
116
|
+
// A failed dispatch re-arms, so a transient RPC failure drains on its own
|
|
117
|
+
// rather than waiting for a run end that may never come.
|
|
118
|
+
test('a failed dispatch retries and delivers once the send recovers', async () => {
|
|
119
|
+
const sessionId = beginQueueTest();
|
|
120
|
+
queue.set(sessionId, [queueItem('transient')]);
|
|
160
121
|
const { host, sent } = makeHost(sessionId);
|
|
122
|
+
let failing = true;
|
|
123
|
+
host.send = async (command) => {
|
|
124
|
+
if (failing) throw new Error('rpc down');
|
|
125
|
+
sent.push(command);
|
|
126
|
+
return {};
|
|
127
|
+
};
|
|
161
128
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
129
|
+
await settleDelivery(host);
|
|
130
|
+
expect(sent).toEqual([]);
|
|
131
|
+
|
|
132
|
+
failing = false;
|
|
133
|
+
jest.advanceTimersByTime(2_000);
|
|
166
134
|
await flush();
|
|
167
135
|
|
|
168
136
|
expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
|
|
169
137
|
expect(queue.get(sessionId)).toEqual([]);
|
|
170
138
|
});
|
|
171
139
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// fields the delivery must use instead — `dataBase64` for the bytes, `type`
|
|
176
|
-
// for the media type, `content` for an inline-able text file.
|
|
177
|
-
test('a queued image rides its persisted base64 payload', async () => {
|
|
178
|
-
const item = queueItem('with image', null);
|
|
179
|
-
item.attachments = [
|
|
180
|
-
{ id: 'i', name: 'shot.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: 'iVBORw0KGgo=' },
|
|
181
|
-
] as QueuedMessage['attachments'];
|
|
182
|
-
queue.set(sessionId, [item]);
|
|
140
|
+
test('repeated triggers deliver the head exactly once', async () => {
|
|
141
|
+
const sessionId = beginQueueTest();
|
|
142
|
+
queue.set(sessionId, [queueItem('once')]);
|
|
183
143
|
const { host, sent } = makeHost(sessionId);
|
|
184
144
|
|
|
145
|
+
// Three triggers land before the settle delay elapses — the head must be
|
|
146
|
+
// claimed once, not once per trigger.
|
|
185
147
|
scheduleQueueDelivery(host);
|
|
186
|
-
jest.advanceTimersByTime(600);
|
|
187
|
-
await flush();
|
|
188
|
-
|
|
189
|
-
expect(sent).toEqual([
|
|
190
|
-
{
|
|
191
|
-
type: 'prompt',
|
|
192
|
-
message: 'with image',
|
|
193
|
-
images: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }],
|
|
194
|
-
},
|
|
195
|
-
]);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
test('a queued text file is inlined into the delivered prompt', async () => {
|
|
199
|
-
const item = queueItem('review this', null);
|
|
200
|
-
item.attachments = [
|
|
201
|
-
{ id: 't', name: 'notes.md', type: 'text/markdown', size: 5, preview: '', content: '# hi' },
|
|
202
|
-
] as QueuedMessage['attachments'];
|
|
203
|
-
queue.set(sessionId, [item]);
|
|
204
|
-
const { host, sent } = makeHost(sessionId);
|
|
205
|
-
|
|
206
148
|
scheduleQueueDelivery(host);
|
|
207
|
-
jest.advanceTimersByTime(600);
|
|
208
|
-
await flush();
|
|
209
|
-
|
|
210
|
-
const prompt = sent.find((c) => c.type === 'prompt');
|
|
211
|
-
expect(prompt?.message).toBe('review this\n\nAttached file: notes.md\n```markdown\n# hi\n```');
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test('an attachment without a payload is not sent as a blank image', async () => {
|
|
215
|
-
const item = queueItem('no payload', null);
|
|
216
|
-
item.attachments = [
|
|
217
|
-
{ id: 'i', name: 'broken.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: '' },
|
|
218
|
-
] as QueuedMessage['attachments'];
|
|
219
|
-
queue.set(sessionId, [item]);
|
|
220
|
-
const { host, sent } = makeHost(sessionId);
|
|
221
|
-
|
|
222
149
|
scheduleQueueDelivery(host);
|
|
223
150
|
jest.advanceTimersByTime(600);
|
|
224
151
|
await flush();
|
|
225
152
|
|
|
226
|
-
expect(sent
|
|
153
|
+
expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
|
|
154
|
+
expect(queue.get(sessionId)).toEqual([]);
|
|
227
155
|
});
|
|
228
156
|
|
|
229
157
|
test('a lost timer delays the queue instead of wedging it', async () => {
|
|
158
|
+
const sessionId = beginQueueTest();
|
|
230
159
|
queue.set(sessionId, [queueItem('recovered')]);
|
|
231
160
|
const { host, sent } = makeHost(sessionId);
|
|
232
161
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* semantics live on the server now.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { claimHeadQueueItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
|
|
23
|
+
import { claimHeadQueueItem, hasQueuedItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
|
|
24
24
|
import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
|
|
25
25
|
import type { QueuedMessage } from '@/shared/types/chat';
|
|
26
26
|
|
|
@@ -35,26 +35,57 @@ const DELIVERY_DELAY_MS = 500;
|
|
|
35
35
|
*
|
|
36
36
|
* Without this, one lost timer wedges the session's queue FOREVER: the guard
|
|
37
37
|
* below early-returns on any existing entry, so nothing would ever schedule
|
|
38
|
-
* again. A timer can be lost without the process dying —
|
|
39
|
-
*
|
|
40
|
-
* loop drops the callback — and the symptom is silent: the queue simply never
|
|
38
|
+
* again. A timer can be lost without the process dying — a wedged event loop
|
|
39
|
+
* drops the callback — and the symptom is silent: the queue simply never
|
|
41
40
|
* drains and no error is reported. Past this bound the entry is presumed lost
|
|
42
41
|
* and rescheduled, so a client nudge (sent whenever a session with queued
|
|
43
42
|
* items mounts or regains focus) always recovers the queue.
|
|
43
|
+
*
|
|
44
|
+
* This covers only a timer that never RUNS. A delivery that runs and cannot
|
|
45
|
+
* proceed re-arms itself (see {@link DELIVERY_RETRY_MS}); the two paths are
|
|
46
|
+
* independent, so neither depends on a client trigger arriving.
|
|
44
47
|
*/
|
|
45
48
|
const DELIVERY_STALE_MS = 5_000;
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Retry interval for a delivery that could not run: the session was busy at its
|
|
52
|
+
* tick, or the attempt itself failed.
|
|
53
|
+
*
|
|
54
|
+
* A busy session is the common case, not an edge one — a subagent outlives the
|
|
55
|
+
* parent turn's `agent_end`, a compaction runs after it, and an ask/approval
|
|
56
|
+
* dialog parks it indefinitely — and every one of those is exactly when the
|
|
57
|
+
* run-end trigger fires. `isBusy()` is an in-memory flag read, so polling it
|
|
58
|
+
* costs nothing; the alternative (a wake hook per busy source) would need
|
|
59
|
+
* re-wiring every time a new source is added.
|
|
60
|
+
*/
|
|
61
|
+
const DELIVERY_RETRY_MS = 2_000;
|
|
62
|
+
|
|
47
63
|
interface PendingDelivery {
|
|
48
64
|
timer: ReturnType<typeof setTimeout>;
|
|
49
65
|
/** Wall-clock after which this entry is presumed lost and superseded. */
|
|
50
66
|
expiresAt: number;
|
|
51
67
|
}
|
|
52
68
|
|
|
69
|
+
declare global {
|
|
70
|
+
// eslint-disable-next-line no-var
|
|
71
|
+
var __ompQueueDeliveryTimers: Map<string, PendingDelivery> | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
/** Per-session pending delivery timer, so a second agent_end while one is
|
|
54
|
-
* already scheduled cannot double-book the head.
|
|
55
|
-
|
|
75
|
+
* already scheduled cannot double-book the head.
|
|
76
|
+
*
|
|
77
|
+
* On `globalThis` for the same reason the session registry is: a `bun --hot`
|
|
78
|
+
* soft reload re-evaluates this module but keeps globalThis, so a module-local
|
|
79
|
+
* map would silently orphan every pending entry — the reloaded module would
|
|
80
|
+
* see an empty map, `clearPendingTimer` could no longer cancel the orphan, and
|
|
81
|
+
* the session's queue would stall with nothing logged. */
|
|
82
|
+
function getPendingTimers(): Map<string, PendingDelivery> {
|
|
83
|
+
if (!globalThis.__ompQueueDeliveryTimers) globalThis.__ompQueueDeliveryTimers = new Map();
|
|
84
|
+
return globalThis.__ompQueueDeliveryTimers;
|
|
85
|
+
}
|
|
56
86
|
|
|
57
87
|
function clearPendingTimer(sessionId: string): void {
|
|
88
|
+
const pendingTimers = getPendingTimers();
|
|
58
89
|
const entry = pendingTimers.get(sessionId);
|
|
59
90
|
if (entry === undefined) return;
|
|
60
91
|
clearTimeout(entry.timer);
|
|
@@ -129,26 +160,87 @@ export interface QueueDeliveryHost {
|
|
|
129
160
|
export function scheduleQueueDelivery(session: QueueDeliveryHost): void {
|
|
130
161
|
const sessionId = session.sessionId;
|
|
131
162
|
if (!sessionId) return;
|
|
163
|
+
const pendingTimers = getPendingTimers();
|
|
132
164
|
const pending = pendingTimers.get(sessionId);
|
|
133
165
|
if (pending !== undefined) {
|
|
134
166
|
if (Date.now() < pending.expiresAt) return;
|
|
135
167
|
// Lost timer: clear it so its callback cannot also fire and double-deliver.
|
|
136
168
|
clearTimeout(pending.timer);
|
|
137
169
|
}
|
|
170
|
+
armDelivery(session, DELIVERY_DELAY_MS);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Deliver the head right now, with no timer involved. Returns whether an item
|
|
175
|
+
* was actually sent.
|
|
176
|
+
*
|
|
177
|
+
* The nudge route's entry point: the failure a nudge recovers from is a lost
|
|
178
|
+
* timer, so the nudge itself must not depend on one. Busy sessions re-arm the
|
|
179
|
+
* normal retry (which is timer-based, and harmless — the nudge is a bonus path,
|
|
180
|
+
* not the only one), and an empty queue is a no-op.
|
|
181
|
+
*/
|
|
182
|
+
export async function deliverQueueNow(session: QueueDeliveryHost): Promise<boolean> {
|
|
183
|
+
return deliverNext(session);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Arm (or re-arm) this session's delivery tick. */
|
|
187
|
+
function armDelivery(session: QueueDeliveryHost, delayMs: number): void {
|
|
188
|
+
const sessionId = session.sessionId;
|
|
189
|
+
const pendingTimers = getPendingTimers();
|
|
138
190
|
const timer = setTimeout(() => {
|
|
139
191
|
pendingTimers.delete(sessionId);
|
|
140
|
-
|
|
141
|
-
|
|
192
|
+
// The attempt is fire-and-forget from the timer's perspective, but it must
|
|
193
|
+
// never reject silently: an unhandled rejection here is how a throwing
|
|
194
|
+
// `claimHeadQueueItem` (a `getDbSync()` before the db is resolved) or a
|
|
195
|
+
// failing RPC turned into a queue that simply stopped draining with nothing
|
|
196
|
+
// in the log. `deliverNext` owns its own failure handling; this catch is
|
|
197
|
+
// the last resort so the failure is at least reported.
|
|
198
|
+
deliverNext(session).catch((error) => {
|
|
199
|
+
console.error(`[queue] delivery attempt failed for session ${sessionId}:`, error);
|
|
200
|
+
});
|
|
201
|
+
}, delayMs);
|
|
142
202
|
pendingTimers.set(sessionId, { timer, expiresAt: Date.now() + DELIVERY_STALE_MS });
|
|
143
203
|
}
|
|
144
204
|
|
|
145
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Re-arm the retry, but only while there is still something to deliver.
|
|
207
|
+
*
|
|
208
|
+
* A busy session whose queue emptied in the meantime (another tab took the
|
|
209
|
+
* head, or the user deleted the row) must not leave a poll loop running
|
|
210
|
+
* forever.
|
|
211
|
+
*/
|
|
212
|
+
async function armRetryIfQueued(session: QueueDeliveryHost): Promise<void> {
|
|
213
|
+
if (await hasQueuedItem(session.sessionId)) armDelivery(session, DELIVERY_RETRY_MS);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Deliver the head, or re-arm and try again later. True when an item was sent.
|
|
218
|
+
*
|
|
219
|
+
* A busy session is the normal case at the moment this runs — the run-end that
|
|
220
|
+
* scheduled it fires while a subagent, a compaction or an ask dialog is still
|
|
221
|
+
* live, and `isBusy()` is true for all three. Returning there is what used to
|
|
222
|
+
* strand the queue permanently: the timer had already been deleted from the map
|
|
223
|
+
* by its own callback, so nothing remained to retry it and no later trigger was
|
|
224
|
+
* guaranteed to arrive. Re-arming keeps the retry owned by this module.
|
|
225
|
+
*/
|
|
226
|
+
async function deliverNext(session: QueueDeliveryHost): Promise<boolean> {
|
|
146
227
|
const sessionId = session.sessionId;
|
|
147
|
-
if (!session.isAlive()
|
|
148
|
-
|
|
149
|
-
|
|
228
|
+
if (!session.isAlive()) return false;
|
|
229
|
+
if (session.isBusy()) {
|
|
230
|
+
await armRetryIfQueued(session);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
const item = await claimHeadQueueItem(sessionId);
|
|
234
|
+
if (!item) return false;
|
|
150
235
|
const ok = await dispatchQueuedItem(session, item);
|
|
151
|
-
if (!ok)
|
|
236
|
+
if (!ok) {
|
|
237
|
+
await requeueHeadQueueItem(sessionId, item);
|
|
238
|
+
// The dispatch threw (provider error, dead child). Retry on the same cadence
|
|
239
|
+
// a busy session gets, so a transient failure drains without user action.
|
|
240
|
+
await armRetryIfQueued(session);
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
152
244
|
}
|
|
153
245
|
|
|
154
246
|
/**
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* concurrent nudges. A failed send re-inserts the claimed row at the head.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { getDb
|
|
22
|
+
import { getDb } from '@/server/db.server';
|
|
23
23
|
import { withTransaction } from '@/shared/lib/db/transaction.server';
|
|
24
24
|
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
25
25
|
import type { QueuedMessage, QueuedMessageModel } from '@/shared/types/chat';
|
|
@@ -154,11 +154,20 @@ export async function reorderQueue(sessionId: string, orderedIds: string[]): Pro
|
|
|
154
154
|
* Atomically claim the head for delivery: read + delete inside one
|
|
155
155
|
* transaction, so a concurrent claimant (second tab, reload nudge racing a
|
|
156
156
|
* run end) can never observe — and send — the same item twice. Null when the
|
|
157
|
-
* queue is empty.
|
|
158
|
-
*
|
|
157
|
+
* queue is empty.
|
|
158
|
+
*
|
|
159
|
+
* Uses the ASYNC `getDb()` handle rather than `getDbSync()`, even though the
|
|
160
|
+
* transaction block itself is synchronous (`bun:sqlite` is sync, so BEGIN…COMMIT
|
|
161
|
+
* cannot interleave with another request and the atomicity above is unaffected
|
|
162
|
+
* by the await before it). `getDbSync()` throws when its module-local resolved
|
|
163
|
+
* handle is unset, which a `bun --hot` reload of `db.server.ts` does until some
|
|
164
|
+
* other request calls `getDb()` — and the delivery path runs from a timer, so
|
|
165
|
+
* nothing else necessarily would. That throw was swallowed by a fire-and-forget
|
|
166
|
+
* call and stranded the queue silently, with nudges failing the same way.
|
|
167
|
+
* `getDb()` re-initializes instead, so a delivery heals the state it needs.
|
|
159
168
|
*/
|
|
160
|
-
export function claimHeadQueueItem(sessionId: string): QueuedMessage | null {
|
|
161
|
-
const db =
|
|
169
|
+
export async function claimHeadQueueItem(sessionId: string): Promise<QueuedMessage | null> {
|
|
170
|
+
const db = await getDb();
|
|
162
171
|
return withTransaction(db, () => {
|
|
163
172
|
const row = db.raw
|
|
164
173
|
.query('SELECT * FROM queued_messages WHERE session_id = ? ORDER BY position ASC LIMIT 1')
|
|
@@ -169,6 +178,22 @@ export function claimHeadQueueItem(sessionId: string): QueuedMessage | null {
|
|
|
169
178
|
});
|
|
170
179
|
}
|
|
171
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Whether this session has anything queued, without claiming it.
|
|
183
|
+
*
|
|
184
|
+
* Used by the delivery retry to decide whether a retry is still worth arming:
|
|
185
|
+
* a busy session whose queue emptied (delivered elsewhere, or deleted by the
|
|
186
|
+
* user) must not leave a poll loop running. Async for the same reason the claim
|
|
187
|
+
* is — `getDb()` heals a hot-reloaded module where `getDbSync()` would throw.
|
|
188
|
+
*/
|
|
189
|
+
export async function hasQueuedItem(sessionId: string): Promise<boolean> {
|
|
190
|
+
const db = await getDb();
|
|
191
|
+
const row = db.raw
|
|
192
|
+
.query('SELECT 1 AS present FROM queued_messages WHERE session_id = ? LIMIT 1')
|
|
193
|
+
.get(sessionId) as { present?: number } | undefined;
|
|
194
|
+
return row?.present === 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
172
197
|
/**
|
|
173
198
|
* Put a failed delivery back at the head (position below the current minimum)
|
|
174
199
|
* so it is retried before anything else. The next run end or mount nudge
|