@kabmo-labs/kabmo 0.3.1 → 0.5.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/client/index.html +1 -1
- package/dist/client/index.js +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-cs3b557t.js} +792 -792
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
- package/src/client/components/mobile/RightSidebar.tsx +30 -36
- package/src/client/components/mobile/SessionSidebar.tsx +124 -101
- package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +47 -14
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
- package/src/client/components/mobile/panel-chips.test.tsx +155 -0
- package/src/client/components/mobile/panel-chips.tsx +74 -0
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +15 -10
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +162 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/ui/toasts.ts +12 -1
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/ui/titlebar.test.ts +124 -0
- package/src/shared/lib/ui/titlebar.ts +54 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
- package/src/shared/lib/workspace/mobile-dock.ts +55 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- package/src/test-support/silent-socket.ts +69 -0
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The left dock's state, as the desktop layout and its bar read it.
|
|
8
|
+
*
|
|
9
|
+
* Backed by the GLOBAL `app_settings.omp_left_panels` (see
|
|
10
|
+
* `shared/lib/workspace/left-dock.ts`), not by `layout.*` session state: the
|
|
11
|
+
* dock lists the same views in every chat, so a placement stored per session
|
|
12
|
+
* would move the user's arrangement when they picked another session.
|
|
13
|
+
*
|
|
14
|
+
* The read is synchronous from the primed settings snapshot, so the first render
|
|
15
|
+
* already knows whether the bar is drawn. A write notifies every reader on
|
|
16
|
+
* screen, because they all resolve against this one module-level cache.
|
|
17
|
+
*
|
|
18
|
+
* The snapshot is compared by IDENTITY of the stored value: `writeSetting`
|
|
19
|
+
* replaces it with the exact object a write handed it, so a reference comparison
|
|
20
|
+
* is both correct and free — a rebuilt object would make
|
|
21
|
+
* `useSyncExternalStore` see a change on every render and loop.
|
|
22
|
+
*/
|
|
23
|
+
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
24
|
+
import { useSyncExternalStore } from 'preact/compat';
|
|
25
|
+
import { readKabmoSetting } from '@/shared/lib/settings/client';
|
|
26
|
+
import {
|
|
27
|
+
LEFT_DOCK_SETTING_KEY,
|
|
28
|
+
readLeftDock,
|
|
29
|
+
setPanelSide as persistPanelSide,
|
|
30
|
+
writeLeftDock,
|
|
31
|
+
type LeftDockState,
|
|
32
|
+
type PanelSide,
|
|
33
|
+
} from '@/shared/lib/workspace/left-dock';
|
|
34
|
+
import { resolveLeftDockWidth, saveLeftDockWidth } from '@/shared/lib/workspace/left-dock-width';
|
|
35
|
+
|
|
36
|
+
const listeners = new Set<() => void>();
|
|
37
|
+
|
|
38
|
+
/** The stored value the cache was built from, and the state it produced. */
|
|
39
|
+
let lastSource: unknown;
|
|
40
|
+
let cached: LeftDockState = { enabled: false, panels: [] };
|
|
41
|
+
|
|
42
|
+
function subscribe(listener: () => void): () => void {
|
|
43
|
+
listeners.add(listener);
|
|
44
|
+
return () => listeners.delete(listener);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function snapshot(): LeftDockState {
|
|
48
|
+
const source = readKabmoSetting<unknown>(LEFT_DOCK_SETTING_KEY);
|
|
49
|
+
if (source === lastSource) return cached;
|
|
50
|
+
lastSource = source;
|
|
51
|
+
cached = readLeftDock();
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function publish(): void {
|
|
56
|
+
lastSource = undefined;
|
|
57
|
+
for (const listener of listeners) listener();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LeftDockHandle extends LeftDockState {
|
|
61
|
+
/** Draw the dock's activity bar. Off by default. */
|
|
62
|
+
setEnabled: (enabled: boolean) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Place a plugin panel on the left, or send it back to the right.
|
|
65
|
+
*
|
|
66
|
+
* Placing a panel TURNS THE RAIL ON. The rail exists to reach the panels on
|
|
67
|
+
* it, so leaving it off after a successful placement would be a move with no
|
|
68
|
+
* button, no view and no way to undo it from the bar — the settings row would
|
|
69
|
+
* be the only control, which is exactly the state a user reads as broken.
|
|
70
|
+
* Moving a panel back to the right does NOT turn the rail off: that is the
|
|
71
|
+
* user's switch, and the dock may still be worth showing.
|
|
72
|
+
*/
|
|
73
|
+
setSide: (panelId: string, side: PanelSide) => void;
|
|
74
|
+
/** The width this view opens at, and the writer a drag commits through. */
|
|
75
|
+
widthOf: (viewId: string) => number;
|
|
76
|
+
commitWidth: (viewId: string, px: number) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useLeftDock(): LeftDockHandle {
|
|
80
|
+
const state = useSyncExternalStore(subscribe, snapshot);
|
|
81
|
+
|
|
82
|
+
const setEnabled = useCallback((enabled: boolean) => {
|
|
83
|
+
writeLeftDock({ enabled });
|
|
84
|
+
publish();
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const setSide = useCallback((panelId: string, side: PanelSide) => {
|
|
88
|
+
// The coupling — placing a panel also shows the rail — lives in the store,
|
|
89
|
+
// so a second caller cannot produce a placement nobody can reach.
|
|
90
|
+
persistPanelSide(panelId, side);
|
|
91
|
+
publish();
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const commitWidth = useCallback((viewId: string, px: number) => {
|
|
95
|
+
saveLeftDockWidth(viewId, px);
|
|
96
|
+
publish();
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
setEnabled,
|
|
102
|
+
setSide,
|
|
103
|
+
widthOf: resolveLeftDockWidth,
|
|
104
|
+
commitWidth,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The dock's width for ONE view.
|
|
110
|
+
*
|
|
111
|
+
* Per view because a session list reads at 280px and a plugin's table does not:
|
|
112
|
+
* a column that snapped to the list's width the moment the user switched views
|
|
113
|
+
* would be a column with one width that is wrong for every second view.
|
|
114
|
+
*/
|
|
115
|
+
export function useLeftDockWidth(viewId: string): [number, (px: number) => void] {
|
|
116
|
+
const dock = useLeftDock();
|
|
117
|
+
const [width, setWidth] = useState(() => dock.widthOf(viewId));
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
setWidth(dock.widthOf(viewId));
|
|
121
|
+
}, [viewId, dock]);
|
|
122
|
+
|
|
123
|
+
const commitWidth = useCallback(
|
|
124
|
+
(px: number) => {
|
|
125
|
+
dock.commitWidth(viewId, px);
|
|
126
|
+
setWidth(dock.widthOf(viewId));
|
|
127
|
+
},
|
|
128
|
+
[dock, viewId],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return [width, commitWidth];
|
|
132
|
+
}
|
|
@@ -123,6 +123,19 @@ export function seedPanelRegistry(payload: PanelRegistryPayload): void {
|
|
|
123
123
|
adoptPanelRegistry(payload);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Forget the adopted payload — a test seam, and nothing else.
|
|
128
|
+
*
|
|
129
|
+
* The store is anchored on `globalThis` (a `bun --hot` reload must not lose it),
|
|
130
|
+
* which is also why it survives between test files in one `bun test` process: a
|
|
131
|
+
* suite that adopts a payload with a plugin in it leaves every later suite
|
|
132
|
+
* drawing that plugin's button. A file that adopts one has to put the store
|
|
133
|
+
* back.
|
|
134
|
+
*/
|
|
135
|
+
export function resetPanelRegistry(): void {
|
|
136
|
+
globalThis.__kabmoPanelRegistry = undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
/**
|
|
127
140
|
* Re-read the marketplace from disk, bypassing the server's scan cache.
|
|
128
141
|
*
|
package/src/client/tailwind.css
CHANGED
|
@@ -182,6 +182,28 @@
|
|
|
182
182
|
overflow-y: auto;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/* The composer reads at the transcript's own size — 13px — EXCEPT on iOS.
|
|
186
|
+
*
|
|
187
|
+
* Every message row in the timeline is `text-[13px] leading-relaxed`, and a
|
|
188
|
+
* draft written at 16px looked like a different document pasted under the
|
|
189
|
+
* conversation. The size itself is a utility on the textarea; this rule exists
|
|
190
|
+
* for the one platform where 13px is not allowed to apply.
|
|
191
|
+
*
|
|
192
|
+
* iOS Safari ZOOMS the viewport when a text control below 16px takes focus,
|
|
193
|
+
* and it does not zoom back out — the page is left wider than the screen with
|
|
194
|
+
* the composer half off it. So iOS keeps 16px and every other platform reads
|
|
195
|
+
* at the transcript's size. `-webkit-touch-callout` is the standard
|
|
196
|
+
* iOS-only probe (it is a Safari-only property), and it cannot be expressed as
|
|
197
|
+
* a Tailwind variant, which is why this is a class rather than a utility.
|
|
198
|
+
*
|
|
199
|
+
* The PLACEHOLDER needs no rule of its own: `::placeholder` inherits
|
|
200
|
+
* `font-size` from its textarea, so both move together. */
|
|
201
|
+
@supports (-webkit-touch-callout: none) {
|
|
202
|
+
textarea.composer-type {
|
|
203
|
+
font-size: 16px;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
185
207
|
/* Reveal-on-hover controls are written `opacity-0 group-hover:opacity-100`
|
|
186
208
|
* (or `hidden group-hover:flex`), and Tailwind v4 emits every one of those
|
|
187
209
|
* variants inside `@media (hover: hover)` — verified in the emitted
|
|
@@ -33,7 +33,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
|
33
33
|
import { homedir, tmpdir } from 'node:os';
|
|
34
34
|
import * as path from 'node:path';
|
|
35
35
|
|
|
36
|
-
import { getDatabasePath, getDb } from '@/server/db.server';
|
|
36
|
+
import { closeDb, getDatabasePath, getDb } from '@/server/db.server';
|
|
37
37
|
import { removeDir } from '@/test-support/remove-dir';
|
|
38
38
|
|
|
39
39
|
const saved = {
|
|
@@ -151,3 +151,54 @@ describe('a failed open is not cached', () => {
|
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
describe('closeDb', () => {
|
|
156
|
+
test('retires an open that is still in flight, and closes its handle', async () => {
|
|
157
|
+
// The bug this pins, measured on the Release gate: `releaseDb` cleared the
|
|
158
|
+
// slot while `getDb()` was still opening, so the continuation assigned
|
|
159
|
+
// `resolved` on the DROPPED slot object. Nothing could reach that handle
|
|
160
|
+
// again, and on Windows its temp directory stayed locked — `EBUSY … a
|
|
161
|
+
// handle outlived its test`, reported against the two PRECEDING cases.
|
|
162
|
+
const savedSlot = globalThis.__kabmoDb;
|
|
163
|
+
try {
|
|
164
|
+
Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'in-flight', 'db.sqlite');
|
|
165
|
+
globalThis.__kabmoDb = undefined;
|
|
166
|
+
|
|
167
|
+
// Start the open and retire the slot in the SAME tick, so the async
|
|
168
|
+
// continuation is still pending when `closeDb` runs.
|
|
169
|
+
const pending = getDb();
|
|
170
|
+
const retired = globalThis.__kabmoDb as { disposed?: boolean } | undefined;
|
|
171
|
+
closeDb();
|
|
172
|
+
expect(retired?.disposed).toBe(true);
|
|
173
|
+
|
|
174
|
+
// The open settles as a refusal rather than handing back a handle whose
|
|
175
|
+
// owner is gone, and the slot stays clear for the next caller.
|
|
176
|
+
await expect(pending).rejects.toThrow(/retired/);
|
|
177
|
+
expect(globalThis.__kabmoDb ?? null).toBeNull();
|
|
178
|
+
|
|
179
|
+
// A later open under a NEW path works, which is what a test isolating
|
|
180
|
+
// itself between cases needs.
|
|
181
|
+
Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'after', 'db.sqlite');
|
|
182
|
+
const db = await getDb();
|
|
183
|
+
expect(db).toBeDefined();
|
|
184
|
+
closeDb();
|
|
185
|
+
} finally {
|
|
186
|
+
globalThis.__kabmoDb = savedSlot;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('closes a resolved handle and clears the slot', async () => {
|
|
191
|
+
const savedSlot = globalThis.__kabmoDb;
|
|
192
|
+
try {
|
|
193
|
+
Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'resolved', 'db.sqlite');
|
|
194
|
+
globalThis.__kabmoDb = undefined;
|
|
195
|
+
const db = await getDb();
|
|
196
|
+
closeDb();
|
|
197
|
+
expect(globalThis.__kabmoDb ?? null).toBeNull();
|
|
198
|
+
// A closed handle refuses further work rather than silently succeeding.
|
|
199
|
+
expect(() => db.raw.exec('SELECT 1')).toThrow();
|
|
200
|
+
} finally {
|
|
201
|
+
globalThis.__kabmoDb = savedSlot;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
package/src/server/db.server.ts
CHANGED
|
@@ -41,6 +41,19 @@ interface DbSlot {
|
|
|
41
41
|
promise: Promise<DbClient> | null;
|
|
42
42
|
/** Resolved handle for `getDbSync`; hot paths must not await. */
|
|
43
43
|
resolved: DbClient | null;
|
|
44
|
+
/**
|
|
45
|
+
* Set when a caller retired this slot while an open was still in flight.
|
|
46
|
+
*
|
|
47
|
+
* An open is asynchronous (`getDatabasePath` → `createDb` → `initSchema`), so
|
|
48
|
+
* a teardown that clears the slot mid-open does not stop it: the continuation
|
|
49
|
+
* used to keep running and assign `state.resolved` on the DROPPED slot object,
|
|
50
|
+
* leaving an open `bun:sqlite` handle nothing could ever close. On Windows
|
|
51
|
+
* that is a locked temp directory — measured on the Release gate, where
|
|
52
|
+
* `releaseDb` failed with `EBUSY … a handle outlived its test` and reported
|
|
53
|
+
* the two preceding cases as failed. The continuation now closes the handle
|
|
54
|
+
* it opened and rejects instead of caching it.
|
|
55
|
+
*/
|
|
56
|
+
disposed: boolean;
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
declare global {
|
|
@@ -49,7 +62,30 @@ declare global {
|
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
function slot(): DbSlot {
|
|
52
|
-
return (globalThis.__kabmoDb ??= { promise: null, resolved: null });
|
|
65
|
+
return (globalThis.__kabmoDb ??= { promise: null, resolved: null, disposed: false });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Retire the current handle so the next `getDb()` opens the path configured
|
|
70
|
+
* NOW, closing whatever is open.
|
|
71
|
+
*
|
|
72
|
+
* The one caller is `test-support/isolated-db.ts`, but the rule it enforces is
|
|
73
|
+
* a lifecycle one: a handle must never outlive the configuration it was opened
|
|
74
|
+
* under. An in-flight open is marked `disposed` rather than awaited, and the
|
|
75
|
+
* continuation closes its own handle — see {@link DbSlot.disposed}.
|
|
76
|
+
*/
|
|
77
|
+
export function closeDb(): void {
|
|
78
|
+
const state = globalThis.__kabmoDb;
|
|
79
|
+
globalThis.__kabmoDb = undefined;
|
|
80
|
+
if (!state) return;
|
|
81
|
+
state.disposed = true;
|
|
82
|
+
try {
|
|
83
|
+
state.resolved?.raw.close();
|
|
84
|
+
} catch {
|
|
85
|
+
// Already closed by a failed open; the slot is gone either way.
|
|
86
|
+
}
|
|
87
|
+
state.resolved = null;
|
|
88
|
+
state.promise = null;
|
|
53
89
|
}
|
|
54
90
|
|
|
55
91
|
export async function getDatabasePath(): Promise<string> {
|
|
@@ -111,6 +147,18 @@ export async function getDb(): Promise<DbClient> {
|
|
|
111
147
|
|
|
112
148
|
await migrateLegacyProjectSettings(db);
|
|
113
149
|
|
|
150
|
+
// The slot may have been retired while this open was in flight. Assigning
|
|
151
|
+
// `state.resolved` then would cache a handle on an object nothing reaches
|
|
152
|
+
// any more — an open connection whose temp directory can never be deleted.
|
|
153
|
+
if (state.disposed) {
|
|
154
|
+
try {
|
|
155
|
+
db.raw.close();
|
|
156
|
+
} catch {
|
|
157
|
+
// Nothing to do: the caller already moved on.
|
|
158
|
+
}
|
|
159
|
+
throw new Error('database open was retired before it finished');
|
|
160
|
+
}
|
|
161
|
+
|
|
114
162
|
state.resolved = db;
|
|
115
163
|
return db;
|
|
116
164
|
})();
|
package/src/server/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { isMockMode } from '@/server/mock.server';
|
|
|
25
25
|
import { migrateLegacyDataDir } from '@/server/lib/lifecycle/paths';
|
|
26
26
|
import { stopDiscoveryRootsWatch, syncDiscoveryRootsWatch } from '@/server/lib/omp/config/roots-watch.server';
|
|
27
27
|
import { startScheduleRuntime, stopScheduleRuntime } from '@/server/lib/schedule/runtime.server';
|
|
28
|
+
import { startWaitingRuntime, stopWaitingRuntime } from '@/server/lib/waiting/runtime.server';
|
|
28
29
|
import { seedDefaultMarketplace } from '@/server/lib/panels/seed.server';
|
|
29
30
|
import { syncBundledPlugins } from '@/server/lib/panels/sync.server';
|
|
30
31
|
import { startPanelAutoUpdate } from '@/server/lib/panels/auto-update.server';
|
|
@@ -255,6 +256,13 @@ try {
|
|
|
255
256
|
*/
|
|
256
257
|
startScheduleRuntime();
|
|
257
258
|
|
|
259
|
+
/**
|
|
260
|
+
* The waiting-prompt clock: a prompt held until another session finishes. Same
|
|
261
|
+
* shape and the same placement — after the listener and the watchers, because
|
|
262
|
+
* the first sweep may spawn an omp child.
|
|
263
|
+
*/
|
|
264
|
+
startWaitingRuntime();
|
|
265
|
+
|
|
258
266
|
/**
|
|
259
267
|
* Bring installed BUNDLED plugins up to the copy this package ships.
|
|
260
268
|
*
|
|
@@ -320,6 +328,7 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
|
320
328
|
process.once(signal, () => {
|
|
321
329
|
stopDiscoveryRootsWatch();
|
|
322
330
|
stopScheduleRuntime();
|
|
331
|
+
stopWaitingRuntime();
|
|
323
332
|
void app.stop();
|
|
324
333
|
// Plugins own children and listeners nothing else releases, and a detached
|
|
325
334
|
// child outlives its parent — so this is awaited BEFORE the exit. It is
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { clearCompressedCache, compressedAsset, compressedCacheStats } from '@/server/lib/assets/compressed-cache.server';
|
|
9
|
+
|
|
10
|
+
describe('compressedAsset', () => {
|
|
11
|
+
test('computes once for a key and serves the same bytes afterwards', async () => {
|
|
12
|
+
clearCompressedCache();
|
|
13
|
+
let produced = 0;
|
|
14
|
+
const produce = async () => {
|
|
15
|
+
produced += 1;
|
|
16
|
+
return new Uint8Array([1, 2, 3]);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const first = await compressedAsset('k', produce);
|
|
20
|
+
const second = await compressedAsset('k', produce);
|
|
21
|
+
|
|
22
|
+
expect(produced).toBe(1);
|
|
23
|
+
expect(second).toEqual(first);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Two tabs opening a cold instance at the same moment would otherwise both
|
|
27
|
+
// compress a 10 MB bundle. The gate is a deferred promise, not a delay: the
|
|
28
|
+
// second caller must join the first while it is still unresolved.
|
|
29
|
+
test('shares one computation between concurrent callers', async () => {
|
|
30
|
+
clearCompressedCache();
|
|
31
|
+
const gate = Promise.withResolvers<void>();
|
|
32
|
+
let produced = 0;
|
|
33
|
+
const produce = async () => {
|
|
34
|
+
produced += 1;
|
|
35
|
+
await gate.promise;
|
|
36
|
+
return new Uint8Array([9]);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const first = compressedAsset('shared', produce);
|
|
40
|
+
const second = compressedAsset('shared', produce);
|
|
41
|
+
gate.resolve();
|
|
42
|
+
const [a, b] = await Promise.all([first, second]);
|
|
43
|
+
|
|
44
|
+
expect(produced).toBe(1);
|
|
45
|
+
expect(a).toEqual(b);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a different key is a different entry', async () => {
|
|
49
|
+
clearCompressedCache();
|
|
50
|
+
await compressedAsset('one', async () => new Uint8Array([1]));
|
|
51
|
+
await compressedAsset('two', async () => new Uint8Array([2]));
|
|
52
|
+
|
|
53
|
+
expect(compressedCacheStats().entries).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// An entry is keyed by the asset's ETag, so a rebuild is a new key and the
|
|
57
|
+
// old bytes are never asked for again. Retention is bounded by count.
|
|
58
|
+
test('retains a bounded number of entries', async () => {
|
|
59
|
+
clearCompressedCache();
|
|
60
|
+
for (let i = 0; i < 20; i += 1) {
|
|
61
|
+
await compressedAsset(`key-${i}`, async () => new Uint8Array(1024));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stats = compressedCacheStats();
|
|
65
|
+
expect(stats.entries).toBeLessThanOrEqual(8);
|
|
66
|
+
expect(stats.bytes).toBeLessThanOrEqual(48 * 1024 * 1024);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The caller needs the bytes it asked for even when they cannot be kept, or a
|
|
70
|
+
// single asset larger than the budget would fail to serve at all.
|
|
71
|
+
test('returns an asset larger than the whole budget', async () => {
|
|
72
|
+
clearCompressedCache();
|
|
73
|
+
const huge = new Uint8Array(50 * 1024 * 1024);
|
|
74
|
+
const bytes = await compressedAsset('huge', async () => huge);
|
|
75
|
+
|
|
76
|
+
expect(bytes.byteLength).toBe(huge.byteLength);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compressed bytes for a static asset, kept across requests.
|
|
8
|
+
*
|
|
9
|
+
* `plugins/compress.ts` compresses per response, which is right for a 4 KB JSON
|
|
10
|
+
* body and wrong for the client bundle: the production bundle is 10.4 MB, and
|
|
11
|
+
* re-compressing it on every cold request costs ~20 ms of blocked event loop
|
|
12
|
+
* for a result that cannot have changed. Measured on this repo's own bundle:
|
|
13
|
+
* zstd 10.4 MB -> 2.15 MB in 20 ms, gzip -> 2.32 MB in 66 ms.
|
|
14
|
+
*
|
|
15
|
+
* The key is the asset's own ETag plus the encoding, so an entry is valid
|
|
16
|
+
* exactly as long as the bytes it was made from — a rebuild changes the ETag
|
|
17
|
+
* (size and mtime both move) and the old entry simply stops being asked for.
|
|
18
|
+
* That also means no invalidation logic: the map is bounded, and a stale entry
|
|
19
|
+
* is evicted by age, never served.
|
|
20
|
+
*
|
|
21
|
+
* Two bounds, because a bundle-sized entry is not a small one: an entry count
|
|
22
|
+
* and a total byte budget. A single entry larger than the budget is still
|
|
23
|
+
* RETURNED — it just is not retained — since the caller needs the bytes it
|
|
24
|
+
* asked for either way.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Encodings' worth of the biggest asset, plus room for the next build. */
|
|
28
|
+
const MAX_ENTRIES = 8;
|
|
29
|
+
const MAX_TOTAL_BYTES = 48 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const entries = new Map<string, Uint8Array>();
|
|
32
|
+
const inFlight = new Map<string, Promise<Uint8Array>>();
|
|
33
|
+
let totalBytes = 0;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The compressed bytes for `key`, computing them once.
|
|
37
|
+
*
|
|
38
|
+
* Concurrent requests for the same key share one computation: two tabs opening
|
|
39
|
+
* a cold instance at the same moment would otherwise both compress 10 MB.
|
|
40
|
+
*/
|
|
41
|
+
export async function compressedAsset(key: string, produce: () => Promise<Uint8Array>): Promise<Uint8Array> {
|
|
42
|
+
const hit = entries.get(key);
|
|
43
|
+
if (hit) {
|
|
44
|
+
// Re-insert so the map's own order stays least-recently-used first.
|
|
45
|
+
entries.delete(key);
|
|
46
|
+
entries.set(key, hit);
|
|
47
|
+
return hit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pending = inFlight.get(key);
|
|
51
|
+
if (pending) return pending;
|
|
52
|
+
|
|
53
|
+
const work = produce().then((bytes) => {
|
|
54
|
+
retain(key, bytes);
|
|
55
|
+
return bytes;
|
|
56
|
+
}).finally(() => {
|
|
57
|
+
inFlight.delete(key);
|
|
58
|
+
});
|
|
59
|
+
inFlight.set(key, work);
|
|
60
|
+
return work;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function retain(key: string, bytes: Uint8Array): void {
|
|
64
|
+
// Too big to keep. The caller already holds the reference, so the response is
|
|
65
|
+
// built from it; retaining it would evict everything else and then itself.
|
|
66
|
+
if (bytes.byteLength > MAX_TOTAL_BYTES) return;
|
|
67
|
+
entries.set(key, bytes);
|
|
68
|
+
totalBytes += bytes.byteLength;
|
|
69
|
+
// Evict oldest first, and never the entry just stored.
|
|
70
|
+
for (const [candidate, value] of entries) {
|
|
71
|
+
if (entries.size <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) break;
|
|
72
|
+
if (candidate === key) continue;
|
|
73
|
+
entries.delete(candidate);
|
|
74
|
+
totalBytes -= value.byteLength;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The retained byte count and entry count — the observable half of the cache. */
|
|
79
|
+
export function compressedCacheStats(): { entries: number; bytes: number } {
|
|
80
|
+
return { entries: entries.size, bytes: totalBytes };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Drop everything. Tests only; nothing at runtime has a reason to call this. */
|
|
84
|
+
export function clearCompressedCache(): void {
|
|
85
|
+
entries.clear();
|
|
86
|
+
inFlight.clear();
|
|
87
|
+
totalBytes = 0;
|
|
88
|
+
}
|
|
@@ -194,6 +194,7 @@ describe('schema bootstrap', () => {
|
|
|
194
194
|
'workspace_folders', 'sessions', 'app_settings', 'chat_sessions', 'deleted_workspaces',
|
|
195
195
|
'archived_sessions', 'session_ui_state', 'queued_messages', 'session_stream_state',
|
|
196
196
|
'files', 'btw_topics', 'btw_turns', 'scheduled_tasks', 'scheduled_task_runs',
|
|
197
|
+
'waiting_prompts',
|
|
197
198
|
]) {
|
|
198
199
|
expect(first).toContain(expected);
|
|
199
200
|
}
|
|
@@ -15,6 +15,7 @@ import type { DbClient } from '@/server/lib/db/client';
|
|
|
15
15
|
import { migrateQueueTableFk } from '@/server/lib/queue/schema-migration.server';
|
|
16
16
|
import { ensureBtwSchema } from '@/server/lib/btw/schema.server';
|
|
17
17
|
import { ensureScheduleSchema } from '@/server/lib/schedule/schema.server';
|
|
18
|
+
import { ensureWaitingSchema } from '@/server/lib/waiting/schema.server';
|
|
18
19
|
import { ensurePluginSchema } from '@/server/lib/plugins/schema.server';
|
|
19
20
|
import { migrateWorkspaceFolderColumns } from '@/shared/lib/workspace/schema-migrations';
|
|
20
21
|
import { migrateSessionStreamStateColumns } from '@/shared/lib/omp/session/schema-migrations';
|
|
@@ -104,6 +105,8 @@ export async function initSchema(db: DbClient): Promise<void> {
|
|
|
104
105
|
await ensureBtwSchema(db);
|
|
105
106
|
// Scheduled-task tables, same reasoning: their own module.
|
|
106
107
|
await ensureScheduleSchema(db);
|
|
108
|
+
// Prompts held until another session finishes, same reasoning.
|
|
109
|
+
await ensureWaitingSchema(db);
|
|
107
110
|
// The plugin platform's own tables (kv, records, settings).
|
|
108
111
|
await ensurePluginSchema(db);
|
|
109
112
|
|
|
@@ -121,7 +121,7 @@ describe('resolveRoot / resolveReferencedPath / getDefaultFsRoot', () => {
|
|
|
121
121
|
Bun.env.SYNC_WORKSPACE = 'false';
|
|
122
122
|
Bun.env.KABMO_DB_PATH = dbPath;
|
|
123
123
|
delete Bun.env.DB_PATH;
|
|
124
|
-
globalThis.__kabmoDb = { promise: null, resolved: null };
|
|
124
|
+
globalThis.__kabmoDb = { promise: null, resolved: null, disposed: false };
|
|
125
125
|
const db = await getDb();
|
|
126
126
|
await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', registeredDir]);
|
|
127
127
|
});
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type AutoTitleHost,
|
|
33
33
|
} from '@/server/lib/omp/session/auto-title.server';
|
|
34
34
|
import { scheduleQueueDelivery, type QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
|
|
35
|
+
import { requestWaitingSweep } from '@/server/lib/waiting/sweep-trigger';
|
|
35
36
|
import { markStreamStatus, loadStreamStatuses } from '@/shared/lib/omp/session/stream-state.server';
|
|
36
37
|
import { driveGoalAfterTurn } from '@/server/lib/omp/session/goal-driver.server';
|
|
37
38
|
import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
@@ -81,6 +82,12 @@ export function settleTerminalRun(host: TerminalSettleHost, messages: unknown):
|
|
|
81
82
|
void driveGoalAfterTurn({ host, messages });
|
|
82
83
|
scheduleQueueDelivery(host);
|
|
83
84
|
}
|
|
85
|
+
// A prompt held until this session finished is released HERE, and not only by
|
|
86
|
+
// the sweep tick: the run end is the event the wait was about. The delay is
|
|
87
|
+
// the settle grace — the stream row this frame just wrote needs to age past it
|
|
88
|
+
// before the dependency reads as finished, and a queued follow-up armed by the
|
|
89
|
+
// line above needs its window to go out first.
|
|
90
|
+
requestWaitingSweep();
|
|
84
91
|
// A run's work may have touched files and certainly advanced usage; the
|
|
85
92
|
// realtime layer republishes whichever workspace topics are actually watched
|
|
86
93
|
// (see `republishWatchedWorkspaceTopics`).
|
|
@@ -34,6 +34,7 @@ import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
|
34
34
|
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
35
35
|
import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
36
36
|
import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
37
|
+
import { failWaitingOn } from '@/server/lib/waiting/store.server';
|
|
37
38
|
import { siblingDirForSession } from '@/server/lib/omp/subagent/history/paths';
|
|
38
39
|
import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
|
|
39
40
|
|
|
@@ -122,6 +123,13 @@ export async function purgeSessionData(sessionId: string): Promise<SessionPurgeR
|
|
|
122
123
|
const db = await getDb();
|
|
123
124
|
const rowChanges = await purgeKabmoRows(db, sessionId);
|
|
124
125
|
|
|
126
|
+
// A prompt waiting on this session will never be released: the dependency is
|
|
127
|
+
// gone. Fail it BY NAME rather than letting it wait for a session that no
|
|
128
|
+
// longer exists — and keep the row, because its error text is the only thing
|
|
129
|
+
// that tells the user what happened to the prompt they wrote.
|
|
130
|
+
const strandedPrompts = await failWaitingOn(sessionId, 'The session being waited for was deleted.');
|
|
131
|
+
if (strandedPrompts > 0) emitRealtimeSignal('waiting-changed');
|
|
132
|
+
|
|
125
133
|
// Both caches outlive the mutation by their own keys: the file list/scan pair
|
|
126
134
|
// is mtime-keyed (a delete bumps the parent directory, but the memo can still
|
|
127
135
|
// answer first) and the sidebar dataset is TTL'd, so without this the deleted
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
25
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { tmpdir } from 'node:os';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
28
|
import { testPosix } from '@/test-support/platform';
|
|
@@ -39,6 +39,7 @@ import { getRpcSession, startRpcSession } from '@/server/lib/omp/rpc/session-reg
|
|
|
39
39
|
import { removeDir } from '@/test-support/remove-dir';
|
|
40
40
|
|
|
41
41
|
let root: string;
|
|
42
|
+
let holderLog = '';
|
|
42
43
|
let savedXdg: string | undefined;
|
|
43
44
|
let savedDbPath: string | undefined;
|
|
44
45
|
let savedAgentDir: string | undefined;
|
|
@@ -89,7 +90,12 @@ async function holdLeaseInChild(sessionId: string): Promise<number> {
|
|
|
89
90
|
// The background process must NOT inherit the wrapper's stdout pipe, or
|
|
90
91
|
// reading `$!` would block until that process exits.
|
|
91
92
|
const inner = `exec ${JSON.stringify(process.execPath)} -e ${shellQuote(script)} ${shellQuote(lease)}`;
|
|
92
|
-
|
|
93
|
+
// The holder's stderr goes to a FILE, not to `ignore`: when the child fails
|
|
94
|
+
// to take the lock (a missing libc, a broken FFI bind, a Bun that could not
|
|
95
|
+
// start), the reason is the only useful evidence, and discarding it is what
|
|
96
|
+
// made the Linux CI failure a bare "expected promise that rejects".
|
|
97
|
+
holderLog = join(root, 'holder.log');
|
|
98
|
+
const child = Bun.spawn(['sh', '-c', `{ ${inner} >>${shellQuote(holderLog)} 2>&1 & } ; echo $!`], {
|
|
93
99
|
stdout: 'pipe',
|
|
94
100
|
stderr: 'ignore',
|
|
95
101
|
env: { ...Bun.env, XDG_STATE_HOME: root, PI_CODING_AGENT_DIR: join(root, 'agent') },
|
|
@@ -104,10 +110,45 @@ function shellQuote(value: string): string {
|
|
|
104
110
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* How long a detached `sh -c` holder gets to take the lock.
|
|
115
|
+
*
|
|
116
|
+
* Polling real time is unavoidable here: the condition is an OS `flock` taken
|
|
117
|
+
* by ANOTHER process, which no fake timer can advance. The budget is generous
|
|
118
|
+
* on purpose — a cold CI runner spawns Bun and imports the FFI helper before it
|
|
119
|
+
* locks — and a miss is an ERROR, never a silent proceed.
|
|
120
|
+
*/
|
|
121
|
+
const FOREIGN_HOLDER_TIMEOUT_MS = 15_000;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Wait until a process other than this one actually holds the lease.
|
|
125
|
+
*
|
|
126
|
+
* THROWS rather than returning on timeout. It used to give up silently after
|
|
127
|
+
* 3 s, which turned an infrastructure gap into a green-looking test that proved
|
|
128
|
+
* nothing: `startRpcSession` then found no foreign owner, spawned normally, and
|
|
129
|
+
* the assertion below failed with "Expected promise that rejects" — measured on
|
|
130
|
+
* the Linux CI leg, where the helper burned its whole budget (3075 ms) and the
|
|
131
|
+
* real reason (the detached holder never took the lock) was nowhere in the
|
|
132
|
+
* output. A slow cold runner is a reason to wait longer, never to proceed.
|
|
133
|
+
*/
|
|
108
134
|
async function waitForForeignHolder(leasePath: string): Promise<void> {
|
|
109
|
-
|
|
135
|
+
const deadline = Date.now() + FOREIGN_HOLDER_TIMEOUT_MS;
|
|
136
|
+
for (;;) {
|
|
110
137
|
if (leaseHeldByAnother(leasePath) === true && fileHolderPid(leasePath) !== process.pid) return;
|
|
138
|
+
if (Date.now() >= deadline) {
|
|
139
|
+
const holder = fileHolderPid(leasePath);
|
|
140
|
+
let log = '';
|
|
141
|
+
try {
|
|
142
|
+
log = readFileSync(holderLog, 'utf8').trim();
|
|
143
|
+
} catch {
|
|
144
|
+
// The child never wrote anything: it did not start.
|
|
145
|
+
}
|
|
146
|
+
throw new Error(
|
|
147
|
+
`no foreign holder took ${leasePath} within ${FOREIGN_HOLDER_TIMEOUT_MS} ms ` +
|
|
148
|
+
`(held=${String(leaseHeldByAnother(leasePath))}, holderPid=${String(holder)}, ` +
|
|
149
|
+
`childPids=${JSON.stringify(children)})${log ? `\nholder log:\n${log}` : '\nholder log: (empty)'}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
111
152
|
await Bun.sleep(10);
|
|
112
153
|
}
|
|
113
154
|
}
|