@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.
- 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-twsk4wfa.js} +791 -791
- 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/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/SessionItem.tsx +30 -5
- 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/RightSidebar.tsx +8 -5
- package/src/client/components/mobile/SessionSidebar.tsx +6 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
- 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 +41 -13
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- 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 +14 -9
- 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 +154 -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/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/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/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/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/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/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -20,9 +20,11 @@ import {
|
|
|
20
20
|
TOPIC_PANELS,
|
|
21
21
|
TOPIC_SCHEDULE,
|
|
22
22
|
TOPIC_USAGE,
|
|
23
|
+
TOPIC_WAITING,
|
|
23
24
|
} from '@/shared/lib/realtime/protocol';
|
|
24
25
|
import { type TopicDescriptor } from '@/server/lib/realtime/hub.server';
|
|
25
26
|
import { listScheduledTasks } from '@/server/lib/schedule/store.server';
|
|
27
|
+
import { listWaitingPrompts } from '@/server/lib/waiting/store.server';
|
|
26
28
|
import { buildUsageReport } from '@/server/lib/usage/report.server';
|
|
27
29
|
import { discoverPanelPlugins } from '@/server/lib/panels/registry.server';
|
|
28
30
|
import { loadModelsWithCache } from '@/server/lib/models/registry.server';
|
|
@@ -34,6 +36,9 @@ import { loadModelsWithCache } from '@/server/lib/models/registry.server';
|
|
|
34
36
|
*/
|
|
35
37
|
const RESOLVERS: Record<string, () => Promise<unknown>> = {
|
|
36
38
|
[TOPIC_SCHEDULE]: () => listScheduledTasks(),
|
|
39
|
+
// The waiting list is the same read the runtime sweeps over, so a client and
|
|
40
|
+
// the dispatcher can never disagree about which prompt is still held.
|
|
41
|
+
[TOPIC_WAITING]: () => listWaitingPrompts(),
|
|
37
42
|
// The SAME builder `GET /api/settings/usage` answers with: the panel renders
|
|
38
43
|
// whichever path arrived first, so the two payloads must not diverge.
|
|
39
44
|
[TOPIC_USAGE]: () => buildUsageReport(),
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
TOPIC_SIDEBAR,
|
|
21
21
|
TOPIC_SIDEBAR_STATUS,
|
|
22
22
|
TOPIC_USAGE,
|
|
23
|
+
TOPIC_WAITING,
|
|
23
24
|
isValidTopic,
|
|
24
25
|
reposTopic,
|
|
25
26
|
sessionTopic,
|
|
@@ -93,6 +94,7 @@ export function initRealtimeTopics(): void {
|
|
|
93
94
|
else if (signal === 'workspace-dirty') republishWatchedWorkspaceTopics();
|
|
94
95
|
else if (signal === 'session-data-dirty' && sessionId) republishSessionDataTopics(sessionId);
|
|
95
96
|
else if (signal === 'repos-scanned' && root) publishTopic(reposTopic(root));
|
|
97
|
+
else if (signal === 'waiting-changed') publishTopic(TOPIC_WAITING);
|
|
96
98
|
});
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reject a promise that has not settled within `ms`.
|
|
8
|
+
*
|
|
9
|
+
* Both server-side dispatchers (a scheduled task, a prompt held until another
|
|
10
|
+
* session finishes) wait on a spawn and then on a prompt ack, and neither has a
|
|
11
|
+
* timeout of its own: an omp child that boots but never answers would leave the
|
|
12
|
+
* dispatch in flight for the life of the process. The caller owns the message,
|
|
13
|
+
* because it is the only place that knows what was being waited for.
|
|
14
|
+
*/
|
|
15
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
16
|
+
const { promise: guarded, resolve, reject } = Promise.withResolvers<T>();
|
|
17
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
18
|
+
promise.then(
|
|
19
|
+
(value) => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
resolve(value);
|
|
22
|
+
},
|
|
23
|
+
(error) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
reject(error);
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
return guarded;
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* When was a session's stream row last written?
|
|
8
|
+
*
|
|
9
|
+
* The dependency check needs this and the SIDEBAR does not, which is why it is
|
|
10
|
+
* a query of its own rather than a field on `SessionStreamState`: that payload
|
|
11
|
+
* is the `sidebar:status` wire contract, and widening it for one caller would
|
|
12
|
+
* put a field on every frame that no consumer reads.
|
|
13
|
+
*
|
|
14
|
+
* The conversion happens in SQLite. `updated_at` is written by
|
|
15
|
+
* `CURRENT_TIMESTAMP` — a UTC string with no zone marker — so parsing it in JS
|
|
16
|
+
* would silently read it as local time. `strftime('%s', …)` yields epoch
|
|
17
|
+
* seconds, which is the resolution the row is written at.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getDb } from '@/server/db.server';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Epoch ms of the session's last stream-status write, or null when the session
|
|
24
|
+
* has no row (or the clock cannot be read).
|
|
25
|
+
*/
|
|
26
|
+
export async function streamRowWrittenAt(sessionId: string): Promise<number | null> {
|
|
27
|
+
try {
|
|
28
|
+
const db = await getDb();
|
|
29
|
+
const row = (await db.get(
|
|
30
|
+
"SELECT CAST(strftime('%s', updated_at) AS INTEGER) AS epoch FROM session_stream_state WHERE session_id = ?",
|
|
31
|
+
[sessionId],
|
|
32
|
+
)) as { epoch?: number | null } | undefined;
|
|
33
|
+
return typeof row?.epoch === 'number' ? row.epoch * 1000 : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The waiting-prompt sweep: what it dispatches, what it holds, and what happens
|
|
8
|
+
* when the dispatch fails.
|
|
9
|
+
*
|
|
10
|
+
* Every outside dependency is INJECTED (`WaitingDeps`), never mocked through
|
|
11
|
+
* `mock.module` — that swaps a registry entry for the whole `bun test` run and
|
|
12
|
+
* broke six unrelated suites the last time it was tried here. Injection keeps
|
|
13
|
+
* the blast radius inside this file, and the rules worth pinning are all about
|
|
14
|
+
* ORDER rather than about omp:
|
|
15
|
+
*
|
|
16
|
+
* - a prompt is dispatched exactly once, even for two sweeps that race;
|
|
17
|
+
* - a dependency with a queue still waiting holds the prompt;
|
|
18
|
+
* - a dispatch that throws is retried, then reported, and never silently
|
|
19
|
+
* dropped;
|
|
20
|
+
* - the prompt carries its attachments and its model snapshot.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, test } from 'bun:test';
|
|
24
|
+
import { sweepWaitingPrompts, MAX_DISPATCH_ATTEMPTS, type StreamRowMap, type WaitingDeps } from '@/server/lib/waiting/runtime.server';
|
|
25
|
+
import type { Attachment } from '@/shared/types/chat';
|
|
26
|
+
import type { WaitingPrompt } from '@/shared/types/waiting';
|
|
27
|
+
|
|
28
|
+
const NOW = 1_800_000_000_000;
|
|
29
|
+
const OLD = NOW - 60_000;
|
|
30
|
+
|
|
31
|
+
function prompt(overrides: Partial<WaitingPrompt> = {}): WaitingPrompt {
|
|
32
|
+
return {
|
|
33
|
+
id: 'wait-1',
|
|
34
|
+
pendingId: 'new-1',
|
|
35
|
+
dependsOn: 'dep-a',
|
|
36
|
+
dependsOnTitle: 'Session A',
|
|
37
|
+
prompt: 'do the thing',
|
|
38
|
+
attachments: [],
|
|
39
|
+
model: null,
|
|
40
|
+
modes: null,
|
|
41
|
+
cwd: '/tmp/project',
|
|
42
|
+
folderId: 1,
|
|
43
|
+
status: 'waiting',
|
|
44
|
+
sessionId: null,
|
|
45
|
+
error: null,
|
|
46
|
+
createdAt: OLD,
|
|
47
|
+
updatedAt: OLD,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Harness {
|
|
53
|
+
deps: WaitingDeps;
|
|
54
|
+
/** Commands sent to each spawned session, in order. */
|
|
55
|
+
sent: Record<string, Record<string, unknown>[]>;
|
|
56
|
+
/** Session ids spawned, in order. */
|
|
57
|
+
spawned: string[];
|
|
58
|
+
/** Calls that changed a row, as `verb id`. */
|
|
59
|
+
writes: string[];
|
|
60
|
+
setStreams(rows: StreamRowMap): void;
|
|
61
|
+
setRowWrittenAt(at: number | null): void;
|
|
62
|
+
setQueued(queued: boolean): void;
|
|
63
|
+
setDelivering(delivering: boolean): void;
|
|
64
|
+
failSpawnWith(error: Error | null): void;
|
|
65
|
+
failSendWith(error: Error | null): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function harness(initial: WaitingPrompt[] = [prompt()]): Harness {
|
|
69
|
+
// The attempt counter is the store's own column, not part of the client-facing
|
|
70
|
+
// shape: the harness keeps it beside the rows rather than widening `WaitingPrompt`.
|
|
71
|
+
const attempts = new Map<string, number>();
|
|
72
|
+
let rows = initial.map((row) => ({ ...row }));
|
|
73
|
+
let streams: StreamRowMap = { 'dep-a': { status: 'finish' } };
|
|
74
|
+
let rowWrittenAt: number | null = OLD;
|
|
75
|
+
let queued = false;
|
|
76
|
+
let delivering = false;
|
|
77
|
+
let spawnError: Error | null = null;
|
|
78
|
+
let sendError: Error | null = null;
|
|
79
|
+
const sent: Harness['sent'] = {};
|
|
80
|
+
const spawned: string[] = [];
|
|
81
|
+
const writes: string[] = [];
|
|
82
|
+
let spawnCount = 0;
|
|
83
|
+
|
|
84
|
+
const deps: WaitingDeps = {
|
|
85
|
+
async startNewRpcSession() {
|
|
86
|
+
if (spawnError) throw spawnError;
|
|
87
|
+
spawnCount += 1;
|
|
88
|
+
const id = `spawned-${spawnCount}`;
|
|
89
|
+
spawned.push(id);
|
|
90
|
+
sent[id] = [];
|
|
91
|
+
return {
|
|
92
|
+
realSessionId: id,
|
|
93
|
+
session: {
|
|
94
|
+
async send(command) {
|
|
95
|
+
if (sendError) throw sendError;
|
|
96
|
+
sent[id].push(command);
|
|
97
|
+
return null;
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
getSession: () => undefined,
|
|
103
|
+
loadStreamStates: async () => streams,
|
|
104
|
+
streamRowWrittenAt: async () => rowWrittenAt,
|
|
105
|
+
hasQueuedItem: async () => queued,
|
|
106
|
+
hasPendingDelivery: () => delivering,
|
|
107
|
+
loadPersistedAccessMode: async () => 'always-ask',
|
|
108
|
+
listWaitingPrompts: async () => rows.map((row) => ({ ...row })),
|
|
109
|
+
claimWaitingPrompt: async (id) => {
|
|
110
|
+
const row = rows.find((entry) => entry.id === id);
|
|
111
|
+
if (!row || row.status !== 'waiting') return false;
|
|
112
|
+
row.status = 'dispatched';
|
|
113
|
+
writes.push(`claim ${id}`);
|
|
114
|
+
return true;
|
|
115
|
+
},
|
|
116
|
+
countWaitingAttempt: async (id) => {
|
|
117
|
+
const next = (attempts.get(id) ?? 0) + 1;
|
|
118
|
+
attempts.set(id, next);
|
|
119
|
+
return next;
|
|
120
|
+
},
|
|
121
|
+
markWaitingDispatched: async (id, sessionId) => {
|
|
122
|
+
const row = rows.find((entry) => entry.id === id);
|
|
123
|
+
if (row) {
|
|
124
|
+
row.status = 'dispatched';
|
|
125
|
+
row.sessionId = sessionId;
|
|
126
|
+
}
|
|
127
|
+
writes.push(`dispatched ${id} ${sessionId}`);
|
|
128
|
+
},
|
|
129
|
+
requeueWaitingPrompt: async (id) => {
|
|
130
|
+
const row = rows.find((entry) => entry.id === id);
|
|
131
|
+
if (row) row.status = 'waiting';
|
|
132
|
+
writes.push(`requeue ${id}`);
|
|
133
|
+
},
|
|
134
|
+
failWaitingPrompt: async (id, error) => {
|
|
135
|
+
const row = rows.find((entry) => entry.id === id);
|
|
136
|
+
if (row) {
|
|
137
|
+
row.status = 'error';
|
|
138
|
+
row.error = error;
|
|
139
|
+
}
|
|
140
|
+
writes.push(`fail ${id}`);
|
|
141
|
+
},
|
|
142
|
+
pruneWaitingPrompts: async () => 0,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
deps,
|
|
147
|
+
sent,
|
|
148
|
+
spawned,
|
|
149
|
+
writes,
|
|
150
|
+
setStreams: (next) => {
|
|
151
|
+
streams = next;
|
|
152
|
+
},
|
|
153
|
+
setRowWrittenAt: (next) => {
|
|
154
|
+
rowWrittenAt = next;
|
|
155
|
+
},
|
|
156
|
+
setQueued: (next) => {
|
|
157
|
+
queued = next;
|
|
158
|
+
},
|
|
159
|
+
setDelivering: (next) => {
|
|
160
|
+
delivering = next;
|
|
161
|
+
},
|
|
162
|
+
failSpawnWith: (error) => {
|
|
163
|
+
spawnError = error;
|
|
164
|
+
},
|
|
165
|
+
failSendWith: (error) => {
|
|
166
|
+
sendError = error;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe('sweepWaitingPrompts', () => {
|
|
172
|
+
test('dispatches a prompt once its dependency has finished', async () => {
|
|
173
|
+
const h = harness();
|
|
174
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(1);
|
|
175
|
+
expect(h.spawned).toEqual(['spawned-1']);
|
|
176
|
+
expect(h.sent['spawned-1']).toEqual([{ type: 'prompt', message: 'do the thing' }]);
|
|
177
|
+
expect(h.writes).toContain('dispatched wait-1 spawned-1');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('holds the prompt while the dependency still has a queued follow-up', async () => {
|
|
181
|
+
const h = harness();
|
|
182
|
+
h.setQueued(true);
|
|
183
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
184
|
+
expect(h.spawned).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('holds the prompt while the dependency is still streaming', async () => {
|
|
188
|
+
const h = harness();
|
|
189
|
+
h.setStreams({ 'dep-a': { status: 'stream' } });
|
|
190
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The row's clock is what closes the gap between a run end and the queue it
|
|
194
|
+
// arms 500 ms later, so a freshly written row must not release anything yet.
|
|
195
|
+
test('holds the prompt until the status row has aged past the grace', async () => {
|
|
196
|
+
const h = harness();
|
|
197
|
+
h.setRowWrittenAt(NOW - 100);
|
|
198
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
199
|
+
expect(h.spawned).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('holds the prompt while a delivery slot is armed', async () => {
|
|
203
|
+
const h = harness();
|
|
204
|
+
h.setDelivering(true);
|
|
205
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// The claim is the concurrency answer: the second sweep must not spawn again.
|
|
209
|
+
test('two sweeps racing spawn exactly one session', async () => {
|
|
210
|
+
const h = harness();
|
|
211
|
+
const [first, second] = await Promise.all([
|
|
212
|
+
sweepWaitingPrompts({ now: NOW, deps: h.deps }),
|
|
213
|
+
sweepWaitingPrompts({ now: NOW, deps: h.deps }),
|
|
214
|
+
]);
|
|
215
|
+
expect(first + second).toBe(1);
|
|
216
|
+
expect(h.spawned).toHaveLength(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('a failed spawn returns the prompt to waiting instead of losing it', async () => {
|
|
220
|
+
const h = harness();
|
|
221
|
+
h.failSpawnWith(new Error('omp did not start'));
|
|
222
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
223
|
+
expect(h.writes).toContain('requeue wait-1');
|
|
224
|
+
expect(h.writes).not.toContain('fail wait-1');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a prompt whose dispatch keeps failing is reported, not retried forever', async () => {
|
|
228
|
+
const h = harness();
|
|
229
|
+
h.failSpawnWith(new Error('omp did not start'));
|
|
230
|
+
for (let attempt = 0; attempt < MAX_DISPATCH_ATTEMPTS; attempt += 1) {
|
|
231
|
+
await sweepWaitingPrompts({ now: NOW, deps: h.deps });
|
|
232
|
+
}
|
|
233
|
+
expect(h.writes.filter((write) => write.startsWith('fail '))).toHaveLength(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// The wrapper's `send` reads its own runtime state through `this`, so a
|
|
237
|
+
// dispatch that passes the method detached runs against `undefined` — the
|
|
238
|
+
// real failure was `undefined is not an object (evaluating 'host.restarting')`
|
|
239
|
+
// on every prompt. The harness's send is an object METHOD for that reason,
|
|
240
|
+
// and this asserts the dispatcher never loses its receiver.
|
|
241
|
+
test('sends through the session, never a detached method', async () => {
|
|
242
|
+
const h = harness();
|
|
243
|
+
let receiver: unknown = null;
|
|
244
|
+
h.deps.startNewRpcSession = async () => {
|
|
245
|
+
const session = {
|
|
246
|
+
async send(this: unknown) {
|
|
247
|
+
receiver = this;
|
|
248
|
+
return null;
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
return { realSessionId: 'spawned-x', session };
|
|
252
|
+
};
|
|
253
|
+
await sweepWaitingPrompts({ now: NOW, deps: h.deps });
|
|
254
|
+
expect(receiver).not.toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('carries the attachment bytes and the model snapshot', async () => {
|
|
258
|
+
const attachment: Attachment = { id: 'a1', preview: '', name: 'shot.png', type: 'image/png', dataBase64: 'AAA' };
|
|
259
|
+
const h = harness([
|
|
260
|
+
prompt({
|
|
261
|
+
attachments: [attachment],
|
|
262
|
+
model: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'high', accessMode: 'yolo' },
|
|
263
|
+
}),
|
|
264
|
+
]);
|
|
265
|
+
await sweepWaitingPrompts({ now: NOW, deps: h.deps });
|
|
266
|
+
const commands = h.sent['spawned-1'];
|
|
267
|
+
expect(commands[0]).toEqual({ type: 'set_model', provider: 'anthropic', modelId: 'claude' });
|
|
268
|
+
expect(commands[1]).toEqual({ type: 'set_thinking_level', level: 'high' });
|
|
269
|
+
expect(commands[2]).toMatchObject({ type: 'prompt', accessMode: 'yolo' });
|
|
270
|
+
expect((commands[2].images as { data: string }[])[0]?.data).toBe('AAA');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('nothing is read or spawned when no prompt is waiting', async () => {
|
|
274
|
+
const h = harness([prompt({ status: 'dispatched', sessionId: 'done' })]);
|
|
275
|
+
expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
|
|
276
|
+
expect(h.spawned).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
});
|