ompchamber 3.5.0 → 3.6.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 +357 -353
- package/dist/client/static/js/index-7n838fz5.css +1 -0
- package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
- package/package.json +1 -1
- package/src/cli/lib/commands/status.js +7 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
- package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
- package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
- package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
- package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
- package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
- package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
- package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
- package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
- package/src/client/data/settings/command.ts +0 -40
- package/src/client/hooks/settings/workspace-roots.ts +99 -0
- package/src/client/hooks/ui/updates/index.ts +178 -0
- package/src/server/db.server.ts +41 -11
- package/src/server/index.ts +17 -1
- package/src/server/lib/db/client.ts +17 -0
- package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
- package/src/server/lib/db/compact-chat-overlay.ts +90 -0
- package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
- package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
- package/src/server/lib/omp/config/commands.test.ts +167 -0
- package/src/server/lib/omp/config/commands.ts +195 -0
- package/src/server/lib/omp/config/scope.test.ts +49 -0
- package/src/server/lib/omp/config/scope.ts +65 -0
- package/src/server/lib/omp/config/skills-catalog.ts +35 -3
- package/src/server/lib/omp/config/skills.test.ts +105 -0
- package/src/server/lib/omp/config/skills.ts +260 -47
- package/src/server/lib/omp/rpc/process.ts +22 -14
- package/src/server/lib/omp/rpc/session-registry.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +45 -23
- package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
- package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
- package/src/server/lib/omp/session/merge-stored.ts +78 -11
- package/src/server/lib/omp/session/reload.server.test.ts +84 -0
- package/src/server/lib/omp/session/reload.server.ts +47 -0
- package/src/server/lib/omp/session/subagent-presence.ts +40 -9
- package/src/server/lib/terminal/liveness.server.ts +65 -0
- package/src/server/lib/terminal/runtime.server.ts +70 -117
- package/src/server/lib/terminal/session.server.ts +5 -2
- package/src/server/lib/terminal/store.ts +72 -0
- package/src/server/lib/updates/apply.ts +62 -33
- package/src/server/lib/updates/install.ts +9 -2
- package/src/server/lib/updates/omp.ts +37 -9
- package/src/server/lib/updates/single-flight.test.ts +61 -0
- package/src/server/lib/updates/single-flight.ts +68 -0
- package/src/server/routes/chat/session.ts +13 -3
- package/src/server/routes/health/probe.ts +8 -0
- package/src/server/routes/omp/index.ts +0 -2
- package/src/server/routes/settings/commands.ts +102 -15
- package/src/server/routes/settings/skills.ts +165 -113
- package/src/server/routes/terminal/sessions.ts +1 -1
- package/src/server/routes/updates/apply.ts +101 -14
- package/src/shared/lib/chat/composer/client.ts +27 -18
- package/src/shared/lib/chat/composer/scope.test.ts +87 -0
- package/src/shared/lib/markdown/katex.ts +11 -1
- package/src/shared/lib/markdown/marked.ts +68 -13
- package/src/shared/lib/markdown/math.test.ts +53 -1
- package/src/shared/lib/updates/progress.test.ts +54 -0
- package/src/shared/lib/updates/progress.ts +50 -0
- package/src/shared/types/settings/command.ts +15 -2
- package/src/shared/types/settings/skill.ts +13 -0
- package/dist/client/static/js/index-vx0ernrv.css +0 -1
- package/src/client/hooks/ui/updates.ts +0 -108
- package/src/server/routes/omp/commands.ts +0 -29
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* The overlay repair. Its whole risk is asymmetric: narrowing a row it should
|
|
6
|
+
* not touch DESTROYS a conversation (for a chamber-created session the DB is the
|
|
7
|
+
* only copy there is), while leaving a row it should narrow merely wastes disk.
|
|
8
|
+
* So the file-existence gate is the behaviour under test, and it is exercised
|
|
9
|
+
* against a real temp tree rather than a stub — `findSessionFileById` is the
|
|
10
|
+
* thing being trusted.
|
|
11
|
+
*
|
|
12
|
+
* The pass has no marker on purpose (see the module): the per-row "did the
|
|
13
|
+
* filter shrink it?" guard is what makes it self-limiting, so a second run must
|
|
14
|
+
* find nothing left to do.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { createDb, type DbClient } from '@/server/lib/db/client';
|
|
22
|
+
import { initSchema } from '@/server/lib/db/schema';
|
|
23
|
+
import { compactChatOverlayRows } from '@/server/lib/db/compact-chat-overlay';
|
|
24
|
+
|
|
25
|
+
const WITH_FILE = '01a0dddd-1111-7222-8333-444455556666';
|
|
26
|
+
const CHAMBER_ONLY = 'new-1700000000000';
|
|
27
|
+
const NOTICE = 'Context window: 1000000 tokens (1% used)';
|
|
28
|
+
|
|
29
|
+
/** Mirrored shape: the user turn plus assistant rows no reader ever touches. */
|
|
30
|
+
const MIRRORED = [
|
|
31
|
+
{ id: 'msg-1-user', role: 'user', content: 'hello' },
|
|
32
|
+
{ id: 'omp-a1', role: 'ai', content: 'hi there' },
|
|
33
|
+
{ id: 'cmdout-1', role: 'ai', content: '', notice: NOTICE },
|
|
34
|
+
{ id: 'omp-a2', role: 'ai', content: 'more' },
|
|
35
|
+
];
|
|
36
|
+
/** A chamber-created conversation: no session file exists for it. */
|
|
37
|
+
const CHAMBER = [
|
|
38
|
+
{ id: 'msg-9-user', role: 'user', content: 'ask' },
|
|
39
|
+
{ id: 'local-ai', role: 'ai', content: 'answer' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const cleanup: string[] = [];
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
for (const target of cleanup) rmSync(target, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function tempDir(label: string): string {
|
|
48
|
+
const dir = join(tmpdir(), `overlay-${label}-${crypto.randomUUID()}`);
|
|
49
|
+
cleanup.push(dir);
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A sessions root holding exactly one session file, named the way omp names it. */
|
|
55
|
+
function sessionsRoot(): string {
|
|
56
|
+
const root = tempDir('root');
|
|
57
|
+
const project = join(root, '--proj--');
|
|
58
|
+
mkdirSync(project, { recursive: true });
|
|
59
|
+
writeFileSync(
|
|
60
|
+
join(project, `2026-01-01T00-00-00-000Z_${WITH_FILE}.jsonl`),
|
|
61
|
+
`${JSON.stringify({ type: 'session', id: WITH_FILE, timestamp: '2026-01-01T00:00:00.000Z', cwd: '/' })}\n`,
|
|
62
|
+
);
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function seededDb() {
|
|
67
|
+
const db = createDb(join(tempDir('db'), 'db.sqlite'));
|
|
68
|
+
await initSchema(db);
|
|
69
|
+
const put = (sessionId: string, messages: unknown[]) =>
|
|
70
|
+
db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages) VALUES (?, ?, ?)', [
|
|
71
|
+
sessionId,
|
|
72
|
+
't',
|
|
73
|
+
JSON.stringify(messages),
|
|
74
|
+
]);
|
|
75
|
+
await put(WITH_FILE, MIRRORED);
|
|
76
|
+
await put(CHAMBER_ONLY, CHAMBER);
|
|
77
|
+
return db;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function storedMessages(db: DbClient, sessionId: string) {
|
|
81
|
+
const row = (await db.get('SELECT messages FROM chat_sessions WHERE session_id = ?', [sessionId])) as {
|
|
82
|
+
messages: string;
|
|
83
|
+
};
|
|
84
|
+
return JSON.parse(row.messages) as { id: string; role: string }[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('compactChatOverlayRows', () => {
|
|
88
|
+
test('narrows a mirrored row whose session file exists', async () => {
|
|
89
|
+
const db = await seededDb();
|
|
90
|
+
await compactChatOverlayRows(db, sessionsRoot());
|
|
91
|
+
|
|
92
|
+
const kept = await storedMessages(db, WITH_FILE);
|
|
93
|
+
expect(kept.map((m) => m.id)).toEqual(['msg-1-user', 'cmdout-1']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('leaves a chamber-created row untouched — the DB is its only copy', async () => {
|
|
97
|
+
const db = await seededDb();
|
|
98
|
+
await compactChatOverlayRows(db, sessionsRoot());
|
|
99
|
+
|
|
100
|
+
expect(await storedMessages(db, CHAMBER_ONLY)).toEqual(CHAMBER);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('finds nothing left to do on a second run', async () => {
|
|
104
|
+
const db = await seededDb();
|
|
105
|
+
const root = sessionsRoot();
|
|
106
|
+
await compactChatOverlayRows(db, root);
|
|
107
|
+
const once = JSON.stringify({
|
|
108
|
+
mirrored: await storedMessages(db, WITH_FILE),
|
|
109
|
+
chamber: await storedMessages(db, CHAMBER_ONLY),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await compactChatOverlayRows(db, root);
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
mirrored: await storedMessages(db, WITH_FILE),
|
|
117
|
+
chamber: await storedMessages(db, CHAMBER_ONLY),
|
|
118
|
+
}),
|
|
119
|
+
).toBe(once);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Boot-time repair of the `chat_sessions` overlay.
|
|
8
|
+
*
|
|
9
|
+
* The overlay used to be a full mirror of every conversation: the chat timeline
|
|
10
|
+
* posted its whole rendered message list on every `message_end`, so each stored
|
|
11
|
+
* row carried a copy of the transcript omp already owns in the session JSONL.
|
|
12
|
+
* `overlayRowsForOmpSession` narrows what is written from now on, and this pass
|
|
13
|
+
* applies the same rule to rows written before it existed — measured on the
|
|
14
|
+
* author's install, 165.6 MB of 167.9 MB of mirrored rows were assistant
|
|
15
|
+
* messages no reader ever touches.
|
|
16
|
+
*
|
|
17
|
+
* It has NO marker, deliberately, and that is the difference from the
|
|
18
|
+
* `queue_fk_removed` migration: the guard here is per-row ("does the filter
|
|
19
|
+
* actually shrink this row?"), which makes the pass self-limiting rather than
|
|
20
|
+
* once-only. A marker would be set after the first clean pass and then leave
|
|
21
|
+
* every row written by an older build uncompacted for good — a live server has
|
|
22
|
+
* to be restarted for the narrowed writer to take effect, and it keeps writing
|
|
23
|
+
* mirrors until it is.
|
|
24
|
+
*
|
|
25
|
+
* Two properties keep it safe on an existing database:
|
|
26
|
+
*
|
|
27
|
+
* - It only rewrites a row whose session file EXISTS. For a chamber-created
|
|
28
|
+
* session the DB is the only copy, so those rows are left exactly as they are.
|
|
29
|
+
* - It only rewrites a row it actually shrinks, so the cost is proportional to
|
|
30
|
+
* what is left to fix: 762 ms for a 173 MB overlay on first run, and one
|
|
31
|
+
* parse-and-skip of the remaining ~5 MB on every later start.
|
|
32
|
+
*
|
|
33
|
+
* A failure is logged rather than thrown: it must never be able to stop the
|
|
34
|
+
* server from starting. It is called from the server bootstrap right after the
|
|
35
|
+
* "listening" banner and deliberately NOT from `getDb()` — the test suite and
|
|
36
|
+
* the CLI reach that in real mode, so a database HANDLE would be able to launch
|
|
37
|
+
* the rewrite (verified the hard way: a `bun test` run compacted a live
|
|
38
|
+
* database).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { DbClient } from '@/server/lib/db/client';
|
|
42
|
+
import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
43
|
+
import { overlayRowsForOmpSession } from '@/server/lib/omp/session/merge-stored';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rewrite every over-full `chat_sessions` row whose transcript omp owns.
|
|
47
|
+
*
|
|
48
|
+
* `sessionsRoot` is the locator's own seam, forwarded so a test can point the
|
|
49
|
+
* file lookup at its own tree instead of the real agent directory.
|
|
50
|
+
*/
|
|
51
|
+
export async function compactChatOverlayRows(db: DbClient, sessionsRoot?: string): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const rows = (await db.all('SELECT session_id, messages FROM chat_sessions')) as {
|
|
54
|
+
session_id: string;
|
|
55
|
+
messages: string;
|
|
56
|
+
}[];
|
|
57
|
+
|
|
58
|
+
let compacted = 0;
|
|
59
|
+
let reclaimed = 0;
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(row.messages);
|
|
64
|
+
} catch {
|
|
65
|
+
// Unparseable is not ours to fix: the readers already treat it as empty.
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(parsed) || parsed.length === 0) continue;
|
|
69
|
+
|
|
70
|
+
const reduced = overlayRowsForOmpSession(parsed);
|
|
71
|
+
// The filter only ever removes, so an unchanged length means nothing to do
|
|
72
|
+
// — and the file lookup below is the expensive half, so it comes second.
|
|
73
|
+
if (reduced.length === parsed.length) continue;
|
|
74
|
+
if (!(await findSessionFileById(row.session_id, sessionsRoot))) continue;
|
|
75
|
+
|
|
76
|
+
const next = JSON.stringify(reduced);
|
|
77
|
+
await db.run('UPDATE chat_sessions SET messages = ? WHERE session_id = ?', [next, row.session_id]);
|
|
78
|
+
compacted += 1;
|
|
79
|
+
reclaimed += row.messages.length - next.length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (compacted > 0) {
|
|
83
|
+
console.log(
|
|
84
|
+
`[chat-overlay] compacted ${compacted} mirrored session row(s), reclaimed ${(reclaimed / 1e6).toFixed(1)} MB`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.warn('[chat-overlay] compaction failed; will retry on next start:', error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { SPAWN_CLIFF, countOpenFileDescriptors, describeSpawnFailure } from '@/server/lib/lifecycle/fd-pressure';
|
|
4
|
+
|
|
5
|
+
describe('countOpenFileDescriptors', () => {
|
|
6
|
+
test('sweeps this process own descriptors on a POSIX host', () => {
|
|
7
|
+
const pressure = countOpenFileDescriptors();
|
|
8
|
+
if (process.platform === 'win32') {
|
|
9
|
+
expect(pressure).toBeNull();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
expect(pressure).not.toBeNull();
|
|
13
|
+
// Stdio is always open, and the sweep never walks past Darwin's OPEN_MAX —
|
|
14
|
+
// a descriptor above it cannot be opened, which is the whole point.
|
|
15
|
+
expect(pressure?.open).toBeGreaterThanOrEqual(3);
|
|
16
|
+
expect(pressure?.highest).toBeGreaterThanOrEqual(2);
|
|
17
|
+
expect(pressure?.limit).toBeLessThanOrEqual(SPAWN_CLIFF);
|
|
18
|
+
expect(pressure?.nearCliff).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('counts descriptors opened and closed around it', () => {
|
|
22
|
+
const before = countOpenFileDescriptors();
|
|
23
|
+
if (!before) return; // Host cannot answer; nothing to assert.
|
|
24
|
+
const opened = [0, 1, 2, 3, 4].map(() => fs.openSync('/dev/null', 'r'));
|
|
25
|
+
try {
|
|
26
|
+
const during = countOpenFileDescriptors();
|
|
27
|
+
expect(during!.open - before.open).toBeGreaterThanOrEqual(opened.length);
|
|
28
|
+
expect(during!.highest).toBeGreaterThanOrEqual(Math.max(...opened));
|
|
29
|
+
} finally {
|
|
30
|
+
for (const fd of opened) fs.closeSync(fd);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('describeSpawnFailure', () => {
|
|
36
|
+
test('passes an unrelated failure through untouched', () => {
|
|
37
|
+
// The helper must not relabel a genuine bad-descriptor or ENOENT bug as
|
|
38
|
+
// resource pressure; only an observable full table earns the extra text.
|
|
39
|
+
expect(describeSpawnFailure(new Error('spawn ENOENT'))).toBe('spawn ENOENT');
|
|
40
|
+
expect(describeSpawnFailure('boom')).toBe('boom');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('names exhaustion only when the table really is full', () => {
|
|
44
|
+
const opened: number[] = [];
|
|
45
|
+
try {
|
|
46
|
+
while (opened.length < SPAWN_CLIFF + 16) {
|
|
47
|
+
try {
|
|
48
|
+
opened.push(fs.openSync('/dev/null', 'r'));
|
|
49
|
+
} catch {
|
|
50
|
+
break; // Host limit is lower than the cliff; the gate is untestable here.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!countOpenFileDescriptors()?.nearCliff) return;
|
|
54
|
+
|
|
55
|
+
const raw = "EBADF: bad file descriptor, posix_spawn '/bin/sh'";
|
|
56
|
+
const described = describeSpawnFailure(new Error(raw));
|
|
57
|
+
expect(described.startsWith(raw)).toBe(true);
|
|
58
|
+
expect(described).toContain('out of file descriptors');
|
|
59
|
+
// The symptom gate still holds under pressure: an unrelated failure keeps
|
|
60
|
+
// its own message instead of being blamed on the descriptor table.
|
|
61
|
+
expect(describeSpawnFailure(new Error('spawn ENOENT'))).toBe('spawn ENOENT');
|
|
62
|
+
} finally {
|
|
63
|
+
for (const fd of opened) fs.closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File-descriptor pressure, and the honest name for a spawn that dies from it.
|
|
8
|
+
*
|
|
9
|
+
* On macOS `posix_spawn` fails with `EBADF: bad file descriptor` the moment it
|
|
10
|
+
* must hand the child a descriptor numbered at or above `OPEN_MAX` (10240). The
|
|
11
|
+
* error names the shim it tried to exec — `posix_spawn '/bin/sh'` — and says
|
|
12
|
+
* nothing about the cause, so a terminal panel reads "Failed to start /bin/zsh:
|
|
13
|
+
* EBADF: bad file descriptor, posix_spawn '/bin/sh'" while the real condition is
|
|
14
|
+
* that the whole server process has run out of descriptors. Measured A/B on Bun
|
|
15
|
+
* 1.4.2 / macOS 26: 10230 open descriptors spawn fine, 10240 fails with exactly
|
|
16
|
+
* that message.
|
|
17
|
+
*
|
|
18
|
+
* A dev server reaches that state on its own, without a leak in this codebase:
|
|
19
|
+
* `Bun.serve({ development: true })` keeps the client module graph open
|
|
20
|
+
* (measured: 3545 descriptors for this app, against 17 in production), and a
|
|
21
|
+
* macOS reload never closes the previous generation's (bun#40706, bun#40907 —
|
|
22
|
+
* the pre-exec sweep exists only on Linux). The count therefore grows with
|
|
23
|
+
* every rebuild, and past the cliff every spawn in the process fails at once:
|
|
24
|
+
* the PTY shell, the `omp` child, `git`, `rg`.
|
|
25
|
+
*
|
|
26
|
+
* `countOpenFileDescriptors()` answers "how close?" with `fcntl(F_GETFD)` over
|
|
27
|
+
* the descriptor table — the same `bun:ffi` route `flock.ts` takes. The sweep
|
|
28
|
+
* stops at the cliff, because a descriptor above it cannot be opened anyway and
|
|
29
|
+
* that bound keeps the probe near 1 ms. It returns null where the symbols are
|
|
30
|
+
* unavailable (and on Windows), and every caller must read null as "unknown",
|
|
31
|
+
* never as "fine": `describeSpawnFailure` only names exhaustion when it can see
|
|
32
|
+
* the pressure, so a genuine bad-descriptor bug still reports itself plainly.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { dlopen, FFIType } from 'bun:ffi';
|
|
36
|
+
import type { FFIFunction, Library } from 'bun:ffi';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Darwin's `OPEN_MAX`: the first descriptor number a process cannot hold, and
|
|
40
|
+
* therefore the point where `posix_spawn` starts answering `EBADF`. Verified on
|
|
41
|
+
* Bun 1.4.2 (10230 ok, 10240 fails).
|
|
42
|
+
*/
|
|
43
|
+
export const SPAWN_CLIFF = 10_240;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pressure is reported as "near" from 90% of the swept range. The remaining
|
|
47
|
+
* 1024 descriptors are what a burst of spawns needs (an omp child takes
|
|
48
|
+
* several), and the failure arrives without a warning of its own: once the
|
|
49
|
+
* table is full there is room for neither a retry nor a log line.
|
|
50
|
+
*/
|
|
51
|
+
const NEAR_CLIFF_RATIO = 0.9;
|
|
52
|
+
|
|
53
|
+
const FD_SYMBOLS = {
|
|
54
|
+
fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
55
|
+
getdtablesize: { args: [], returns: FFIType.i32 },
|
|
56
|
+
} satisfies Record<string, FFIFunction>;
|
|
57
|
+
|
|
58
|
+
/** Library names to try, in the order each platform resolves them. */
|
|
59
|
+
const LIBC_NAMES = ['libc.dylib', 'libSystem.B.dylib', 'libc.so.6'] as const;
|
|
60
|
+
|
|
61
|
+
/** `F_GETFD` — the cheapest question that distinguishes open from closed. */
|
|
62
|
+
const F_GETFD = 1;
|
|
63
|
+
|
|
64
|
+
declare global {
|
|
65
|
+
// eslint-disable-next-line no-var
|
|
66
|
+
var __ompChamberFdLib: Library<typeof FD_SYMBOLS> | null | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** libc with `fcntl`/`getdtablesize`, or null where this host has neither. */
|
|
70
|
+
function libc(): Library<typeof FD_SYMBOLS> | null {
|
|
71
|
+
if (globalThis.__ompChamberFdLib !== undefined) return globalThis.__ompChamberFdLib;
|
|
72
|
+
globalThis.__ompChamberFdLib = null;
|
|
73
|
+
if (process.platform === 'win32') return null;
|
|
74
|
+
for (const name of LIBC_NAMES) {
|
|
75
|
+
try {
|
|
76
|
+
globalThis.__ompChamberFdLib = dlopen(name, FD_SYMBOLS);
|
|
77
|
+
return globalThis.__ompChamberFdLib;
|
|
78
|
+
} catch {
|
|
79
|
+
// Try the next name; a host with none of them keeps the null answer.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface FdPressure {
|
|
86
|
+
/** Descriptors open below `limit`. */
|
|
87
|
+
open: number;
|
|
88
|
+
/** Highest descriptor number seen, or -1 when the table looked empty. */
|
|
89
|
+
highest: number;
|
|
90
|
+
/** Where the sweep stopped: `getdtablesize()`, capped at the cliff. */
|
|
91
|
+
limit: number;
|
|
92
|
+
/** Whether the next spawn is at risk. See `NEAR_CLIFF_RATIO`. */
|
|
93
|
+
nearCliff: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sweep the descriptor table, or null when this host cannot answer.
|
|
98
|
+
*
|
|
99
|
+
* Descriptors are allocated lowest-first, so the number of open descriptors and
|
|
100
|
+
* the highest one agree on a saturated table — which is the only state that
|
|
101
|
+
* matters here, and the state a dev server reaches by degrees.
|
|
102
|
+
*
|
|
103
|
+
* Never throws: `GET /api/health` is the CLI's readiness probe and the client's
|
|
104
|
+
* liveness check, so a host where the symbol resolves but the call does not
|
|
105
|
+
* must degrade to "unknown" rather than take those down. `open` saturates at
|
|
106
|
+
* `limit`, since the sweep stops there by design.
|
|
107
|
+
*/
|
|
108
|
+
export function countOpenFileDescriptors(): FdPressure | null {
|
|
109
|
+
const symbols = libc();
|
|
110
|
+
if (!symbols) return null;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const reported = symbols.symbols.getdtablesize();
|
|
114
|
+
const table = Number.isFinite(reported) && reported > 0 ? reported : SPAWN_CLIFF;
|
|
115
|
+
const limit = Math.min(table, SPAWN_CLIFF);
|
|
116
|
+
|
|
117
|
+
let open = 0;
|
|
118
|
+
let highest = -1;
|
|
119
|
+
for (let fd = 0; fd < limit; fd++) {
|
|
120
|
+
if (symbols.symbols.fcntl(fd, F_GETFD, 0) !== -1) {
|
|
121
|
+
open += 1;
|
|
122
|
+
highest = fd;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { open, highest, limit, nearCliff: highest >= limit * NEAR_CLIFF_RATIO };
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What descriptor exhaustion looks like in Bun's own words. macOS names the
|
|
133
|
+
* failing syscall (`EBADF … posix_spawn '/bin/sh'`), Linux the errno
|
|
134
|
+
* (`EMFILE: too many open files, socketpair`) — measured on both.
|
|
135
|
+
*/
|
|
136
|
+
const EXHAUSTION_SYMPTOM = /\bEBADF\b|\bEMFILE\b|too many open files/i;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The message to surface for a failed spawn: Bun's own, plus what it takes to
|
|
140
|
+
* act on it when the cause is descriptor exhaustion.
|
|
141
|
+
*
|
|
142
|
+
* Two conditions, both required. The message must carry the symptom, and the
|
|
143
|
+
* sweep must see real pressure — a truncated table is not the only way `EBADF`
|
|
144
|
+
* reaches here, and appending "out of descriptors" to a genuine bad-descriptor
|
|
145
|
+
* bug (or to an unrelated `ENOENT` that failed while the table happened to be
|
|
146
|
+
* full) sends the next reader down the wrong path.
|
|
147
|
+
*/
|
|
148
|
+
export function describeSpawnFailure(error: unknown): string {
|
|
149
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
150
|
+
if (!EXHAUSTION_SYMPTOM.test(raw)) return raw;
|
|
151
|
+
const pressure = countOpenFileDescriptors();
|
|
152
|
+
if (!pressure || !pressure.nearCliff) return raw;
|
|
153
|
+
return (
|
|
154
|
+
`${raw} — the chamber server is out of file descriptors ` +
|
|
155
|
+
`(${pressure.open} of ${pressure.limit} open, highest #${pressure.highest}); restart the instance. ` +
|
|
156
|
+
`A dev server holds the whole client module graph open and a macOS reload never releases ` +
|
|
157
|
+
`the previous generation (bun#40706), so the count only climbs.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Command-file location and authoring.
|
|
8
|
+
*
|
|
9
|
+
* `get_available_commands` reports a command's name, description and hint but
|
|
10
|
+
* never the file behind it, so the pane's read-only/editable decision and the
|
|
11
|
+
* delete's safety both rest on this lookup. The rules worth locking are the
|
|
12
|
+
* ones a user cannot see: which root a duplicate name resolves to (omp runs the
|
|
13
|
+
* PROJECT file), which roots the chamber may write to (only the two
|
|
14
|
+
* `omp`-native ones), and the `.prompt.md` suffix VS Code prompt files carry.
|
|
15
|
+
*
|
|
16
|
+
* Everything runs inside a temp workspace and a temp `$HOME`, so the real
|
|
17
|
+
* `~/.omp/agent/commands` is never read or written.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import { tmpdir } from 'os';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
deleteCommandFile,
|
|
27
|
+
locateCommandFile,
|
|
28
|
+
readCommandFile,
|
|
29
|
+
writeCommandFile,
|
|
30
|
+
} from '@/server/lib/omp/config/commands';
|
|
31
|
+
|
|
32
|
+
let projectDir = '';
|
|
33
|
+
let homeDir = '';
|
|
34
|
+
let realHome: string | undefined;
|
|
35
|
+
|
|
36
|
+
const write = (filePath: string, body = 'body') => {
|
|
37
|
+
fs.mkdirSync(join(filePath, '..'), { recursive: true });
|
|
38
|
+
fs.writeFileSync(filePath, body);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
projectDir = fs.mkdtempSync(join(tmpdir(), 'ompchamber-cmd-project-'));
|
|
43
|
+
homeDir = fs.mkdtempSync(join(tmpdir(), 'ompchamber-cmd-home-'));
|
|
44
|
+
realHome = Bun.env.HOME;
|
|
45
|
+
// `userCommandDirs()` reads `$HOME` for the `.claude`/`.codex`/`.agents`/
|
|
46
|
+
// `.config/opencode` roots, so redirecting it keeps the test off the real one.
|
|
47
|
+
Bun.env.HOME = homeDir;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
if (realHome === undefined) delete Bun.env.HOME;
|
|
52
|
+
else Bun.env.HOME = realHome;
|
|
53
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
54
|
+
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('locateCommandFile', () => {
|
|
58
|
+
test('a workspace .omp/commands file is the managed project root', async () => {
|
|
59
|
+
write(join(projectDir, '.omp', 'commands', 'proj.md'));
|
|
60
|
+
expect(await locateCommandFile('proj', projectDir)).toEqual({
|
|
61
|
+
filePath: join(projectDir, '.omp', 'commands', 'proj.md'),
|
|
62
|
+
scope: 'project',
|
|
63
|
+
managed: true,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("another tool's file is found but never managed", async () => {
|
|
68
|
+
write(join(projectDir, '.claude', 'commands', 'claude-cmd.md'));
|
|
69
|
+
expect(await locateCommandFile('claude-cmd', projectDir)).toEqual({
|
|
70
|
+
filePath: join(projectDir, '.claude', 'commands', 'claude-cmd.md'),
|
|
71
|
+
scope: 'project',
|
|
72
|
+
managed: false,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a prompt file is found under its .prompt.md suffix', async () => {
|
|
77
|
+
write(join(projectDir, '.github', 'prompts', 'copilot.prompt.md'));
|
|
78
|
+
expect((await locateCommandFile('copilot', projectDir))?.filePath)
|
|
79
|
+
.toBe(join(projectDir, '.github', 'prompts', 'copilot.prompt.md'));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a duplicate name resolves to the project file', async () => {
|
|
83
|
+
write(join(homeDir, '.claude', 'commands', 'dupe.md'));
|
|
84
|
+
write(join(projectDir, '.omp', 'commands', 'dupe.md'));
|
|
85
|
+
expect((await locateCommandFile('dupe', projectDir))?.filePath)
|
|
86
|
+
.toBe(join(projectDir, '.omp', 'commands', 'dupe.md'));
|
|
87
|
+
// With no workspace the same name is the user-level file, which the
|
|
88
|
+
// chamber does not own.
|
|
89
|
+
expect(await locateCommandFile('dupe')).toEqual({
|
|
90
|
+
filePath: join(homeDir, '.claude', 'commands', 'dupe.md'),
|
|
91
|
+
scope: 'user',
|
|
92
|
+
managed: false,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a name that is not a plain file name is refused', async () => {
|
|
97
|
+
expect(await locateCommandFile('../escape', projectDir)).toBeNull();
|
|
98
|
+
expect(await locateCommandFile('nested/name', projectDir)).toBeNull();
|
|
99
|
+
expect(await locateCommandFile(' ', projectDir)).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a builtin has no file at all', async () => {
|
|
103
|
+
expect(await locateCommandFile('model', projectDir)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('writeCommandFile / deleteCommandFile', () => {
|
|
108
|
+
test('round-trips a project command through the file', async () => {
|
|
109
|
+
const { filePath } = await writeCommandFile({
|
|
110
|
+
scope: 'project',
|
|
111
|
+
projectDir,
|
|
112
|
+
name: 'round',
|
|
113
|
+
description: 'round trip',
|
|
114
|
+
argumentHint: '[a|b]',
|
|
115
|
+
body: 'do $ARGUMENTS',
|
|
116
|
+
});
|
|
117
|
+
expect(filePath).toBe(join(projectDir, '.omp', 'commands', 'round.md'));
|
|
118
|
+
// The parsed body carries the trailing newline the serializer emits, and
|
|
119
|
+
// the leading blank line that separates it from the frontmatter is dropped.
|
|
120
|
+
expect(await readCommandFile(filePath)).toEqual({
|
|
121
|
+
description: 'round trip',
|
|
122
|
+
argumentHint: '[a|b]',
|
|
123
|
+
body: 'do $ARGUMENTS\n',
|
|
124
|
+
});
|
|
125
|
+
// Saving what was read must not grow the file: the pane posts exactly this.
|
|
126
|
+
const first = fs.readFileSync(filePath, 'utf8');
|
|
127
|
+
const parsed = await readCommandFile(filePath);
|
|
128
|
+
await writeCommandFile({
|
|
129
|
+
scope: 'project',
|
|
130
|
+
projectDir,
|
|
131
|
+
name: 'round',
|
|
132
|
+
description: parsed!.description,
|
|
133
|
+
argumentHint: parsed!.argumentHint,
|
|
134
|
+
body: parsed!.body,
|
|
135
|
+
});
|
|
136
|
+
expect(fs.readFileSync(filePath, 'utf8')).toBe(first);
|
|
137
|
+
expect(await deleteCommandFile('round', projectDir)).toBe(true);
|
|
138
|
+
expect(await locateCommandFile('round', projectDir)).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a rename moves the file instead of leaving two', async () => {
|
|
142
|
+
await writeCommandFile({ scope: 'project', projectDir, name: 'before', description: 'd', argumentHint: '', body: 'b' });
|
|
143
|
+
await writeCommandFile({
|
|
144
|
+
scope: 'project',
|
|
145
|
+
projectDir,
|
|
146
|
+
previousName: 'before',
|
|
147
|
+
name: 'after',
|
|
148
|
+
description: 'd',
|
|
149
|
+
argumentHint: '',
|
|
150
|
+
body: 'b',
|
|
151
|
+
});
|
|
152
|
+
expect(await locateCommandFile('before', projectDir)).toBeNull();
|
|
153
|
+
expect(await locateCommandFile('after', projectDir)).not.toBeNull();
|
|
154
|
+
await deleteCommandFile('after', projectDir);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a project write with no workspace is refused', async () => {
|
|
158
|
+
expect(writeCommandFile({ scope: 'project', name: 'lost', description: 'd', argumentHint: '', body: 'b' }))
|
|
159
|
+
.rejects.toThrow('workspace root is required');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a delete cannot reach a file another tool installed", async () => {
|
|
163
|
+
write(join(projectDir, '.claude', 'commands', 'notours.md'));
|
|
164
|
+
expect(deleteCommandFile('notours', projectDir)).rejects.toThrow('cannot delete');
|
|
165
|
+
expect(fs.existsSync(join(projectDir, '.claude', 'commands', 'notours.md'))).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|