@kabmo-labs/kabmo 0.3.1 → 0.4.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.
Files changed (135) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,313 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The waiting-prompt clock: a prompt held until another session finishes.
8
+ *
9
+ * Same shape as the scheduled-task runtime, and for the same reason — omp has
10
+ * no notion of a prompt that has not been sent, so the wait is Kabmo's own and
11
+ * has to outlive the tab that created it. A client-side watcher would be gone
12
+ * on reload, invisible to a second tab, and unable to survive a closed browser.
13
+ *
14
+ * Two triggers keep it moving, plus a tick:
15
+ *
16
+ * 1. a dependency's run end (`terminal-settle.ts`) schedules a sweep, so the
17
+ * normal case dispatches within a second;
18
+ * 2. the follow-up queue's delivery path schedules one when it drains, so a
19
+ * dependency that had a queue of its own is not waited on past its last
20
+ * prompt;
21
+ * 3. a 5 s tick covers a lost timer, a restart, and a dependency that ends
22
+ * while this process was not watching (a second Kabmo instance owns the
23
+ * child, but the ROW is shared).
24
+ *
25
+ * The claim in `store.server.ts` is what makes several processes safe: only the
26
+ * caller whose UPDATE changed the row may spawn.
27
+ */
28
+
29
+ import {
30
+ startNewRpcSession,
31
+ type AgentSessionWrapper,
32
+ } from '@/server/lib/omp/rpc/manager';
33
+ import { getRpcSession } from '@/server/lib/omp/rpc/session-registry';
34
+ import { kabmoModeEnv } from '@/server/lib/omp/extensions/locator';
35
+ import { hasQueuedItem } from '@/server/lib/queue/store.server';
36
+ import { hasPendingDelivery } from '@/server/lib/queue/delivery.server';
37
+ import { applyModelSnapshot, promptCommand } from '@/server/lib/queue/payload';
38
+ import { loadStreamStates } from '@/shared/lib/omp/session/stream-state.server';
39
+ import { streamRowWrittenAt } from '@/server/lib/waiting/row-clock.server';
40
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
41
+ import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
42
+ import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
43
+ import { publishSidebarStructure } from '@/server/lib/realtime/topics.server';
44
+ import { withTimeout } from '@/server/lib/util/with-timeout';
45
+ import { dependencyVerdict, type DependencyState } from '@/server/lib/waiting/settle';
46
+ import { setWaitingSweepHandler } from '@/server/lib/waiting/sweep-trigger';
47
+ import {
48
+ claimWaitingPrompt,
49
+ countWaitingAttempt,
50
+ failWaitingPrompt,
51
+ listWaitingPrompts,
52
+ markWaitingDispatched,
53
+ pruneWaitingPrompts,
54
+ requeueWaitingPrompt,
55
+ } from '@/server/lib/waiting/store.server';
56
+ import type { WaitingPrompt } from '@/shared/types/waiting';
57
+
58
+ /** How often the clock is consulted. */
59
+ const TICK_MS = 5_000;
60
+
61
+ /** Delay before the first tick: boot work owns the first seconds. */
62
+ const FIRST_TICK_DELAY_MS = 3_000;
63
+
64
+ /** How long a spawn + delivery may take before the attempt is called failed. */
65
+ const DISPATCH_TIMEOUT_MS = 120_000;
66
+
67
+ /** A prompt whose dispatch failed this many times is reported, not retried. */
68
+ export const MAX_DISPATCH_ATTEMPTS = 3;
69
+
70
+ /** The prompt-sending surface a dispatch needs; narrower than the wrapper so a
71
+ * test can drive the whole path without spawning a process. */
72
+ export interface WaitingSessionHandle {
73
+ send(command: Record<string, unknown>): Promise<unknown>;
74
+ }
75
+
76
+ /**
77
+ * Everything a dispatch reaches outside this module, injected rather than
78
+ * imported at the call site — the same reason `ScheduleDeps` is: `mock.module`
79
+ * would swap the registry entry for every other suite in the same `bun test`
80
+ * run, and the store and the spawn path are both process-wide singletons.
81
+ */
82
+ export interface WaitingDeps {
83
+ startNewRpcSession(
84
+ cwd: string,
85
+ mode?: ApprovalMode,
86
+ modeEnv?: Record<string, string>,
87
+ ): Promise<{ session: WaitingSessionHandle; realSessionId: string }>;
88
+ getSession(sessionId: string): AgentSessionWrapper | undefined;
89
+ loadStreamStates(): Promise<StreamRowMap>;
90
+ /** Epoch ms of a session's last status write, or null when unknown. */
91
+ streamRowWrittenAt(sessionId: string): Promise<number | null>;
92
+ hasQueuedItem(sessionId: string): Promise<boolean>;
93
+ hasPendingDelivery(sessionId: string): boolean;
94
+ loadPersistedAccessMode(): Promise<ApprovalMode>;
95
+ listWaitingPrompts(): Promise<WaitingPrompt[]>;
96
+ claimWaitingPrompt(id: string): Promise<boolean>;
97
+ countWaitingAttempt(id: string): Promise<number>;
98
+ markWaitingDispatched(id: string, sessionId: string): Promise<void>;
99
+ requeueWaitingPrompt(id: string, error: string): Promise<void>;
100
+ failWaitingPrompt(id: string, error: string): Promise<void>;
101
+ pruneWaitingPrompts(): Promise<number>;
102
+ }
103
+
104
+ /**
105
+ * Built lazily, never as a module-level constant.
106
+ *
107
+ * `terminal-settle.ts` imports this module (a run end schedules a sweep) and
108
+ * this module reaches back into the RPC registry — a cycle. Reading the spawn
109
+ * function while the registry module is still evaluating would capture an
110
+ * uninitialized binding, so the object is assembled at call time instead.
111
+ */
112
+ function defaultDeps(): WaitingDeps {
113
+ return {
114
+ startNewRpcSession,
115
+ getSession: getRpcSession,
116
+ loadStreamStates,
117
+ streamRowWrittenAt,
118
+ hasQueuedItem,
119
+ hasPendingDelivery,
120
+ loadPersistedAccessMode,
121
+ listWaitingPrompts: () => listWaitingPrompts(),
122
+ claimWaitingPrompt,
123
+ countWaitingAttempt,
124
+ markWaitingDispatched,
125
+ requeueWaitingPrompt,
126
+ failWaitingPrompt,
127
+ pruneWaitingPrompts: () => pruneWaitingPrompts(),
128
+ };
129
+ }
130
+
131
+ interface WaitingRuntimeSlot {
132
+ timer: ReturnType<typeof setInterval> | null;
133
+ /** In-flight sweep, so a slow dispatch does not overlap the next tick. */
134
+ sweeping: boolean;
135
+ /** A one-shot sweep requested by a run end, coalesced. */
136
+ pending: ReturnType<typeof setTimeout> | null;
137
+ }
138
+
139
+ declare global {
140
+ // eslint-disable-next-line no-var
141
+ var __kabmoWaitingRuntime: WaitingRuntimeSlot | undefined;
142
+ }
143
+
144
+ function slot(): WaitingRuntimeSlot {
145
+ return (globalThis.__kabmoWaitingRuntime ??= { timer: null, sweeping: false, pending: null });
146
+ }
147
+
148
+ /** One session's stream row, as the dependency check reads it. */
149
+ export interface StreamRow {
150
+ status: 'stream' | 'finish' | 'abort';
151
+ }
152
+
153
+ /** The stream rows keyed by session id — one read serves a whole sweep. */
154
+ export type StreamRowMap = Record<string, StreamRow>;
155
+
156
+ /** The dependency's state, as `settle.ts` reads it. */
157
+ export async function readDependencyState(
158
+ sessionId: string,
159
+ deps: WaitingDeps,
160
+ streams?: StreamRowMap,
161
+ ): Promise<DependencyState> {
162
+ const rows = streams ?? (await deps.loadStreamStates());
163
+ const wrapper = deps.getSession(sessionId);
164
+ return {
165
+ stream: rows[sessionId],
166
+ rowWrittenAt: await deps.streamRowWrittenAt(sessionId),
167
+ busy: wrapper ? wrapper.isBusy() : false,
168
+ queued: await deps.hasQueuedItem(sessionId),
169
+ delivering: deps.hasPendingDelivery(sessionId),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Spawn the session this prompt belongs to and send it.
175
+ *
176
+ * The prompt is assembled by `queue/payload.ts`, the same builder the follow-up
177
+ * queue uses: both send a prompt nobody typed in this tab, from the same
178
+ * persisted shape.
179
+ */
180
+ async function dispatchWaitingPrompt(
181
+ prompt: WaitingPrompt,
182
+ deps: WaitingDeps,
183
+ ): Promise<{ sessionId: string }> {
184
+ const accessMode = prompt.model?.accessMode ?? (await deps.loadPersistedAccessMode());
185
+ const modeEnv = prompt.modes ? kabmoModeEnv(prompt.modes) : undefined;
186
+ const { session, realSessionId } = await deps.startNewRpcSession(prompt.cwd, accessMode, modeEnv);
187
+ // A CLOSURE, never `session.send` itself: the wrapper's `send` reaches its own
188
+ // runtime state through `this`, so passing the method detached makes it run
189
+ // against `undefined` — measured as `undefined is not an object (evaluating
190
+ // 'host.restarting')` and a prompt recorded as failed.
191
+ const send = (command: Record<string, unknown>) => session.send(command);
192
+ await applyModelSnapshot(send, prompt.model);
193
+ await send(promptCommand({ text: prompt.prompt, attachments: prompt.attachments, model: prompt.model }));
194
+ return { sessionId: realSessionId };
195
+ }
196
+
197
+ /**
198
+ * Sweep every waiting prompt whose dependency has provably finished.
199
+ *
200
+ * Serial on purpose: the claim already decided who may spawn, and several
201
+ * omp children booting at once fight over the CPU the first one needs.
202
+ */
203
+ export async function sweepWaitingPrompts(
204
+ options: { now?: number; deps?: WaitingDeps } = {},
205
+ ): Promise<number> {
206
+ const deps = options.deps ?? defaultDeps();
207
+ const now = options.now ?? Date.now();
208
+ const waiting = (await deps.listWaitingPrompts()).filter((prompt) => prompt.status === 'waiting');
209
+ if (waiting.length === 0) return 0;
210
+
211
+ // One stream read for the whole pass: the rows are one table, and every
212
+ // prompt names a session id in it.
213
+ const streams = await deps.loadStreamStates();
214
+ let dispatched = 0;
215
+ for (const prompt of waiting) {
216
+ const state = await readDependencyState(prompt.dependsOn, deps, streams);
217
+ if (!dependencyVerdict(state, now).settled) continue;
218
+ if (!(await deps.claimWaitingPrompt(prompt.id))) continue;
219
+ try {
220
+ const { sessionId } = await withTimeout(
221
+ dispatchWaitingPrompt(prompt, deps),
222
+ DISPATCH_TIMEOUT_MS,
223
+ 'The waiting prompt was not accepted in time.',
224
+ );
225
+ await deps.markWaitingDispatched(prompt.id, sessionId);
226
+ dispatched += 1;
227
+ // The session exists but omp has not written its transcript: the sidebar
228
+ // row has to come from the structure republish, exactly as it does for a
229
+ // spawn the composer started.
230
+ publishSidebarStructure();
231
+ emitRealtimeSignal('session-attached', sessionId);
232
+ } catch (error) {
233
+ const message = error instanceof Error ? error.message : String(error);
234
+ const attempts = await deps.countWaitingAttempt(prompt.id);
235
+ if (attempts >= MAX_DISPATCH_ATTEMPTS) {
236
+ await deps.failWaitingPrompt(prompt.id, message);
237
+ } else {
238
+ // The prompt has NOT been delivered: returning it to `waiting` retries
239
+ // on the next sweep rather than dropping a prompt the user still wants.
240
+ await deps.requeueWaitingPrompt(prompt.id, message);
241
+ }
242
+ }
243
+ emitRealtimeSignal('waiting-changed');
244
+ }
245
+ // Retention is checked on the same pass: a terminal row is only needed long
246
+ // enough for the client that created it to read the outcome.
247
+ void deps.pruneWaitingPrompts().catch(() => {});
248
+ return dispatched;
249
+ }
250
+
251
+ /** Run one sweep, coalescing a burst of triggers into one pass. */
252
+ function sweepOnce(deps: WaitingDeps): void {
253
+ const state = slot();
254
+ if (state.sweeping) return;
255
+ state.sweeping = true;
256
+ sweepWaitingPrompts({ deps })
257
+ .catch((error) => console.error('[waiting] sweep failed:', error))
258
+ .finally(() => {
259
+ state.sweeping = false;
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Ask for a sweep shortly. Called when a run ends and when a queue drains.
265
+ *
266
+ * The delay is the dependency's own settle grace: sweeping instantly would find
267
+ * the row it just watched end still inside its grace window and do nothing.
268
+ */
269
+ export function scheduleWaitingSweep(delayMs = 1_600): void {
270
+ const state = slot();
271
+ if (state.pending) clearTimeout(state.pending);
272
+ state.pending = setTimeout(() => {
273
+ state.pending = null;
274
+ sweepOnce(defaultDeps());
275
+ }, delayMs);
276
+ state.pending.unref();
277
+ }
278
+
279
+ /** Start the tick. Idempotent: a second call is a no-op while one is running. */
280
+ export function startWaitingRuntime(): void {
281
+ const state = slot();
282
+ if (state.timer) return;
283
+ // The queue's delivery path asks for a sweep through this slot rather than by
284
+ // importing this module: it would close a cycle, because this one reads the
285
+ // queue to decide whether a dependency still has work.
286
+ setWaitingSweepHandler((delayMs) => scheduleWaitingSweep(delayMs));
287
+ const tick = () => sweepOnce(defaultDeps());
288
+ setTimeout(tick, FIRST_TICK_DELAY_MS).unref();
289
+ state.timer = setInterval(tick, TICK_MS);
290
+ // A timer must never hold the process open: the listener is what keeps the
291
+ // server alive, and a shutdown should not wait for the next tick.
292
+ state.timer.unref();
293
+ }
294
+
295
+ /** Stop the tick (shutdown, tests). */
296
+ export function stopWaitingRuntime(): void {
297
+ const state = slot();
298
+ setWaitingSweepHandler(null);
299
+ if (state.pending) {
300
+ clearTimeout(state.pending);
301
+ state.pending = null;
302
+ }
303
+ if (state.timer) {
304
+ clearInterval(state.timer);
305
+ state.timer = null;
306
+ }
307
+ }
308
+
309
+ /** Whether a prompt's dependency currently looks settled (the route's guard). */
310
+ export async function isDependencySettled(sessionId: string, deps: WaitingDeps = defaultDeps()): Promise<boolean> {
311
+ return dependencyVerdict(await readDependencyState(sessionId, deps)).settled;
312
+ }
313
+
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Waiting-prompt schema, kept out of `db.server.ts`'s bootstrap block for the
8
+ * same reason `btw/schema.server.ts` and `schedule/schema.server.ts` are:
9
+ * called once per database open, from `initSchema`.
10
+ *
11
+ * `status` is stored rather than derived. "waiting" is a clock state, and the
12
+ * claim that releases a prompt is a WRITE — two Kabmo instances share this file,
13
+ * so the row itself has to say whether the prompt was already sent.
14
+ */
15
+
16
+ import type { DbClient } from '@/server/lib/db/client';
17
+
18
+ const WAITING_SCHEMA_SQL = `
19
+ CREATE TABLE IF NOT EXISTS waiting_prompts (
20
+ id TEXT PRIMARY KEY,
21
+ pending_id TEXT,
22
+ depends_on TEXT NOT NULL,
23
+ depends_on_title TEXT,
24
+ message TEXT NOT NULL DEFAULT '',
25
+ attachments TEXT NOT NULL DEFAULT '[]',
26
+ provider TEXT,
27
+ model_id TEXT,
28
+ thinking_level TEXT,
29
+ access_mode TEXT,
30
+ modes TEXT,
31
+ cwd TEXT NOT NULL DEFAULT '',
32
+ folder_id INTEGER,
33
+ status TEXT NOT NULL,
34
+ session_id TEXT,
35
+ error TEXT,
36
+ attempts INTEGER NOT NULL DEFAULT 0,
37
+ created_at INTEGER NOT NULL,
38
+ updated_at INTEGER NOT NULL
39
+ );
40
+ CREATE INDEX IF NOT EXISTS idx_waiting_status ON waiting_prompts (status, depends_on);
41
+ `;
42
+
43
+ /** Idempotent: the waiting prompts' own table, created on every database open. */
44
+ export async function ensureWaitingSchema(db: DbClient): Promise<void> {
45
+ await db.exec(WAITING_SCHEMA_SQL);
46
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The dependency check, which is the whole "is it finished yet?" rule.
8
+ *
9
+ * Every case below is a state a real session reaches and a condition whose
10
+ * absence shipped a bug of its own: `finish` written before the turn's
11
+ * bookkeeping settles, a follow-up queued at the run end, a delivery armed
12
+ * 500 ms out, and a row whose one-second clock is younger than the decision.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { dependencyVerdict, SETTLE_GRACE_MS } from '@/server/lib/waiting/settle';
17
+
18
+ const NOW = 1_800_000_000_000;
19
+ const OLD = NOW - SETTLE_GRACE_MS - 5_000;
20
+
21
+ const settled = { busy: false, queued: false, delivering: false };
22
+
23
+ describe('dependencyVerdict', () => {
24
+ test('a session still streaming is never settled', () => {
25
+ expect(dependencyVerdict({ ...settled, stream: { status: 'stream', updatedAt: OLD } }, NOW)).toEqual({
26
+ settled: false,
27
+ reason: 'streaming',
28
+ });
29
+ });
30
+
31
+ test('a finished run with nothing pending is settled', () => {
32
+ expect(dependencyVerdict({ ...settled, stream: { status: 'finish', updatedAt: OLD } }, NOW)).toEqual({
33
+ settled: true,
34
+ reason: null,
35
+ });
36
+ });
37
+
38
+ // Stop is a finish for the purposes of the wait: the user ended the run, and a
39
+ // dependent prompt has no reason to wait for a turn that will never resume.
40
+ test('an aborted run counts as finished', () => {
41
+ expect(dependencyVerdict({ ...settled, stream: { status: 'abort', updatedAt: OLD } }, NOW).settled).toBe(true);
42
+ });
43
+
44
+ test('a session with no stream row at all counts as finished', () => {
45
+ expect(dependencyVerdict({ ...settled, stream: undefined }, NOW)).toEqual({ settled: true, reason: 'no-row' });
46
+ });
47
+
48
+ // The three "there is still work here" conditions, each measured against a run
49
+ // whose status row already says finish.
50
+ test('a live wrapper that is still busy blocks the release', () => {
51
+ expect(dependencyVerdict({ ...settled, busy: true, stream: { status: 'finish', updatedAt: OLD } }, NOW)).toEqual({
52
+ settled: false,
53
+ reason: 'busy',
54
+ });
55
+ });
56
+
57
+ test('a queued follow-up blocks the release', () => {
58
+ expect(dependencyVerdict({ ...settled, queued: true, stream: { status: 'finish', updatedAt: OLD } }, NOW)).toEqual({
59
+ settled: false,
60
+ reason: 'queued',
61
+ });
62
+ });
63
+
64
+ test('an armed delivery slot blocks the release', () => {
65
+ expect(
66
+ dependencyVerdict({ ...settled, delivering: true, stream: { status: 'finish', updatedAt: OLD } }, NOW),
67
+ ).toEqual({ settled: false, reason: 'delivering' });
68
+ });
69
+
70
+ test('a status row younger than the grace window is not yet trusted', () => {
71
+ expect(
72
+ dependencyVerdict(
73
+ { ...settled, stream: { status: 'finish', updatedAt: NOW - SETTLE_GRACE_MS + 1 } },
74
+ NOW,
75
+ ),
76
+ ).toEqual({ settled: false, reason: 'grace' });
77
+ });
78
+
79
+ // The clock is optional: a row written before the column was read has none,
80
+ // and refusing to release on that would strand every prompt created against it.
81
+ test('a row with no clock is settled rather than held forever', () => {
82
+ expect(dependencyVerdict({ ...settled, stream: { status: 'finish', updatedAt: null } }, NOW).settled).toBe(true);
83
+ });
84
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * When is the session a prompt is waiting for actually finished?
8
+ *
9
+ * "The stream row says `finish`" is not the answer on its own, and each of the
10
+ * other conditions below is a measured failure of trusting it alone:
11
+ *
12
+ * - **A live wrapper can still be busy.** `agent_end` is written before the
13
+ * turn's bookkeeping settles, and a compaction, a subagent or an open ask
14
+ * dialog keeps `isBusy()` true with no `stream` row to say so. A prompt sent
15
+ * into that state is refused by omp mid-turn and dropped.
16
+ * - **A queued follow-up is about to be delivered.** The run end arms the
17
+ * queue 500 ms out, so `finish` followed by a claim is the normal sequence
18
+ * for a session the user kept typing into. Waiting on `hasQueuedItem` alone
19
+ * still races the window between the claim and the prompt.
20
+ * - **A pending delivery timer is the same fact, one step earlier.**
21
+ * - **The row's clock is coarse.** `CURRENT_TIMESTAMP` has one-second
22
+ * resolution, so a `finish` written this second may be a row an `agent_start`
23
+ * is about to overwrite. A grace window on top of it closes the gap.
24
+ *
25
+ * The check is deliberately conservative: a dependency that is not yet provably
26
+ * finished is simply re-checked on the next sweep. Nothing is lost by waiting a
27
+ * second longer, and a prompt delivered into a running turn is lost entirely.
28
+ */
29
+
30
+ import type { SessionStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
31
+
32
+ /** Slack applied after a dependency's last stream write, in ms. */
33
+ export const SETTLE_GRACE_MS = 1_500;
34
+
35
+ /** Everything the check reads, injected so a test needs no database or child. */
36
+ export interface DependencyState {
37
+ /** The dependency's stream row, or undefined when it has none. */
38
+ stream: { status: SessionStreamStatus; updatedAt?: number | null } | undefined;
39
+ /** Epoch ms the row was last written, or null when the caller cannot tell. */
40
+ rowWrittenAt?: number | null;
41
+ /** Whether a live wrapper for it is still working (dialog, subagent, turn). */
42
+ busy: boolean;
43
+ /** Whether its follow-up queue still holds an item. */
44
+ queued: boolean;
45
+ /** Whether this process has a delivery slot armed for it. */
46
+ delivering: boolean;
47
+ }
48
+
49
+ export interface DependencyVerdict {
50
+ settled: boolean;
51
+ /** Why it is not settled yet — shown nowhere, logged and asserted in tests. */
52
+ reason: 'no-row' | 'streaming' | 'busy' | 'queued' | 'delivering' | 'grace' | null;
53
+ }
54
+
55
+ /**
56
+ * Whether a dependency has provably finished: no run, no queue, no delivery in
57
+ * flight, and its last status write is older than the grace window.
58
+ */
59
+ export function dependencyVerdict(state: DependencyState, now = Date.now()): DependencyVerdict {
60
+ const status = state.stream?.status;
61
+ if (status === 'stream') return { settled: false, reason: 'streaming' };
62
+ // No row at all is a session Kabmo has never seen run — an id typed by hand,
63
+ // or one whose row the heal pass released. Nothing can be waited on, so it
64
+ // counts as finished rather than blocking the prompt forever.
65
+ if (state.stream === undefined) return { settled: true, reason: 'no-row' };
66
+ if (state.busy) return { settled: false, reason: 'busy' };
67
+ if (state.queued) return { settled: false, reason: 'queued' };
68
+ if (state.delivering) return { settled: false, reason: 'delivering' };
69
+ // The grace is about ORDER, not about the row's own fields: a run end writes
70
+ // `finish` and arms the follow-up queue 500 ms later, so a caller supplies the
71
+ // write's clock separately (see `streamRowWrittenAt`).
72
+ const writtenAt = state.rowWrittenAt ?? state.stream.updatedAt;
73
+ if (typeof writtenAt === 'number' && now - writtenAt < SETTLE_GRACE_MS) {
74
+ return { settled: false, reason: 'grace' };
75
+ }
76
+ return { settled: true, reason: null };
77
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The waiting-prompt store, against a real temp database.
8
+ *
9
+ * The rules pinned here are the ones a second caller would break silently: the
10
+ * claim is what makes two sweeps (or two Kabmo instances on the shared file)
11
+ * safe, a terminal row has to stay readable long enough for the client that
12
+ * created it to learn the outcome, and a retry must not look like a delivery.
13
+ */
14
+
15
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { isolateDb, releaseDb } from '@/test-support/isolated-db';
20
+ import { getDb } from '@/server/db.server';
21
+ import {
22
+ cancelWaitingPrompt,
23
+ claimWaitingPrompt,
24
+ countWaitingAttempt,
25
+ createWaitingPrompt,
26
+ failWaitingOn,
27
+ failWaitingPrompt,
28
+ getWaitingPrompt,
29
+ listWaitingOn,
30
+ listWaitingPrompts,
31
+ markWaitingDispatched,
32
+ pruneWaitingPrompts,
33
+ requeueWaitingPrompt,
34
+ WAITING_RETENTION_MS,
35
+ } from '@/server/lib/waiting/store.server';
36
+
37
+ const ROOT = path.join(os.tmpdir(), `kabmo-waiting-store-${process.pid}`);
38
+
39
+ beforeAll(async () => {
40
+ fs.mkdirSync(ROOT, { recursive: true });
41
+ isolateDb();
42
+ });
43
+
44
+ afterAll(async () => {
45
+ releaseDb();
46
+ fs.rmSync(ROOT, { recursive: true, force: true });
47
+ });
48
+
49
+ beforeEach(async () => {
50
+ const db = await getDb();
51
+ await db.run('DELETE FROM waiting_prompts');
52
+ });
53
+
54
+ const input = {
55
+ pendingId: 'new-1700000000000',
56
+ dependsOn: 'session-a',
57
+ dependsOnTitle: 'Session A',
58
+ prompt: 'summarise the diff',
59
+ attachments: [{ id: 'a1', preview: '', name: 'notes.txt', type: 'text/plain', content: 'hello' }],
60
+ model: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'high', accessMode: 'yolo' },
61
+ modes: { plan: true, goal: false },
62
+ cwd: '/tmp/project',
63
+ folderId: 3,
64
+ };
65
+
66
+ describe('waiting prompt store', () => {
67
+ test('round-trips every field the dispatcher needs', async () => {
68
+ const created = await createWaitingPrompt(input);
69
+ expect(created.status).toBe('waiting');
70
+ const stored = await getWaitingPrompt(created.id);
71
+ expect(stored).toMatchObject({
72
+ pendingId: 'new-1700000000000',
73
+ dependsOn: 'session-a',
74
+ dependsOnTitle: 'Session A',
75
+ prompt: 'summarise the diff',
76
+ cwd: '/tmp/project',
77
+ folderId: 3,
78
+ modes: { plan: true, goal: false },
79
+ });
80
+ expect(stored?.model).toEqual({
81
+ provider: 'anthropic',
82
+ modelId: 'claude',
83
+ thinkingLevel: 'high',
84
+ accessMode: 'yolo',
85
+ });
86
+ expect(stored?.attachments[0]?.content).toBe('hello');
87
+ });
88
+
89
+ test('the claim is won once, and only from `waiting`', async () => {
90
+ const created = await createWaitingPrompt(input);
91
+ expect(await claimWaitingPrompt(created.id)).toBe(true);
92
+ expect(await claimWaitingPrompt(created.id)).toBe(false);
93
+ });
94
+
95
+ test('a requeued prompt can be claimed again', async () => {
96
+ const created = await createWaitingPrompt(input);
97
+ await claimWaitingPrompt(created.id);
98
+ await requeueWaitingPrompt(created.id, 'omp did not start');
99
+ const stored = await getWaitingPrompt(created.id);
100
+ expect(stored?.status).toBe('waiting');
101
+ expect(stored?.error).toBe('omp did not start');
102
+ expect(await claimWaitingPrompt(created.id)).toBe(true);
103
+ });
104
+
105
+ test('a dispatch records the session it became', async () => {
106
+ const created = await createWaitingPrompt(input);
107
+ await claimWaitingPrompt(created.id);
108
+ await markWaitingDispatched(created.id, 'omp-session-9');
109
+ expect((await getWaitingPrompt(created.id))?.sessionId).toBe('omp-session-9');
110
+ });
111
+
112
+ // A claimed prompt that then failed to deliver is a FAILURE, not a delivery:
113
+ // leaving it at `dispatched` would tell the client its prompt arrived.
114
+ test('a claimed prompt can still be failed', async () => {
115
+ const created = await createWaitingPrompt(input);
116
+ await claimWaitingPrompt(created.id);
117
+ await failWaitingPrompt(created.id, 'the child died');
118
+ expect(await getWaitingPrompt(created.id)).toMatchObject({ status: 'error', error: 'the child died' });
119
+ });
120
+
121
+ test('cancel reaches only a prompt that has not been claimed', async () => {
122
+ const created = await createWaitingPrompt(input);
123
+ expect(await cancelWaitingPrompt(created.id)).toBe(true);
124
+ expect((await getWaitingPrompt(created.id))?.status).toBe('cancelled');
125
+ expect(await cancelWaitingPrompt(created.id)).toBe(false);
126
+
127
+ const claimed = await createWaitingPrompt(input);
128
+ await claimWaitingPrompt(claimed.id);
129
+ expect(await cancelWaitingPrompt(claimed.id)).toBe(false);
130
+ expect((await getWaitingPrompt(claimed.id))?.status).toBe('dispatched');
131
+ });
132
+
133
+ test('deleting a session fails everything waiting on it, by name', async () => {
134
+ const first = await createWaitingPrompt(input);
135
+ const second = await createWaitingPrompt({ ...input, dependsOn: 'session-b' });
136
+ expect(await listWaitingOn('session-a')).toHaveLength(1);
137
+ expect(await failWaitingOn('session-a', 'The session being waited for was deleted.')).toBe(1);
138
+ expect(await getWaitingPrompt(first.id)).toMatchObject({
139
+ status: 'error',
140
+ error: 'The session being waited for was deleted.',
141
+ });
142
+ expect((await getWaitingPrompt(second.id))?.status).toBe('waiting');
143
+ });
144
+
145
+ test('the list carries waiting rows plus recent outcomes, and prunes the rest', async () => {
146
+ const waiting = await createWaitingPrompt(input);
147
+ const done = await createWaitingPrompt(input);
148
+ await claimWaitingPrompt(done.id);
149
+ await markWaitingDispatched(done.id, 'omp-1');
150
+
151
+ const now = Date.now();
152
+ expect((await listWaitingPrompts(now)).map((row) => row.id)).toContain(waiting.id);
153
+ expect((await listWaitingPrompts(now)).map((row) => row.id)).toContain(done.id);
154
+
155
+ // Past retention the terminal row goes; the waiting one stays, because it
156
+ // has no outcome yet.
157
+ expect(await pruneWaitingPrompts(now + WAITING_RETENTION_MS + 1)).toBe(1);
158
+ const remaining = await listWaitingPrompts(now + WAITING_RETENTION_MS + 1);
159
+ expect(remaining.map((row) => row.id)).toEqual([waiting.id]);
160
+ });
161
+
162
+ test('attempts are counted for the failure ceiling', async () => {
163
+ const created = await createWaitingPrompt(input);
164
+ expect(await countWaitingAttempt(created.id)).toBe(1);
165
+ expect(await countWaitingAttempt(created.id)).toBe(2);
166
+ });
167
+ });