@hellcoder/companion 0.113.2 → 0.113.3
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/assets/{AgentsPage-1K8kPnm0.js → AgentsPage-B2NtxJig.js} +1 -1
- package/dist/assets/{CronManager-BZyF07KE.js → CronManager-CES2-E0_.js} +1 -1
- package/dist/assets/{DashboardPage-CbI1O6Zk.js → DashboardPage-aFf4uQfU.js} +1 -1
- package/dist/assets/{IntegrationsPage-CI70O4xo.js → IntegrationsPage-C_tZ2P0K.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-B4FLu5lN.js → LinearOAuthSettingsPage-DUQik5kh.js} +1 -1
- package/dist/assets/{LinearSettingsPage-cPsVQjgm.js → LinearSettingsPage-DFh8jfhh.js} +1 -1
- package/dist/assets/{Playground-CtT6Yg7z.js → Playground-C4t0-Zia.js} +1 -1
- package/dist/assets/{PromptsPage-aPQqBbHT.js → PromptsPage-Ma4gA0R7.js} +1 -1
- package/dist/assets/{RunsPage-DN71HYQf.js → RunsPage-jk2yW392.js} +1 -1
- package/dist/assets/{SandboxManager-DUH9hgUI.js → SandboxManager-BA6-RlkR.js} +1 -1
- package/dist/assets/{SettingsPage-BOzfigwV.js → SettingsPage-BehGv-KV.js} +1 -1
- package/dist/assets/{TailscalePage-DyKEeXYp.js → TailscalePage-CZ6V3kc7.js} +1 -1
- package/dist/assets/{index-Ck0r9rSX.js → index-B1Oejky6.js} +4 -4
- package/dist/assets/{sw-register-U38bnRB2.js → sw-register-B9GK06Mo.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/claude-adapter.ts +26 -1
- package/server/fs-utils.test.ts +105 -0
- package/server/fs-utils.ts +100 -2
- package/server/logger.test.ts +69 -6
- package/server/logger.ts +49 -6
- package/server/proc-diagnostics.test.ts +44 -0
- package/server/proc-diagnostics.ts +28 -1
- package/server/recorder.test.ts +12 -10
- package/server/recorder.ts +30 -7
- package/server/session-git-info.ts +34 -19
- package/server/ws-bridge.test.ts +34 -10
- package/server/ws-bridge.ts +63 -6
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
hasLiveDescendants,
|
|
8
8
|
countDescendants,
|
|
9
9
|
findOrphanedMcpProcesses,
|
|
10
|
+
stdoutFdOpen,
|
|
10
11
|
} from "./proc-diagnostics.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -287,3 +288,46 @@ describe("findOrphanedMcpProcesses", () => {
|
|
|
287
288
|
expect(typeof found?.comm).toBe("string");
|
|
288
289
|
});
|
|
289
290
|
});
|
|
291
|
+
|
|
292
|
+
describe("stdoutFdOpen", () => {
|
|
293
|
+
const spawned: ReturnType<typeof spawn>[] = [];
|
|
294
|
+
|
|
295
|
+
afterEach(() => {
|
|
296
|
+
for (const p of spawned.splice(0)) {
|
|
297
|
+
try { p.kill("SIGKILL"); } catch { /* already gone */ }
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// This probe decides how a mid-stream stdout EOF is attributed: a pipe only
|
|
302
|
+
// EOFs at the read end when every write end has closed, so "our reader saw
|
|
303
|
+
// EOF but fd 1 is still open" proves a reader-side (server) stream failure
|
|
304
|
+
// rather than a CLI wedge. Production sampling showed every wedge-kill
|
|
305
|
+
// victim healthy — this is the field that finally distinguishes the cases.
|
|
306
|
+
it.runIf(isLinux)("returns true for a live process holding stdout open", async () => {
|
|
307
|
+
const child = spawn("sleep", ["30"], { stdio: "ignore" });
|
|
308
|
+
spawned.push(child);
|
|
309
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
310
|
+
expect(stdoutFdOpen(child.pid)).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it.runIf(isLinux)("returns false when the process closed its own stdout", async () => {
|
|
314
|
+
// exec 1>&- closes fd 1 in the shell itself, then keeps the process alive.
|
|
315
|
+
const child = spawn("/bin/bash", ["-c", "exec 1>&-; sleep 30"], { stdio: "ignore" });
|
|
316
|
+
spawned.push(child);
|
|
317
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
318
|
+
expect(stdoutFdOpen(child.pid)).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it.runIf(isLinux)("returns false for an exited process", async () => {
|
|
322
|
+
const child = spawn("true", [], { stdio: "ignore" });
|
|
323
|
+
spawned.push(child);
|
|
324
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
325
|
+
expect(stdoutFdOpen(child.pid)).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("returns null for a missing pid and never throws", () => {
|
|
329
|
+
expect(stdoutFdOpen(undefined)).toBeNull();
|
|
330
|
+
expect(stdoutFdOpen(null)).toBeNull();
|
|
331
|
+
expect(stdoutFdOpen(0)).toBeNull();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Everything here is best-effort and must never throw: this runs on a recovery
|
|
9
9
|
// path, and a diagnostic that breaks recovery is worse than no diagnostic.
|
|
10
10
|
|
|
11
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
12
12
|
|
|
13
13
|
/** A point-in-time snapshot of kernel state for a process. */
|
|
14
14
|
export interface ProcSnapshot {
|
|
@@ -281,3 +281,30 @@ export function captureProcState(pid: number | undefined): ProcSnapshot {
|
|
|
281
281
|
if (!readAnything) snapshot.error = "process_gone_or_unreadable";
|
|
282
282
|
return snapshot;
|
|
283
283
|
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Does the process still hold its stdout (fd 1) open?
|
|
287
|
+
*
|
|
288
|
+
* The discriminating probe for a mid-stream stdout EOF. A pipe only EOFs at
|
|
289
|
+
* the read end once every write end is closed — so if OUR reader reported EOF
|
|
290
|
+
* while the CLI's fd 1 is still open, the "EOF" was synthetic on the reader
|
|
291
|
+
* (Bun stream) side, not the CLI closing its output. That distinction decides
|
|
292
|
+
* whether a "wedged" kill is recovering from a real CLI failure or destroying
|
|
293
|
+
* a healthy process because of our own stream handling. Production sampling
|
|
294
|
+
* showed every wedge-kill victim was healthy (S/ep_poll), which is what
|
|
295
|
+
* motivated recording this at kill time.
|
|
296
|
+
*
|
|
297
|
+
* Returns true if fd 1 is open, false if it is definitely gone (fd closed or
|
|
298
|
+
* process exited), and null when it cannot be determined (non-Linux,
|
|
299
|
+
* permissions).
|
|
300
|
+
*/
|
|
301
|
+
export function stdoutFdOpen(pid: number | undefined | null): boolean | null {
|
|
302
|
+
if (!pid || process.platform !== "linux") return null;
|
|
303
|
+
try {
|
|
304
|
+
readlinkSync(`/proc/${pid}/fd/1`);
|
|
305
|
+
return true;
|
|
306
|
+
} catch (err) {
|
|
307
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
308
|
+
return code === "ENOENT" || code === "ESRCH" ? false : null;
|
|
309
|
+
}
|
|
310
|
+
}
|
package/server/recorder.test.ts
CHANGED
|
@@ -350,7 +350,7 @@ describe("RecorderManager", () => {
|
|
|
350
350
|
// ─── Cleanup / Rotation ─────────────────────────────────────────────────────
|
|
351
351
|
|
|
352
352
|
describe("cleanup / rotation", () => {
|
|
353
|
-
it("deletes oldest files when total lines exceed maxLines", () => {
|
|
353
|
+
it("deletes oldest files when total lines exceed maxLines", async () => {
|
|
354
354
|
// Create 3 files with 10 entries each (= 11 lines each including header, 33 total)
|
|
355
355
|
// Use different mtimes so we control which is "oldest"
|
|
356
356
|
const now = Date.now();
|
|
@@ -365,7 +365,7 @@ describe("cleanup / rotation", () => {
|
|
|
365
365
|
maxLines: 20,
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
-
const deleted = mgr.cleanup();
|
|
368
|
+
const deleted = await mgr.cleanup();
|
|
369
369
|
|
|
370
370
|
// Should have deleted at least the oldest file (11 lines), bringing total to 22,
|
|
371
371
|
// still > 20, so the mid file (11 lines) gets deleted too → total 11 lines
|
|
@@ -376,7 +376,7 @@ describe("cleanup / rotation", () => {
|
|
|
376
376
|
expect(remaining[0]).toContain("new_claude");
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
-
it("does not delete files from active recording sessions", () => {
|
|
379
|
+
it("does not delete files from active recording sessions", async () => {
|
|
380
380
|
// Create an old file that would normally be deleted
|
|
381
381
|
const now = Date.now();
|
|
382
382
|
createFakeRecording(tempDir, "stale_claude_2025-01-01.jsonl", 10, new Date(now - 3000));
|
|
@@ -390,7 +390,7 @@ describe("cleanup / rotation", () => {
|
|
|
390
390
|
mgr.record("active-sess", "in", "msg", "cli", "claude", "/cwd");
|
|
391
391
|
|
|
392
392
|
// Now cleanup should delete the stale file but NOT the active recording's file
|
|
393
|
-
const deleted = mgr.cleanup();
|
|
393
|
+
const deleted = await mgr.cleanup();
|
|
394
394
|
|
|
395
395
|
// stale file deleted
|
|
396
396
|
expect(existsSync(join(tempDir, "stale_claude_2025-01-01.jsonl"))).toBe(false);
|
|
@@ -403,7 +403,7 @@ describe("cleanup / rotation", () => {
|
|
|
403
403
|
mgr.closeAll();
|
|
404
404
|
});
|
|
405
405
|
|
|
406
|
-
it("is a no-op when total lines are under the limit", () => {
|
|
406
|
+
it("is a no-op when total lines are under the limit", async () => {
|
|
407
407
|
// 2 files × 3 entries = 2 × 4 lines = 8 total, well under 100
|
|
408
408
|
createFakeRecording(tempDir, "a_claude_2025-01-01.jsonl", 3);
|
|
409
409
|
createFakeRecording(tempDir, "b_claude_2025-01-02.jsonl", 3);
|
|
@@ -414,36 +414,38 @@ describe("cleanup / rotation", () => {
|
|
|
414
414
|
maxLines: 100,
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
-
const deleted = mgr.cleanup();
|
|
417
|
+
const deleted = await mgr.cleanup();
|
|
418
418
|
expect(deleted).toBe(0);
|
|
419
419
|
|
|
420
420
|
expect(readDirSafe(tempDir).length).toBe(2);
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
-
it("handles empty recordings directory gracefully", () => {
|
|
423
|
+
it("handles empty recordings directory gracefully", async () => {
|
|
424
424
|
const mgr = new RecorderManager({
|
|
425
425
|
globalEnabled: false,
|
|
426
426
|
recordingsDir: tempDir,
|
|
427
427
|
maxLines: 10,
|
|
428
428
|
});
|
|
429
429
|
|
|
430
|
-
const deleted = mgr.cleanup();
|
|
430
|
+
const deleted = await mgr.cleanup();
|
|
431
431
|
expect(deleted).toBe(0);
|
|
432
432
|
});
|
|
433
433
|
|
|
434
|
-
it("runs cleanup at construction when globally enabled", () => {
|
|
434
|
+
it("runs cleanup at construction when globally enabled", async () => {
|
|
435
435
|
// Pre-fill the directory over the limit
|
|
436
436
|
const now = Date.now();
|
|
437
437
|
createFakeRecording(tempDir, "old_claude_2025-01-01.jsonl", 20, new Date(now - 2000));
|
|
438
438
|
createFakeRecording(tempDir, "new_claude_2025-01-02.jsonl", 5, new Date(now - 1000));
|
|
439
439
|
|
|
440
440
|
// Total = 21 + 6 = 27 lines, maxLines = 10
|
|
441
|
-
// Constructor should
|
|
441
|
+
// Constructor should kick off cleanup immediately; it is async now, so
|
|
442
|
+
// await the coalesced in-flight pass before asserting.
|
|
442
443
|
const mgr = new RecorderManager({
|
|
443
444
|
globalEnabled: true,
|
|
444
445
|
recordingsDir: tempDir,
|
|
445
446
|
maxLines: 10,
|
|
446
447
|
});
|
|
448
|
+
await mgr.cleanup();
|
|
447
449
|
|
|
448
450
|
const remaining = readDirSafe(tempDir);
|
|
449
451
|
expect(remaining.length).toBe(1);
|
package/server/recorder.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { BackendType } from "./session-types.js";
|
|
5
5
|
import { COMPANION_HOME } from "./paths.js";
|
|
6
|
-
import { countFileLines } from "./fs-utils.js";
|
|
6
|
+
import { countFileLines, countFileLinesCached, cachedFileLines, type FileLinesCacheEntry } from "./fs-utils.js";
|
|
7
7
|
|
|
8
8
|
const DEFAULT_MAX_LINES = 1_000_000;
|
|
9
9
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
@@ -152,6 +152,13 @@ export class RecorderManager {
|
|
|
152
152
|
private globalEnabled: boolean;
|
|
153
153
|
private recordingsDir: string;
|
|
154
154
|
private maxLines: number;
|
|
155
|
+
/** Stat-validated line-count cache: finished recordings are immutable, so
|
|
156
|
+
* only actively-written files are ever re-read. Keeps the periodic scan of
|
|
157
|
+
* a multi-hundred-MB recordings dir from stalling the event loop (which
|
|
158
|
+
* starved live CLI stdio streams). */
|
|
159
|
+
private lineCountCache = new Map<string, FileLinesCacheEntry>();
|
|
160
|
+
/** Serializes cleanup passes so a slow scan cannot overlap the next tick. */
|
|
161
|
+
private cleanupInFlight: Promise<number> | null = null;
|
|
155
162
|
private perSessionEnabled = new Set<string>();
|
|
156
163
|
private perSessionDisabled = new Set<string>();
|
|
157
164
|
private recorders = new Map<string, SessionRecorder>();
|
|
@@ -174,8 +181,8 @@ export class RecorderManager {
|
|
|
174
181
|
|
|
175
182
|
if (this.globalEnabled) {
|
|
176
183
|
// Run cleanup at startup (async, non-blocking) and periodically
|
|
177
|
-
this.cleanup();
|
|
178
|
-
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
184
|
+
void this.cleanup();
|
|
185
|
+
this.cleanupTimer = setInterval(() => void this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
179
186
|
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
180
187
|
}
|
|
181
188
|
}
|
|
@@ -279,8 +286,10 @@ export class RecorderManager {
|
|
|
279
286
|
if (firstUnderscore === -1 || secondUnderscore === -1) {
|
|
280
287
|
return { filename, sessionId: "", backendType: "", startedAt: "", lines: 0 };
|
|
281
288
|
}
|
|
282
|
-
// Count lines —
|
|
283
|
-
|
|
289
|
+
// Count lines — prefer counts warmed by the periodic async scan;
|
|
290
|
+
// fall back to a direct count for files the scan has not seen yet.
|
|
291
|
+
const fullPath = join(this.recordingsDir, filename);
|
|
292
|
+
const lines = cachedFileLines(fullPath, this.lineCountCache) ?? countFileLines(fullPath);
|
|
284
293
|
return {
|
|
285
294
|
filename,
|
|
286
295
|
sessionId: withoutExt.substring(0, firstUnderscore),
|
|
@@ -309,7 +318,20 @@ export class RecorderManager {
|
|
|
309
318
|
* Delete oldest recording files until total lines are under maxLines.
|
|
310
319
|
* Skips files that belong to active (currently recording) sessions.
|
|
311
320
|
*/
|
|
312
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Async and cache-backed on purpose: the previous sync implementation
|
|
323
|
+
* re-read every byte of the recordings directory (385MB observed) on the
|
|
324
|
+
* event loop every 5 minutes. Overlapping passes are coalesced.
|
|
325
|
+
*/
|
|
326
|
+
cleanup(): Promise<number> {
|
|
327
|
+
if (this.cleanupInFlight) return this.cleanupInFlight;
|
|
328
|
+
this.cleanupInFlight = this.runCleanup().finally(() => {
|
|
329
|
+
this.cleanupInFlight = null;
|
|
330
|
+
});
|
|
331
|
+
return this.cleanupInFlight;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async runCleanup(): Promise<number> {
|
|
313
335
|
try {
|
|
314
336
|
this.ensureDir();
|
|
315
337
|
const files = readdirSync(this.recordingsDir).filter((f) => f.endsWith(".jsonl"));
|
|
@@ -326,7 +348,7 @@ export class RecorderManager {
|
|
|
326
348
|
|
|
327
349
|
for (const filename of files) {
|
|
328
350
|
const fullPath = join(this.recordingsDir, filename);
|
|
329
|
-
const lines =
|
|
351
|
+
const lines = await countFileLinesCached(fullPath, this.lineCountCache);
|
|
330
352
|
let mtimeMs = 0;
|
|
331
353
|
try {
|
|
332
354
|
mtimeMs = statSync(fullPath).mtimeMs;
|
|
@@ -349,6 +371,7 @@ export class RecorderManager {
|
|
|
349
371
|
if (activeFiles.has(entry.path)) continue;
|
|
350
372
|
try {
|
|
351
373
|
unlinkSync(entry.path);
|
|
374
|
+
this.lineCountCache.delete(entry.path);
|
|
352
375
|
totalLines -= entry.lines;
|
|
353
376
|
deleted++;
|
|
354
377
|
} catch {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { SessionState } from "./session-types.js";
|
|
4
4
|
import { containerManager } from "./container-manager.js";
|
|
@@ -7,23 +7,38 @@ function shellEscapeSingle(value: string): string {
|
|
|
7
7
|
return value.replace(/'/g, "'\\''");
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Run a git (or docker-wrapped git) command asynchronously.
|
|
12
|
+
*
|
|
13
|
+
* This used to be execSync. Resolving git info runs up to four commands per
|
|
14
|
+
* session and fires for EVERY session on browser connect — a browser
|
|
15
|
+
* reconnect storm over a dozen sessions meant dozens of back-to-back
|
|
16
|
+
* synchronous subprocess calls (3s timeout each) on the event loop. Those
|
|
17
|
+
* stalls starved live CLI stdio streams mid-turn, which the wedge detector
|
|
18
|
+
* then misread as dead transports. Async keeps the loop free; the git info
|
|
19
|
+
* lands a few ms later, which nothing here is sensitive to.
|
|
20
|
+
*/
|
|
21
|
+
function runGitCommand(sessionId: string, state: SessionState, command: string): Promise<string> {
|
|
22
|
+
let fullCommand = command;
|
|
23
|
+
let cwd: string | undefined = state.cwd;
|
|
24
|
+
|
|
11
25
|
if (state.is_containerized) {
|
|
12
26
|
const container = containerManager.getContainer(sessionId);
|
|
13
|
-
if (container?.containerId) {
|
|
14
|
-
|
|
15
|
-
const inner = `cd '${shellEscapeSingle(containerCwd)}' && ${command}`;
|
|
16
|
-
const dockerCmd = `docker exec ${container.containerId} sh -lc ${JSON.stringify(inner)}`;
|
|
17
|
-
return execSync(dockerCmd, { encoding: "utf-8", timeout: 3000 }).trim();
|
|
27
|
+
if (!container?.containerId) {
|
|
28
|
+
return Promise.reject(new Error("container not tracked"));
|
|
18
29
|
}
|
|
19
|
-
|
|
30
|
+
const containerCwd = container.containerCwd || "/workspace";
|
|
31
|
+
const inner = `cd '${shellEscapeSingle(containerCwd)}' && ${command}`;
|
|
32
|
+
fullCommand = `docker exec ${container.containerId} sh -lc ${JSON.stringify(inner)}`;
|
|
33
|
+
cwd = undefined;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
|
-
return
|
|
23
|
-
cwd:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
37
|
+
exec(fullCommand, { cwd, encoding: "utf-8", timeout: 3000 }, (error, stdout) => {
|
|
38
|
+
if (error) rejectPromise(error);
|
|
39
|
+
else resolvePromise(stdout.trim());
|
|
40
|
+
});
|
|
41
|
+
});
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
function mapContainerPathToHost(sessionId: string, state: SessionState, pathValue: string): string {
|
|
@@ -39,7 +54,7 @@ function mapContainerPathToHost(sessionId: string, state: SessionState, pathValu
|
|
|
39
54
|
return pathValue;
|
|
40
55
|
}
|
|
41
56
|
|
|
42
|
-
export function resolveSessionGitInfo(sessionId: string, state: SessionState): void {
|
|
57
|
+
export async function resolveSessionGitInfo(sessionId: string, state: SessionState): Promise<void> {
|
|
43
58
|
if (!state.cwd) return;
|
|
44
59
|
const wasContainerized = state.is_containerized;
|
|
45
60
|
const previous = {
|
|
@@ -50,10 +65,10 @@ export function resolveSessionGitInfo(sessionId: string, state: SessionState): v
|
|
|
50
65
|
git_behind: state.git_behind,
|
|
51
66
|
};
|
|
52
67
|
try {
|
|
53
|
-
state.git_branch = runGitCommand(sessionId, state, "git rev-parse --abbrev-ref HEAD 2>/dev/null");
|
|
68
|
+
state.git_branch = await runGitCommand(sessionId, state, "git rev-parse --abbrev-ref HEAD 2>/dev/null");
|
|
54
69
|
|
|
55
70
|
try {
|
|
56
|
-
const gitDir = runGitCommand(sessionId, state, "git rev-parse --git-dir 2>/dev/null");
|
|
71
|
+
const gitDir = await runGitCommand(sessionId, state, "git rev-parse --git-dir 2>/dev/null");
|
|
57
72
|
state.is_worktree = gitDir.includes("/worktrees/");
|
|
58
73
|
} catch {
|
|
59
74
|
state.is_worktree = false;
|
|
@@ -61,10 +76,10 @@ export function resolveSessionGitInfo(sessionId: string, state: SessionState): v
|
|
|
61
76
|
|
|
62
77
|
try {
|
|
63
78
|
if (state.is_worktree) {
|
|
64
|
-
const commonDir = runGitCommand(sessionId, state, "git rev-parse --git-common-dir 2>/dev/null");
|
|
79
|
+
const commonDir = await runGitCommand(sessionId, state, "git rev-parse --git-common-dir 2>/dev/null");
|
|
65
80
|
state.repo_root = resolve(state.cwd, commonDir, "..");
|
|
66
81
|
} else {
|
|
67
|
-
state.repo_root = runGitCommand(sessionId, state, "git rev-parse --show-toplevel 2>/dev/null");
|
|
82
|
+
state.repo_root = await runGitCommand(sessionId, state, "git rev-parse --show-toplevel 2>/dev/null");
|
|
68
83
|
}
|
|
69
84
|
state.repo_root = mapContainerPathToHost(sessionId, state, state.repo_root);
|
|
70
85
|
} catch {
|
|
@@ -72,7 +87,7 @@ export function resolveSessionGitInfo(sessionId: string, state: SessionState): v
|
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
try {
|
|
75
|
-
const counts = runGitCommand(
|
|
90
|
+
const counts = await runGitCommand(
|
|
76
91
|
sessionId,
|
|
77
92
|
state,
|
|
78
93
|
"git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null",
|
package/server/ws-bridge.test.ts
CHANGED
|
@@ -17,7 +17,19 @@ if (typeof globalThis.Bun === "undefined") {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const mockExecSync = vi.hoisted(() => vi.fn());
|
|
20
|
-
|
|
20
|
+
// session-git-info now uses async exec (the sync version stalled the event
|
|
21
|
+
// loop on browser-connect storms); delegate it to the same mockExecSync
|
|
22
|
+
// fixtures so every test keeps configuring git output in one place.
|
|
23
|
+
const mockExec = vi.hoisted(() => (cmd: string, opts: unknown, cb: (err: Error | null, stdout: string) => void) => {
|
|
24
|
+
process.nextTick(() => {
|
|
25
|
+
try {
|
|
26
|
+
cb(null, String(mockExecSync(cmd, opts)));
|
|
27
|
+
} catch (err) {
|
|
28
|
+
cb(err as Error, "");
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
vi.mock("node:child_process", () => ({ execSync: mockExecSync, exec: mockExec }));
|
|
21
33
|
vi.mock("node:crypto", () => ({ randomUUID: () => "test-uuid" }));
|
|
22
34
|
|
|
23
35
|
// Mock settings-manager to prevent AI validation from interfering with tests.
|
|
@@ -680,7 +692,7 @@ describe("CLI handlers", () => {
|
|
|
680
692
|
|
|
681
693
|
const state = bridge.getSession("s1")!.state;
|
|
682
694
|
expect(state.cwd).toBe("/Users/stan/Dev/myproject");
|
|
683
|
-
expect(state.git_branch).toBe("container-branch");
|
|
695
|
+
await vi.waitFor(() => expect(state.git_branch).toBe("container-branch"));
|
|
684
696
|
expect(state.repo_root).toBe("/Users/stan/Dev/myproject");
|
|
685
697
|
expect(state.git_behind).toBe(1);
|
|
686
698
|
expect(state.git_ahead).toBe(3);
|
|
@@ -716,7 +728,7 @@ describe("CLI handlers", () => {
|
|
|
716
728
|
await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/workspace" }));
|
|
717
729
|
|
|
718
730
|
const state = bridge.getSession("s1")!.state;
|
|
719
|
-
expect(state.repo_root).toBe("/Users/stan/Dev/myproject/packages/api");
|
|
731
|
+
await vi.waitFor(() => expect(state.repo_root).toBe("/Users/stan/Dev/myproject/packages/api"));
|
|
720
732
|
expect(getContainerSpy).toHaveBeenCalledWith("s1");
|
|
721
733
|
getContainerSpy.mockRestore();
|
|
722
734
|
});
|
|
@@ -733,8 +745,9 @@ describe("CLI handlers", () => {
|
|
|
733
745
|
bridge.handleCLIOpen(cli, "s1");
|
|
734
746
|
await bridge.handleCLIMessage(cli, makeInitMsg());
|
|
735
747
|
|
|
748
|
+
// Git info now resolves asynchronously (off the event loop) — wait for it.
|
|
749
|
+
await vi.waitFor(() => expect(bridge.getSession("s1")!.state.git_branch).toBe("feat/test-branch"));
|
|
736
750
|
const state = bridge.getSession("s1")!.state;
|
|
737
|
-
expect(state.git_branch).toBe("feat/test-branch");
|
|
738
751
|
expect(state.repo_root).toBe("/repo");
|
|
739
752
|
expect(state.git_ahead).toBe(5);
|
|
740
753
|
expect(state.git_behind).toBe(2);
|
|
@@ -754,7 +767,7 @@ describe("CLI handlers", () => {
|
|
|
754
767
|
await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/home/user/myproject" }));
|
|
755
768
|
|
|
756
769
|
const state = bridge.getSession("s1")!.state;
|
|
757
|
-
expect(state.repo_root).toBe("/home/user/myproject");
|
|
770
|
+
await vi.waitFor(() => expect(state.repo_root).toBe("/home/user/myproject"));
|
|
758
771
|
});
|
|
759
772
|
|
|
760
773
|
it("handleCLIMessage: system.status updates compacting and permissionMode", async () => {
|
|
@@ -1182,7 +1195,7 @@ describe("Browser handlers", () => {
|
|
|
1182
1195
|
expect(firstMsg.session.session_id).toBe("s1");
|
|
1183
1196
|
});
|
|
1184
1197
|
|
|
1185
|
-
it("handleBrowserOpen: refreshes git branch
|
|
1198
|
+
it("handleBrowserOpen: refreshes git branch and broadcasts the update once it lands", async () => {
|
|
1186
1199
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
1187
1200
|
if (cmd.includes("--abbrev-ref HEAD")) return "feat/dynamic-branch\n";
|
|
1188
1201
|
if (cmd.includes("--git-dir")) return ".git\n";
|
|
@@ -1201,9 +1214,17 @@ describe("Browser handlers", () => {
|
|
|
1201
1214
|
const browser = makeBrowserSocket("s1");
|
|
1202
1215
|
bridge.handleBrowserOpen(browser, "s1");
|
|
1203
1216
|
|
|
1217
|
+
// The snapshot goes out immediately — git resolution no longer blocks the
|
|
1218
|
+
// event loop (sync git on browser-connect storms starved CLI stdio and got
|
|
1219
|
+
// healthy CLIs killed as "wedged"). The refreshed branch follows as a
|
|
1220
|
+
// session_update broadcast once the async git calls land.
|
|
1204
1221
|
const firstMsg = JSON.parse(browser.send.mock.calls[0][0]);
|
|
1205
1222
|
expect(firstMsg.type).toBe("session_init");
|
|
1206
|
-
|
|
1223
|
+
await vi.waitFor(() => {
|
|
1224
|
+
const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
|
|
1225
|
+
const update = calls.find((c: { type: string }) => c.type === "session_update");
|
|
1226
|
+
expect(update?.session?.git_branch).toBe("feat/dynamic-branch");
|
|
1227
|
+
});
|
|
1207
1228
|
expect(gitInfoCb).toHaveBeenCalledWith("s1", "/repo", "feat/dynamic-branch");
|
|
1208
1229
|
});
|
|
1209
1230
|
|
|
@@ -1762,9 +1783,12 @@ describe("CLI message routing", () => {
|
|
|
1762
1783
|
|
|
1763
1784
|
await bridge.handleCLIMessage(cli, msg);
|
|
1764
1785
|
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1786
|
+
let updateMsg: any;
|
|
1787
|
+
await vi.waitFor(() => {
|
|
1788
|
+
const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
|
|
1789
|
+
updateMsg = calls.find((c: any) => c.type === "session_update");
|
|
1790
|
+
expect(updateMsg).toBeDefined();
|
|
1791
|
+
});
|
|
1768
1792
|
expect(updateMsg.session.git_branch).toBe("feat/new-branch");
|
|
1769
1793
|
expect(updateMsg.session.git_ahead).toBe(1);
|
|
1770
1794
|
expect(bridge.getSession("s1")!.state.git_branch).toBe("feat/new-branch");
|
package/server/ws-bridge.ts
CHANGED
|
@@ -187,8 +187,10 @@ export class WsBridge {
|
|
|
187
187
|
stateMachine: new SessionStateMachine(p.id, "terminated"),
|
|
188
188
|
};
|
|
189
189
|
session.state.backend_type = session.backendType;
|
|
190
|
-
// Resolve git info for restored sessions (may have been persisted
|
|
191
|
-
|
|
190
|
+
// Resolve git info for restored sessions (may have been persisted
|
|
191
|
+
// without it). Fire-and-forget: the info lands asynchronously and
|
|
192
|
+
// nothing in the restore path reads it synchronously.
|
|
193
|
+
void resolveSessionGitInfo(session.id, session.state);
|
|
192
194
|
this.sessions.set(p.id, session);
|
|
193
195
|
// Restored sessions with completed turns don't need auto-naming re-triggered
|
|
194
196
|
if (session.state.num_turns > 0) {
|
|
@@ -207,10 +209,56 @@ export class WsBridge {
|
|
|
207
209
|
persistSessionFn(session, this.store);
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
/** In-flight git refresh bookkeeping per session: a refresh requested while
|
|
213
|
+
* one is running is coalesced into a single re-run (options OR-merged),
|
|
214
|
+
* because the running pass may be reading a state object that a
|
|
215
|
+
* `system.init`/`session_update` handler has since replaced. */
|
|
216
|
+
private gitRefreshPending = new Map<string, {
|
|
217
|
+
rerun: boolean;
|
|
218
|
+
options: { broadcastUpdate?: boolean; notifyPoller?: boolean };
|
|
219
|
+
}>();
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fire-and-forget: git resolution now runs async subprocesses, so this
|
|
223
|
+
* returns immediately and broadcasts/persists when the info lands. A
|
|
224
|
+
* browser reconnect storm used to trigger a burst of synchronous git calls
|
|
225
|
+
* (4 per session × every session) that stalled the event loop and starved
|
|
226
|
+
* live CLI stdio streams; concurrent refreshes for the same session are
|
|
227
|
+
* coalesced instead of stacking.
|
|
228
|
+
*/
|
|
210
229
|
private refreshGitInfo(
|
|
211
230
|
session: Session,
|
|
212
231
|
options: { broadcastUpdate?: boolean; notifyPoller?: boolean } = {},
|
|
213
232
|
): void {
|
|
233
|
+
const pending = this.gitRefreshPending.get(session.id);
|
|
234
|
+
if (pending) {
|
|
235
|
+
pending.rerun = true;
|
|
236
|
+
pending.options = {
|
|
237
|
+
broadcastUpdate: pending.options.broadcastUpdate || options.broadcastUpdate,
|
|
238
|
+
notifyPoller: pending.options.notifyPoller || options.notifyPoller,
|
|
239
|
+
};
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const entry = { rerun: false, options: { ...options } };
|
|
243
|
+
this.gitRefreshPending.set(session.id, entry);
|
|
244
|
+
void (async () => {
|
|
245
|
+
try {
|
|
246
|
+
do {
|
|
247
|
+
entry.rerun = false;
|
|
248
|
+
// Reads session.state fresh on every pass, so a re-run picks up a
|
|
249
|
+
// state object swapped in while the previous pass was mid-flight.
|
|
250
|
+
await this.doRefreshGitInfo(session, entry.options);
|
|
251
|
+
} while (entry.rerun);
|
|
252
|
+
} finally {
|
|
253
|
+
this.gitRefreshPending.delete(session.id);
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async doRefreshGitInfo(
|
|
259
|
+
session: Session,
|
|
260
|
+
options: { broadcastUpdate?: boolean; notifyPoller?: boolean } = {},
|
|
261
|
+
): Promise<void> {
|
|
214
262
|
const before = {
|
|
215
263
|
git_branch: session.state.git_branch,
|
|
216
264
|
is_worktree: session.state.is_worktree,
|
|
@@ -220,7 +268,7 @@ export class WsBridge {
|
|
|
220
268
|
git_behind: session.state.git_behind,
|
|
221
269
|
};
|
|
222
270
|
|
|
223
|
-
resolveSessionGitInfo(session.id, session.state);
|
|
271
|
+
await resolveSessionGitInfo(session.id, session.state);
|
|
224
272
|
|
|
225
273
|
let changed = false;
|
|
226
274
|
for (const key of WsBridge.GIT_SESSION_KEYS) {
|
|
@@ -449,7 +497,10 @@ export class WsBridge {
|
|
|
449
497
|
// treated as an auth failure and denied keepalive relaunch. authBlocked
|
|
450
498
|
// is re-set the moment another auth-error result arrives.
|
|
451
499
|
session.authBlocked = false;
|
|
452
|
-
|
|
500
|
+
// broadcastUpdate: the refresh is async now, so fresh git info can no
|
|
501
|
+
// longer ride along in the snapshot broadcast below — it follows as a
|
|
502
|
+
// session_update once the git calls land (only when something changed).
|
|
503
|
+
this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
|
|
453
504
|
this.broadcastToBrowsers(session, { type: "session_init", session: session.state });
|
|
454
505
|
session.stateMachine.transition("ready", "system_init");
|
|
455
506
|
this.persistSession(session);
|
|
@@ -467,7 +518,10 @@ export class WsBridge {
|
|
|
467
518
|
...(skills?.length ? { skills } : {}),
|
|
468
519
|
backend_type: session.backendType,
|
|
469
520
|
};
|
|
470
|
-
|
|
521
|
+
// broadcastUpdate: the refresh is async now, so fresh git info can no
|
|
522
|
+
// longer ride along in the snapshot broadcast below — it follows as a
|
|
523
|
+
// session_update once the git calls land (only when something changed).
|
|
524
|
+
this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
|
|
471
525
|
this.persistSession(session);
|
|
472
526
|
if (session.pendingMessages.length > 0 && adapter.isConnected()) {
|
|
473
527
|
this.flushQueuedBrowserMessages(session, adapter, "backend_session_update");
|
|
@@ -1002,7 +1056,10 @@ export class WsBridge {
|
|
|
1002
1056
|
this.stopIdleKillWatchdog(sessionId);
|
|
1003
1057
|
|
|
1004
1058
|
// Refresh git state on browser connect so branch changes made mid-session are reflected.
|
|
1005
|
-
|
|
1059
|
+
// broadcastUpdate: the refresh is async now, so fresh git info can no
|
|
1060
|
+
// longer ride along in the snapshot broadcast below — it follows as a
|
|
1061
|
+
// session_update once the git calls land (only when something changed).
|
|
1062
|
+
this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
|
|
1006
1063
|
|
|
1007
1064
|
// Send current session state as snapshot
|
|
1008
1065
|
const snapshot: BrowserIncomingMessage = {
|