@cueloop/daemon 0.1.0-alpha.80 → 0.1.0-alpha.82
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/package.json +6 -3
- package/src/api.test.ts +15 -0
- package/src/api.ts +45 -10
- package/src/capabilities.ts +7 -2
- package/src/client.ts +170 -43
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +15 -0
- package/src/harness-state-store.ts +6 -7
- package/src/{herdr-tab-store.test.ts → herdr-thread-surface-store.test.ts} +16 -31
- package/src/herdr-thread-surface-store.ts +64 -0
- package/src/herdr-thread-surface.test.ts +411 -0
- package/src/herdr-thread-surface.ts +355 -0
- package/src/paths.ts +7 -2
- package/src/server.test.ts +2 -0
- package/src/server.ts +67 -11
- package/src/single-instance.test.ts +90 -2
- package/src/thread-review.ts +4 -14
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.ts +13 -3
- package/src/herdr-pane.test.ts +0 -240
- package/src/herdr-pane.ts +0 -167
- package/src/herdr-tab-store.ts +0 -66
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectHerdr,
|
|
3
|
+
type HerdrEnv,
|
|
4
|
+
type Thread,
|
|
5
|
+
type ThreadSurfaceOpenStatus,
|
|
6
|
+
} from "@cueloop/schema";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import * as v from "valibot";
|
|
9
|
+
import type { HerdrThreadSurfaceHandle } from "./herdr-thread-surface-store";
|
|
10
|
+
import { loadHerdrThreadSurface, type HerdrThreadSurface } from "./thread-surface-config";
|
|
11
|
+
|
|
12
|
+
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
13
|
+
|
|
14
|
+
interface HerdrCommandResult {
|
|
15
|
+
exitCode: number;
|
|
16
|
+
stdout: Buffer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function runHerdrCommand(
|
|
20
|
+
command: string[],
|
|
21
|
+
options: { stdout: "pipe" | "ignore"; stderr: "ignore"; timeout: number },
|
|
22
|
+
): HerdrCommandResult {
|
|
23
|
+
const result = spawnSync(command[0]!, command.slice(1), {
|
|
24
|
+
stdio: ["ignore", options.stdout, options.stderr],
|
|
25
|
+
timeout: options.timeout,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return { exitCode: result.status ?? 1, stdout: result.stdout ?? Buffer.alloc(0) };
|
|
29
|
+
}
|
|
30
|
+
const CreatedTabSchema = v.object({
|
|
31
|
+
result: v.optional(
|
|
32
|
+
v.object({
|
|
33
|
+
root_pane: v.optional(
|
|
34
|
+
v.object({ pane_id: v.optional(v.string()), tab_id: v.optional(v.string()) }),
|
|
35
|
+
),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
const PaneResultSchema = v.object({
|
|
40
|
+
result: v.optional(v.object({ pane: v.optional(v.unknown()) })),
|
|
41
|
+
});
|
|
42
|
+
const CreatedPaneSchema = v.object({
|
|
43
|
+
result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.string() })) })),
|
|
44
|
+
});
|
|
45
|
+
const PaneNeighborSchema = v.object({
|
|
46
|
+
result: v.object({
|
|
47
|
+
neighbor: v.object({ neighbor_pane_id: v.optional(v.nullable(v.string())) }),
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
const PaneFocusSchema = v.object({
|
|
51
|
+
result: v.optional(
|
|
52
|
+
v.object({ focus: v.optional(v.object({ focused_pane_id: v.nullable(v.string()) })) }),
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Inputs for a new Herdr Thread tab. */
|
|
57
|
+
export interface OpenHerdrThreadTabOptions {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
cwd: string;
|
|
60
|
+
binPath: string;
|
|
61
|
+
label: string;
|
|
62
|
+
workspaceId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Daemon-owned native handles, separate from canonical Thread records. */
|
|
66
|
+
export interface HerdrThreadSurfacePersistence {
|
|
67
|
+
herdrGetThreadSurface(sessionId: string): Promise<HerdrThreadSurfaceHandle | null>;
|
|
68
|
+
herdrSetThreadSurface(sessionId: string, handle: HerdrThreadSurfaceHandle): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Open a focused Herdr tab; keep both native IDs for later liveness checks. */
|
|
72
|
+
export function openHerdrThreadTab(
|
|
73
|
+
options: OpenHerdrThreadTabOptions,
|
|
74
|
+
): HerdrThreadSurfaceHandle | null {
|
|
75
|
+
const { sessionId, cwd, binPath, label, workspaceId } = options;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const created = runHerdrCommand(
|
|
79
|
+
[
|
|
80
|
+
binPath,
|
|
81
|
+
"tab",
|
|
82
|
+
"create",
|
|
83
|
+
...(workspaceId ? ["--workspace", workspaceId] : []),
|
|
84
|
+
"--cwd",
|
|
85
|
+
cwd,
|
|
86
|
+
"--label",
|
|
87
|
+
label,
|
|
88
|
+
"--focus",
|
|
89
|
+
],
|
|
90
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (created.exitCode !== 0) return null;
|
|
94
|
+
const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
|
|
95
|
+
|
|
96
|
+
if (!parsed.success) return null;
|
|
97
|
+
const paneId = parsed.output.result?.root_pane?.pane_id;
|
|
98
|
+
const tabId = parsed.output.result?.root_pane?.tab_id;
|
|
99
|
+
|
|
100
|
+
if (!paneId || !tabId) return null;
|
|
101
|
+
|
|
102
|
+
if (!sendCueloopThreadCommand(binPath, paneId, sessionId)) {
|
|
103
|
+
closeUnlaunchedHerdrSurface(binPath, "tab", tabId);
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { tabId, paneId };
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Inputs for a focused right-hand Herdr Thread pane. */
|
|
115
|
+
export interface OpenHerdrThreadPaneOptions {
|
|
116
|
+
sessionId: string;
|
|
117
|
+
cwd: string;
|
|
118
|
+
binPath: string;
|
|
119
|
+
sourcePaneId: string;
|
|
120
|
+
tabId: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Split the calling Herdr pane right at 50 percent and launch cueloop in it. */
|
|
124
|
+
export function openHerdrThreadPane(
|
|
125
|
+
options: OpenHerdrThreadPaneOptions,
|
|
126
|
+
): HerdrThreadSurfaceHandle | null {
|
|
127
|
+
const { sessionId, cwd, binPath, sourcePaneId, tabId } = options;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const created = runHerdrCommand(
|
|
131
|
+
[
|
|
132
|
+
binPath,
|
|
133
|
+
"pane",
|
|
134
|
+
"split",
|
|
135
|
+
sourcePaneId,
|
|
136
|
+
"--direction",
|
|
137
|
+
"right",
|
|
138
|
+
"--ratio",
|
|
139
|
+
"0.5",
|
|
140
|
+
"--cwd",
|
|
141
|
+
cwd,
|
|
142
|
+
"--focus",
|
|
143
|
+
],
|
|
144
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (created.exitCode !== 0) return null;
|
|
148
|
+
const parsed = v.safeParse(CreatedPaneSchema, JSON.parse(created.stdout.toString()));
|
|
149
|
+
const paneId = parsed.success ? parsed.output.result?.pane?.pane_id : undefined;
|
|
150
|
+
|
|
151
|
+
if (!paneId) return null;
|
|
152
|
+
if (!sendCueloopThreadCommand(binPath, paneId, sessionId)) {
|
|
153
|
+
closeUnlaunchedHerdrSurface(binPath, "pane", paneId);
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { mode: "pane", tabId, paneId };
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function closeUnlaunchedHerdrSurface(binPath: string, kind: "tab" | "pane", id: string): void {
|
|
165
|
+
try {
|
|
166
|
+
runHerdrCommand([binPath, kind, "close", id], {
|
|
167
|
+
stdout: "ignore",
|
|
168
|
+
stderr: "ignore",
|
|
169
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
// The pending Thread remains available through the manual command.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sendCueloopThreadCommand(binPath: string, paneId: string, sessionId: string): boolean {
|
|
177
|
+
const typed = runHerdrCommand([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
|
|
178
|
+
stdout: "ignore",
|
|
179
|
+
stderr: "ignore",
|
|
180
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (typed.exitCode !== 0) return false;
|
|
184
|
+
const entered = runHerdrCommand([binPath, "pane", "send-keys", paneId, "enter"], {
|
|
185
|
+
stdout: "ignore",
|
|
186
|
+
stderr: "ignore",
|
|
187
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return entered.exitCode === 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Check the recorded pane ID before deciding whether to reopen. */
|
|
194
|
+
function herdrPaneAlive(binPath: string, paneId: string): boolean {
|
|
195
|
+
try {
|
|
196
|
+
const got = runHerdrCommand([binPath, "pane", "get", paneId], {
|
|
197
|
+
stdout: "pipe",
|
|
198
|
+
stderr: "ignore",
|
|
199
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (got.exitCode !== 0) return false;
|
|
203
|
+
const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
|
|
204
|
+
|
|
205
|
+
return parsed.success && parsed.output.result?.pane != null;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Focus a recorded Herdr tab. */
|
|
212
|
+
function focusHerdrTab(binPath: string, tabId: string): boolean {
|
|
213
|
+
try {
|
|
214
|
+
const focused = runHerdrCommand([binPath, "tab", "focus", tabId], {
|
|
215
|
+
stdout: "ignore",
|
|
216
|
+
stderr: "ignore",
|
|
217
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return focused.exitCode === 0;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function focusHerdrThreadSurface(binPath: string, handle: HerdrThreadSurfaceHandle): boolean {
|
|
227
|
+
if (!focusHerdrTab(binPath, handle.tabId)) return false;
|
|
228
|
+
|
|
229
|
+
if (handle.mode !== "pane") return true;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const directions = [
|
|
233
|
+
["left", "right"],
|
|
234
|
+
["right", "left"],
|
|
235
|
+
["up", "down"],
|
|
236
|
+
["down", "up"],
|
|
237
|
+
] as const;
|
|
238
|
+
let hasNeighbor = false;
|
|
239
|
+
|
|
240
|
+
for (const [direction, opposite] of directions) {
|
|
241
|
+
const neighbor = herdrPaneNeighbor(binPath, handle.paneId, direction);
|
|
242
|
+
|
|
243
|
+
if (neighbor === undefined) return false;
|
|
244
|
+
if (neighbor === null) continue;
|
|
245
|
+
hasNeighbor = true;
|
|
246
|
+
|
|
247
|
+
if (herdrPaneNeighbor(binPath, neighbor, opposite) !== handle.paneId) continue;
|
|
248
|
+
const focused = runHerdrCommand(
|
|
249
|
+
[binPath, "pane", "focus", "--pane", neighbor, "--direction", opposite],
|
|
250
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
251
|
+
);
|
|
252
|
+
const parsed = v.safeParse(PaneFocusSchema, JSON.parse(focused.stdout.toString()));
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
focused.exitCode === 0 &&
|
|
256
|
+
parsed.success &&
|
|
257
|
+
parsed.output.result?.focus?.focused_pane_id === handle.paneId
|
|
258
|
+
) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return !hasNeighbor;
|
|
264
|
+
} catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function herdrPaneNeighbor(
|
|
270
|
+
binPath: string,
|
|
271
|
+
paneId: string,
|
|
272
|
+
direction: string,
|
|
273
|
+
): string | null | undefined {
|
|
274
|
+
try {
|
|
275
|
+
const neighbor = runHerdrCommand(
|
|
276
|
+
[binPath, "pane", "neighbor", "--pane", paneId, "--direction", direction],
|
|
277
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (neighbor.exitCode !== 0) return undefined;
|
|
281
|
+
const parsed = v.safeParse(PaneNeighborSchema, JSON.parse(neighbor.stdout.toString()));
|
|
282
|
+
|
|
283
|
+
return parsed.success ? (parsed.output.result.neighbor.neighbor_pane_id ?? null) : undefined;
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Reuse a live Thread surface, reopen a closed one, or report a manual fallback. */
|
|
290
|
+
export async function openHerdrThreadSurface(
|
|
291
|
+
session: Thread,
|
|
292
|
+
persistence: HerdrThreadSurfacePersistence,
|
|
293
|
+
env: HerdrEnv = process.env,
|
|
294
|
+
mode: HerdrThreadSurface = loadHerdrThreadSurface(),
|
|
295
|
+
): Promise<ThreadSurfaceOpenStatus> {
|
|
296
|
+
const herdr = detectHerdr(env);
|
|
297
|
+
|
|
298
|
+
if (!herdr) return "unavailable";
|
|
299
|
+
|
|
300
|
+
if (mode === "none") return "disabled";
|
|
301
|
+
const recorded = await recallHerdrThreadSurface(persistence, session.id);
|
|
302
|
+
|
|
303
|
+
if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
|
|
304
|
+
return focusHerdrThreadSurface(herdr.binPath, recorded) ? "focused" : "failed";
|
|
305
|
+
}
|
|
306
|
+
const cwd = session.artifact.meta.cwd ?? session.workspace.repoRoot;
|
|
307
|
+
const opened =
|
|
308
|
+
mode === "pane"
|
|
309
|
+
? env.HERDR_TAB_ID
|
|
310
|
+
? openHerdrThreadPane({
|
|
311
|
+
sessionId: session.id,
|
|
312
|
+
cwd,
|
|
313
|
+
binPath: herdr.binPath,
|
|
314
|
+
sourcePaneId: herdr.paneId,
|
|
315
|
+
tabId: env.HERDR_TAB_ID,
|
|
316
|
+
})
|
|
317
|
+
: null
|
|
318
|
+
: openHerdrThreadTab({
|
|
319
|
+
sessionId: session.id,
|
|
320
|
+
cwd,
|
|
321
|
+
binPath: herdr.binPath,
|
|
322
|
+
label: session.artifact.meta.title ?? session.id,
|
|
323
|
+
workspaceId: env.HERDR_WORKSPACE_ID,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (!opened) return "failed";
|
|
327
|
+
await rememberHerdrThreadSurface(persistence, session.id, opened);
|
|
328
|
+
|
|
329
|
+
return "opened";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A stale daemon cannot prevent the reviewer from opening the pending Thread. */
|
|
333
|
+
async function recallHerdrThreadSurface(
|
|
334
|
+
persistence: HerdrThreadSurfacePersistence,
|
|
335
|
+
sessionId: string,
|
|
336
|
+
): Promise<HerdrThreadSurfaceHandle | null> {
|
|
337
|
+
try {
|
|
338
|
+
return await persistence.herdrGetThreadSurface(sessionId);
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Handle persistence is best-effort after the new terminal has opened. */
|
|
345
|
+
async function rememberHerdrThreadSurface(
|
|
346
|
+
persistence: HerdrThreadSurfacePersistence,
|
|
347
|
+
sessionId: string,
|
|
348
|
+
handle: HerdrThreadSurfaceHandle,
|
|
349
|
+
): Promise<void> {
|
|
350
|
+
try {
|
|
351
|
+
await persistence.herdrSetThreadSurface(sessionId, handle);
|
|
352
|
+
} catch {
|
|
353
|
+
// The Thread remains pending even if the old daemon cannot store this handle.
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -60,11 +60,16 @@ export function ownerTokenPath(home = cueloopHome()): string {
|
|
|
60
60
|
return join(home, "owner.token");
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** Adapter scratch
|
|
64
|
-
export function
|
|
63
|
+
/** Adapter scratch for Herdr handles; the disk name stays stable across tab and pane support. */
|
|
64
|
+
export function herdrThreadSurfacesPath(home = cueloopHome()): string {
|
|
65
65
|
return join(home, "herdr-tabs.json");
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Ghostty-native terminal IDs are adapter scratch, not Thread state. */
|
|
69
|
+
export function ghosttyThreadSurfacesPath(home = cueloopHome()): string {
|
|
70
|
+
return join(home, "ghostty-thread-surfaces.json");
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
/** Harness bindings and Message deliveries, stored independently of Thread records. */
|
|
69
74
|
export function harnessStatePath(home = cueloopHome()): string {
|
|
70
75
|
return join(home, "harness-state.json");
|
package/src/server.test.ts
CHANGED
|
@@ -59,6 +59,8 @@ describe("socket round-trip", () => {
|
|
|
59
59
|
const binding = await client.harnessBind(thread.id, "fake", "fake_1");
|
|
60
60
|
|
|
61
61
|
expect(await client.harnessGetBinding(binding.id)).toEqual(binding);
|
|
62
|
+
expect(await client.harnessBindingsForSession("fake", "fake_1")).toEqual([binding]);
|
|
63
|
+
expect(await client.harnessBindingsForSession("fake", "different")).toEqual([]);
|
|
62
64
|
|
|
63
65
|
const sent = await client.sessionSendMessage(thread.id, "approved", "Ready.");
|
|
64
66
|
const pending = await client.deliveryPending(binding.id);
|
package/src/server.ts
CHANGED
|
@@ -8,10 +8,13 @@ import {
|
|
|
8
8
|
chmodSync,
|
|
9
9
|
closeSync,
|
|
10
10
|
existsSync,
|
|
11
|
+
fstatSync,
|
|
12
|
+
linkSync,
|
|
11
13
|
mkdirSync,
|
|
12
14
|
openSync,
|
|
13
15
|
readFileSync,
|
|
14
16
|
rmSync,
|
|
17
|
+
statSync,
|
|
15
18
|
writeFileSync,
|
|
16
19
|
} from "node:fs";
|
|
17
20
|
import { DaemonCore, type DaemonEvent } from "./api";
|
|
@@ -55,6 +58,7 @@ export interface DaemonOptions {
|
|
|
55
58
|
* so in-process ownership is tracked separately.
|
|
56
59
|
*/
|
|
57
60
|
const HELD_HOMES = new Set<string>();
|
|
61
|
+
const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
58
62
|
|
|
59
63
|
export class DaemonServer {
|
|
60
64
|
readonly core: DaemonCore;
|
|
@@ -91,20 +95,35 @@ export class DaemonServer {
|
|
|
91
95
|
const path = lockPath(this.home);
|
|
92
96
|
|
|
93
97
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const fd = openSync(path, "wx");
|
|
98
|
+
const preparedPath = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
99
|
+
const fd = openSync(preparedPath, "wx", 0o600);
|
|
97
100
|
|
|
101
|
+
try {
|
|
102
|
+
// Publish the complete pid atomically. A competing daemon must never
|
|
103
|
+
// mistake a newly created, still-empty lock for a crashed owner.
|
|
98
104
|
writeFileSync(fd, String(process.pid));
|
|
105
|
+
linkSync(preparedPath, path);
|
|
99
106
|
this.lockFd = fd;
|
|
100
107
|
HELD_HOMES.add(this.home);
|
|
101
108
|
|
|
102
109
|
return true;
|
|
103
|
-
} catch {
|
|
110
|
+
} catch (error) {
|
|
111
|
+
closeSync(fd);
|
|
112
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
113
|
+
|
|
104
114
|
let ownerPid = 0;
|
|
105
115
|
|
|
106
116
|
try {
|
|
107
|
-
|
|
117
|
+
let ownerText = readFileSync(path, "utf8").trim();
|
|
118
|
+
|
|
119
|
+
// Older daemon builds published the lock before writing their pid.
|
|
120
|
+
// Give that live owner time to finish instead of stealing its socket.
|
|
121
|
+
for (let probe = 0; !ownerText && probe < 25; probe++) {
|
|
122
|
+
Atomics.wait(LOCK_WAIT, 0, 0, 20);
|
|
123
|
+
ownerText = readFileSync(path, "utf8").trim();
|
|
124
|
+
}
|
|
125
|
+
if (!ownerText && Date.now() - statSync(path).mtimeMs < 5_000) return false;
|
|
126
|
+
ownerPid = Number(ownerText);
|
|
108
127
|
} catch {
|
|
109
128
|
// the owner vanished between open and read; retry
|
|
110
129
|
continue;
|
|
@@ -125,6 +144,8 @@ export class DaemonServer {
|
|
|
125
144
|
} catch {
|
|
126
145
|
return false;
|
|
127
146
|
}
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(preparedPath, { force: true });
|
|
128
149
|
}
|
|
129
150
|
}
|
|
130
151
|
|
|
@@ -134,6 +155,12 @@ export class DaemonServer {
|
|
|
134
155
|
private releaseLock(): void {
|
|
135
156
|
HELD_HOMES.delete(this.home);
|
|
136
157
|
if (this.lockFd !== null) {
|
|
158
|
+
try {
|
|
159
|
+
if (statSync(lockPath(this.home)).ino === fstatSync(this.lockFd).ino)
|
|
160
|
+
rmSync(lockPath(this.home), { force: true });
|
|
161
|
+
} catch {
|
|
162
|
+
// the lock was already removed or replaced
|
|
163
|
+
}
|
|
137
164
|
try {
|
|
138
165
|
closeSync(this.lockFd);
|
|
139
166
|
} catch {
|
|
@@ -141,7 +168,6 @@ export class DaemonServer {
|
|
|
141
168
|
}
|
|
142
169
|
this.lockFd = null;
|
|
143
170
|
}
|
|
144
|
-
rmSync(lockPath(this.home), { force: true });
|
|
145
171
|
}
|
|
146
172
|
|
|
147
173
|
/**
|
|
@@ -205,6 +231,7 @@ export class DaemonServer {
|
|
|
205
231
|
|
|
206
232
|
stop(): void {
|
|
207
233
|
this.core.dispose();
|
|
234
|
+
if (this.lockFd === null) return;
|
|
208
235
|
this.server?.stop(true);
|
|
209
236
|
this.server = null;
|
|
210
237
|
rmSync(socketPath(this.home), { force: true });
|
|
@@ -469,6 +496,11 @@ export class DaemonServer {
|
|
|
469
496
|
|
|
470
497
|
return this.core.harnessGetBinding(params.bindingId);
|
|
471
498
|
},
|
|
499
|
+
"harness.bindingsForSession": (_connection, request) => {
|
|
500
|
+
const params = parseParams("harness.bindingsForSession", request.params);
|
|
501
|
+
|
|
502
|
+
return this.core.harnessBindingsForSession(params.harness, params.harnessSessionId);
|
|
503
|
+
},
|
|
472
504
|
"harness.consumeApprovedRetry": (_connection, request) => {
|
|
473
505
|
const params = parseParams("harness.consumeApprovedRetry", request.params);
|
|
474
506
|
|
|
@@ -498,12 +530,36 @@ export class DaemonServer {
|
|
|
498
530
|
params.files,
|
|
499
531
|
);
|
|
500
532
|
},
|
|
501
|
-
"herdr.
|
|
502
|
-
this.core.
|
|
503
|
-
"herdr.
|
|
504
|
-
const params = parseParams("herdr.
|
|
533
|
+
"herdr.getThreadSurface": (_connection, request) =>
|
|
534
|
+
this.core.herdrGetThreadSurface(parseParams("herdr.getThreadSurface", request.params).id),
|
|
535
|
+
"herdr.setThreadSurface": (_connection, request) => {
|
|
536
|
+
const params = parseParams("herdr.setThreadSurface", request.params);
|
|
537
|
+
|
|
538
|
+
this.core.herdrSetThreadSurface(params.id, {
|
|
539
|
+
tabId: params.tabId,
|
|
540
|
+
paneId: params.paneId,
|
|
541
|
+
mode: params.mode,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return {};
|
|
545
|
+
},
|
|
546
|
+
"ghostty.getThreadSurface": (_connection, request) =>
|
|
547
|
+
this.core.ghosttyGetThreadSurface(parseParams("ghostty.getThreadSurface", request.params).id),
|
|
548
|
+
"ghostty.setThreadSurface": (_connection, request) => {
|
|
549
|
+
const params = parseParams("ghostty.setThreadSurface", request.params);
|
|
550
|
+
|
|
551
|
+
this.core.ghosttySetThreadSurface(params.id, { terminalId: params.terminalId });
|
|
505
552
|
|
|
506
|
-
|
|
553
|
+
return {};
|
|
554
|
+
},
|
|
555
|
+
"ghostty.claimThreadSurface": (_connection, request) =>
|
|
556
|
+
this.core.ghosttyClaimThreadSurface(
|
|
557
|
+
parseParams("ghostty.claimThreadSurface", request.params).id,
|
|
558
|
+
),
|
|
559
|
+
"ghostty.releaseThreadSurface": (_connection, request) => {
|
|
560
|
+
this.core.ghosttyReleaseThreadSurface(
|
|
561
|
+
parseParams("ghostty.releaseThreadSurface", request.params).id,
|
|
562
|
+
);
|
|
507
563
|
|
|
508
564
|
return {};
|
|
509
565
|
},
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { createServer } from "node:net";
|
|
11
12
|
import { DaemonServer } from "./server";
|
|
12
13
|
import { DaemonClient } from "./client";
|
|
13
|
-
import { lockPath, socketPath } from "./paths";
|
|
14
|
+
import { lockPath, ownerTokenPath, socketPath } from "./paths";
|
|
14
15
|
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
15
16
|
|
|
16
17
|
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
@@ -50,6 +51,23 @@ describe("one daemon per home", () => {
|
|
|
50
51
|
expect(existsSync(path!)).toBe(true);
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
test("stopping a refused server leaves the lock owner's socket intact", async () => {
|
|
55
|
+
const first = server();
|
|
56
|
+
const path = first.start();
|
|
57
|
+
const refused = server();
|
|
58
|
+
|
|
59
|
+
expect(refused.start()).toBeNull();
|
|
60
|
+
refused.stop();
|
|
61
|
+
expect(existsSync(path!)).toBe(true);
|
|
62
|
+
const client = await DaemonClient.connect({ home });
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
expect((await client.ping()).pid).toBe(process.pid);
|
|
66
|
+
} finally {
|
|
67
|
+
client.close();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
53
71
|
test("clients keep talking to the one live daemon after a refused start", async () => {
|
|
54
72
|
// Arrange
|
|
55
73
|
const first = server();
|
|
@@ -81,6 +99,32 @@ describe("one daemon per home", () => {
|
|
|
81
99
|
expect(daemonServer.start()).toBe(socketPath(home));
|
|
82
100
|
});
|
|
83
101
|
|
|
102
|
+
test("a daemon does not steal a lock while its owner writes the pid", async () => {
|
|
103
|
+
const path = lockPath(home);
|
|
104
|
+
const script = `
|
|
105
|
+
import { openSync, writeFileSync } from "node:fs";
|
|
106
|
+
const fd = openSync(${JSON.stringify(path)}, "wx");
|
|
107
|
+
console.log("lock created");
|
|
108
|
+
setTimeout(() => writeFileSync(fd, String(process.pid)), 100);
|
|
109
|
+
setTimeout(() => {}, 2_000);
|
|
110
|
+
`;
|
|
111
|
+
const owner = Bun.spawn([process.execPath, "-e", script], {
|
|
112
|
+
stdout: "pipe",
|
|
113
|
+
stderr: "pipe",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const firstOutput = await owner.stdout.getReader().read();
|
|
118
|
+
|
|
119
|
+
expect(new TextDecoder().decode(firstOutput.value)).toContain("lock created");
|
|
120
|
+
expect(server().start()).toBeNull();
|
|
121
|
+
expect(existsSync(socketPath(home))).toBe(false);
|
|
122
|
+
} finally {
|
|
123
|
+
owner.kill();
|
|
124
|
+
await owner.exited;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
84
128
|
test("an autostarted daemon reclaims a stale socket after taking the lock", async () => {
|
|
85
129
|
writeFileSync(socketPath(home), "stale socket");
|
|
86
130
|
const client = await DaemonClient.connect({ home, autostart: true });
|
|
@@ -93,6 +137,20 @@ describe("one daemon per home", () => {
|
|
|
93
137
|
}
|
|
94
138
|
});
|
|
95
139
|
|
|
140
|
+
test("autostart reports the daemon error when the child cannot start", async () => {
|
|
141
|
+
mkdirSync(ownerTokenPath(home));
|
|
142
|
+
const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
|
|
143
|
+
|
|
144
|
+
// This checks error reporting, not startup speed; allow the child to start on a loaded runner.
|
|
145
|
+
process.env.CUELOOP_START_TIMEOUT_MS = "5000";
|
|
146
|
+
try {
|
|
147
|
+
await expect(DaemonClient.connect({ home, autostart: true })).rejects.toThrow("EISDIR");
|
|
148
|
+
} finally {
|
|
149
|
+
if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
|
|
150
|
+
else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
|
|
151
|
+
}
|
|
152
|
+
}, 10_000);
|
|
153
|
+
|
|
96
154
|
test("stopping releases the lock so a restart works", () => {
|
|
97
155
|
// Arrange
|
|
98
156
|
const first = server();
|
|
@@ -159,4 +217,34 @@ describe("one daemon per home", () => {
|
|
|
159
217
|
else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
|
|
160
218
|
}
|
|
161
219
|
});
|
|
220
|
+
|
|
221
|
+
test("autostart retries after a socket closes during the handshake", async () => {
|
|
222
|
+
const path = socketPath(home);
|
|
223
|
+
const replacement = server();
|
|
224
|
+
const transient = createServer((socket) => {
|
|
225
|
+
socket.destroy();
|
|
226
|
+
transient.close(() => {
|
|
227
|
+
rmSync(path, { force: true });
|
|
228
|
+
rmSync(lockPath(home), { force: true });
|
|
229
|
+
replacement.start();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
writeFileSync(lockPath(home), String(process.pid));
|
|
234
|
+
await new Promise<void>((resolve) => transient.listen(path, resolve));
|
|
235
|
+
const client = new DaemonClient();
|
|
236
|
+
const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
|
|
237
|
+
|
|
238
|
+
client["home"] = home;
|
|
239
|
+
process.env.CUELOOP_START_TIMEOUT_MS = "1000";
|
|
240
|
+
try {
|
|
241
|
+
await client["attachFreshDaemon"](home, path);
|
|
242
|
+
expect((await client.ping()).pid).toBe(process.pid);
|
|
243
|
+
} finally {
|
|
244
|
+
client.close();
|
|
245
|
+
if (transient.listening) transient.close();
|
|
246
|
+
if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
|
|
247
|
+
else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
162
250
|
});
|
package/src/thread-review.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The shared Thread review core: one open/wait/message path for every consumer -
|
|
3
|
-
* CLI commands and agent adapters. openReview resolves the workspace, shapes
|
|
4
|
-
* the artifact, and opens-or-revises by agentSessionId; awaitMessage maps the
|
|
5
|
-
* wait contract onto the agent contract through messageResponse. An
|
|
6
|
-
* adapter keeps only two bespoke parts: parsing its host's event shape and
|
|
7
|
-
* serializing the decision in its host's contract.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
import {
|
|
11
2
|
isMarkdownArtifact,
|
|
12
3
|
newAnnotationId,
|
|
@@ -18,6 +9,8 @@ import {
|
|
|
18
9
|
type WorkspaceKey,
|
|
19
10
|
type WorkflowKind,
|
|
20
11
|
} from "@cueloop/schema";
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { promisify } from "node:util";
|
|
21
14
|
import { messageResponse } from "./api";
|
|
22
15
|
import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
|
|
23
16
|
|
|
@@ -27,12 +20,9 @@ export { messageResponse };
|
|
|
27
20
|
|
|
28
21
|
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
29
22
|
try {
|
|
30
|
-
const
|
|
31
|
-
const out = await new Response(proc.stdout).text();
|
|
32
|
-
|
|
33
|
-
if ((await proc.exited) !== 0) return null;
|
|
23
|
+
const { stdout } = await promisify(execFile)("git", args, { cwd });
|
|
34
24
|
|
|
35
|
-
return
|
|
25
|
+
return stdout.trim();
|
|
36
26
|
} catch {
|
|
37
27
|
return null;
|
|
38
28
|
}
|