@cueloop/daemon 0.1.0-alpha.98 → 0.1.0-alpha.99
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/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +12 -8
- package/src/api.ts +19 -1
- package/src/capabilities.ts +1 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +189 -0
- package/src/client.ts +139 -30
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +397 -0
- package/src/examples/daemon-sdk.ts +55 -0
- package/src/harness-delivery.test.ts +1 -0
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +91 -0
- package/src/pr-poller.ts +1 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.ts +32 -2
- package/src/share-blob.ts +3 -1
- package/src/store.jsonl.test.ts +14 -0
- package/src/store.ts +11 -2
- package/src/thread-agent-validation.ts +11 -0
- package/src/thread-agent.test.ts +105 -0
- package/src/thread-agent.ts +53 -5
- package/src/thread-review.ts +1 -0
- package/src/thread-subscription.test.ts +174 -0
- package/src/thread-subscription.ts +127 -0
- package/src/validate.test.ts +1 -0
- package/src/validate.ts +10 -0
- package/src/vcs-source.ts +1 -0
- package/src/working-tree.ts +2 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonServer } from "./server";
|
|
6
|
+
import { DaemonClient } from "./client";
|
|
7
|
+
import { subscribeThreadState, connectThreadObserver } from "./thread-subscription";
|
|
8
|
+
|
|
9
|
+
import { createTestSignal } from "../../../test/helpers/test-signal";
|
|
10
|
+
|
|
11
|
+
test("subscription refresh discards stale reads and disposal suppresses late callbacks", async () => {
|
|
12
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-subscription-"));
|
|
13
|
+
const server = new DaemonServer({ home, idleExitMs: 0 });
|
|
14
|
+
|
|
15
|
+
server.start();
|
|
16
|
+
const client = await DaemonClient.connect({ home });
|
|
17
|
+
const firstRead = createTestSignal<void>();
|
|
18
|
+
const releaseFirst = createTestSignal<void>();
|
|
19
|
+
const latest = createTestSignal<void>();
|
|
20
|
+
const values: string[] = [];
|
|
21
|
+
let reads = 0;
|
|
22
|
+
let dispose = () => {};
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const thread = await client.sessionCreate(
|
|
26
|
+
{ repoRoot: home, branch: "main" },
|
|
27
|
+
{ type: "plan", content: "Initial", meta: {} },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
dispose = subscribeThreadState({
|
|
31
|
+
connect: connectThreadObserver({ home }),
|
|
32
|
+
matches: (event) => event.sessionId === thread.id,
|
|
33
|
+
read: async (api) => {
|
|
34
|
+
const state = await api.sessionGet(thread.id);
|
|
35
|
+
|
|
36
|
+
if (++reads === 1) {
|
|
37
|
+
firstRead.resolve();
|
|
38
|
+
await releaseFirst.promise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return state;
|
|
42
|
+
},
|
|
43
|
+
onValue: (state) => {
|
|
44
|
+
values.push(state.artifact.meta.title ?? "Initial");
|
|
45
|
+
latest.resolve();
|
|
46
|
+
},
|
|
47
|
+
onError: (error) => latest.reject(error),
|
|
48
|
+
});
|
|
49
|
+
await firstRead.promise;
|
|
50
|
+
await client.sessionSetTitle(thread.id, "New");
|
|
51
|
+
await latest.promise;
|
|
52
|
+
releaseFirst.resolve();
|
|
53
|
+
await Promise.resolve();
|
|
54
|
+
expect(values).toEqual(["New"]);
|
|
55
|
+
dispose();
|
|
56
|
+
await client.sessionSetTitle(thread.id, "After disposal");
|
|
57
|
+
expect(values).toEqual(["New"]);
|
|
58
|
+
} finally {
|
|
59
|
+
dispose();
|
|
60
|
+
client.close();
|
|
61
|
+
server.stop();
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("reconnect subscribes again and refreshes authoritative state even without a notification", async () => {
|
|
67
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-reconnect-"));
|
|
68
|
+
let server = new DaemonServer({ home, idleExitMs: 0 });
|
|
69
|
+
|
|
70
|
+
server.start();
|
|
71
|
+
const client = await DaemonClient.connect({ home });
|
|
72
|
+
const initial = createTestSignal<void>();
|
|
73
|
+
const recovered = createTestSignal<void>();
|
|
74
|
+
let connections = 0;
|
|
75
|
+
const values: string[] = [];
|
|
76
|
+
let dispose = () => {};
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const thread = await client.sessionCreate(
|
|
80
|
+
{ repoRoot: home, branch: "main" },
|
|
81
|
+
{ type: "plan", content: "Initial", meta: {} },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
dispose = subscribeThreadState({
|
|
85
|
+
connect: connectThreadObserver({ home }),
|
|
86
|
+
reconnectMs: 1,
|
|
87
|
+
matches: (event) => event.sessionId === thread.id,
|
|
88
|
+
read: (api) => api.sessionGet(thread.id),
|
|
89
|
+
onConnect: () => {
|
|
90
|
+
connections++;
|
|
91
|
+
},
|
|
92
|
+
onValue: (state) => {
|
|
93
|
+
values.push(state.artifact.meta.title ?? "Initial");
|
|
94
|
+
if (connections === 1) initial.resolve();
|
|
95
|
+
else recovered.resolve();
|
|
96
|
+
},
|
|
97
|
+
onError() {},
|
|
98
|
+
});
|
|
99
|
+
await initial.promise;
|
|
100
|
+
server.stop();
|
|
101
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
102
|
+
server.core.sessionSetTitle(thread.id, "Recovered");
|
|
103
|
+
server.start();
|
|
104
|
+
await recovered.promise;
|
|
105
|
+
expect(connections).toBe(2);
|
|
106
|
+
expect(values).toEqual(["Initial", "Recovered"]);
|
|
107
|
+
} finally {
|
|
108
|
+
dispose();
|
|
109
|
+
client.close();
|
|
110
|
+
server.stop();
|
|
111
|
+
rmSync(home, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a malformed protocol frame stops the observer instead of reconnecting", async () => {
|
|
116
|
+
const { LineBuffer, parseRequestFrame } = await import("./protocol");
|
|
117
|
+
const { socketPath } = await import("./paths");
|
|
118
|
+
const { DAEMON_VERSION } = await import("./version");
|
|
119
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-subscription-protocol-"));
|
|
120
|
+
const failed = createTestSignal<void>();
|
|
121
|
+
const reads = new Map<object, InstanceType<typeof LineBuffer>>();
|
|
122
|
+
let connections = 0;
|
|
123
|
+
const server = Bun.listen({
|
|
124
|
+
unix: socketPath(home),
|
|
125
|
+
socket: {
|
|
126
|
+
open(socket) {
|
|
127
|
+
reads.set(socket, new LineBuffer());
|
|
128
|
+
connections++;
|
|
129
|
+
if (connections > 1) failed.reject(new Error("Unexpected reconnect"));
|
|
130
|
+
},
|
|
131
|
+
data(socket, bytes) {
|
|
132
|
+
reads.get(socket)!.push(bytes.toString(), (line) => {
|
|
133
|
+
const request = parseRequestFrame(line);
|
|
134
|
+
|
|
135
|
+
socket.write(
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
id: request.id,
|
|
138
|
+
result:
|
|
139
|
+
request.method === "daemon.ping"
|
|
140
|
+
? { pid: process.pid, version: DAEMON_VERSION }
|
|
141
|
+
: {},
|
|
142
|
+
}) + "\n",
|
|
143
|
+
);
|
|
144
|
+
if (request.method === "events.subscribe") socket.write("{broken\n");
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
close(socket) {
|
|
148
|
+
reads.delete(socket);
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const dispose = subscribeThreadState({
|
|
153
|
+
connect: connectThreadObserver({ home }),
|
|
154
|
+
reconnectMs: 1,
|
|
155
|
+
matches: () => true,
|
|
156
|
+
read: (client) => client.ping(),
|
|
157
|
+
onValue() {},
|
|
158
|
+
onError: (error) => {
|
|
159
|
+
expect(error).toMatchObject({ kind: "protocol" });
|
|
160
|
+
failed.resolve();
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await failed.promise;
|
|
166
|
+
// A timer boundary proves no reconnect remains scheduled after the protocol closure.
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
168
|
+
expect(connections).toBe(1);
|
|
169
|
+
} finally {
|
|
170
|
+
dispose();
|
|
171
|
+
server.stop(true);
|
|
172
|
+
rmSync(home, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
|
|
2
|
+
import { DaemonTransportError } from "./client-errors";
|
|
3
|
+
import type { EventFrame } from "./protocol";
|
|
4
|
+
|
|
5
|
+
/** Notifications are refresh hints; each connection starts with an authoritative read. */
|
|
6
|
+
export interface ThreadSubscriptionOptions<Value> {
|
|
7
|
+
connect: () => Promise<DaemonClient>;
|
|
8
|
+
matches: (event: EventFrame) => boolean;
|
|
9
|
+
read: (client: DaemonClient, signal: AbortSignal) => Promise<Value>;
|
|
10
|
+
onValue: (value: Value, client: DaemonClient) => void;
|
|
11
|
+
// eslint-disable-next-line type-evidence/no-unknown-parameters -- Socket and read failures arrive from an untyped Promise rejection boundary.
|
|
12
|
+
onError: (error: unknown) => void;
|
|
13
|
+
onConnect?: (client: DaemonClient) => void;
|
|
14
|
+
reconnectMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Dispose removes listeners, aborts local reads, and closes only the observation connection. */
|
|
18
|
+
export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<Value>): () => void {
|
|
19
|
+
let stopped = false;
|
|
20
|
+
let generation = 0;
|
|
21
|
+
let connection: DaemonClient | undefined;
|
|
22
|
+
let detach: (() => void) | undefined;
|
|
23
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
24
|
+
const reads = new Set<AbortController>();
|
|
25
|
+
const schedule = () => {
|
|
26
|
+
if (!stopped) timer = setTimeout(() => void connect(), options.reconnectMs ?? 250);
|
|
27
|
+
};
|
|
28
|
+
const connect = async () => {
|
|
29
|
+
const epoch = ++generation;
|
|
30
|
+
let request = 0;
|
|
31
|
+
let api: DaemonClient | undefined;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
api = await options.connect();
|
|
35
|
+
if (stopped || epoch !== generation) {
|
|
36
|
+
api.close();
|
|
37
|
+
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
connection = api;
|
|
41
|
+
const current = api;
|
|
42
|
+
const refresh = async () => {
|
|
43
|
+
const version = ++request;
|
|
44
|
+
|
|
45
|
+
for (const read of reads) read.abort();
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
|
|
48
|
+
reads.add(controller);
|
|
49
|
+
try {
|
|
50
|
+
const value = await options.read(current, controller.signal);
|
|
51
|
+
|
|
52
|
+
if (!stopped && epoch === generation && version === request)
|
|
53
|
+
options.onValue(value, current);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (!stopped && epoch === generation && version === request) {
|
|
56
|
+
if (error instanceof Error && isFatalSubscriptionError(error)) {
|
|
57
|
+
stopped = true;
|
|
58
|
+
++generation;
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
detach?.();
|
|
61
|
+
current.close();
|
|
62
|
+
}
|
|
63
|
+
options.onError(error);
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
reads.delete(controller);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const offEvent = api.onEvent((event) => {
|
|
70
|
+
if (options.matches(event)) void refresh();
|
|
71
|
+
});
|
|
72
|
+
const offDisconnect = api.onDisconnect((reason) => {
|
|
73
|
+
if (stopped || epoch !== generation) return;
|
|
74
|
+
++generation;
|
|
75
|
+
detach?.();
|
|
76
|
+
for (const read of reads) read.abort();
|
|
77
|
+
connection = undefined;
|
|
78
|
+
current.close();
|
|
79
|
+
if (isFatalSubscriptionError(reason)) {
|
|
80
|
+
stopped = true;
|
|
81
|
+
options.onError(reason);
|
|
82
|
+
} else schedule();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
detach = () => {
|
|
86
|
+
offEvent();
|
|
87
|
+
offDisconnect();
|
|
88
|
+
};
|
|
89
|
+
await api.subscribe();
|
|
90
|
+
if (stopped || epoch !== generation) return;
|
|
91
|
+
options.onConnect?.(api);
|
|
92
|
+
await refresh();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (stopped || epoch !== generation) return;
|
|
95
|
+
++generation;
|
|
96
|
+
detach?.();
|
|
97
|
+
api?.close();
|
|
98
|
+
connection = undefined;
|
|
99
|
+
options.onError(error);
|
|
100
|
+
if (!(error instanceof Error && isFatalSubscriptionError(error))) schedule();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
void connect();
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
stopped = true;
|
|
108
|
+
++generation;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
detach?.();
|
|
111
|
+
for (const read of reads) read.abort();
|
|
112
|
+
reads.clear();
|
|
113
|
+
connection?.close();
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Reconnect never replays writes, only subscribes and reads the latest Thread state. */
|
|
118
|
+
export function connectThreadObserver(options: ConnectOptions): () => Promise<DaemonClient> {
|
|
119
|
+
return () => DaemonClient.connect(options);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isFatalSubscriptionError(error: Error): boolean {
|
|
123
|
+
return (
|
|
124
|
+
error instanceof DaemonClientError ||
|
|
125
|
+
(error instanceof DaemonTransportError && error.kind === "protocol")
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/validate.test.ts
CHANGED
|
@@ -330,6 +330,7 @@ describe("wire pins", () => {
|
|
|
330
330
|
workingCopy: "# ",
|
|
331
331
|
textCuts: [{ start: 2, end: 3, quote: "P" }],
|
|
332
332
|
viewedPaths: ["src/a.ts"],
|
|
333
|
+
messageOperations: [{ operationId: "operation", fingerprint: "hash", result: fullMessage }],
|
|
333
334
|
message: fullMessage,
|
|
334
335
|
status: "pending",
|
|
335
336
|
createdAt: "now",
|
package/src/validate.ts
CHANGED
|
@@ -223,6 +223,12 @@ export const Params = {
|
|
|
223
223
|
text: v.pipe(v.string(), v.maxLength(65_536)),
|
|
224
224
|
context: v.optional(v.pipe(v.string(), v.maxLength(32_768))),
|
|
225
225
|
retry: v.optional(NonEmpty),
|
|
226
|
+
operationId: v.optional(v.pipe(NonEmpty, v.maxLength(128))),
|
|
227
|
+
}),
|
|
228
|
+
"agent.reply": v.object({
|
|
229
|
+
id: SessionId,
|
|
230
|
+
commentId: NonEmpty,
|
|
231
|
+
body: v.pipe(NonEmpty, v.maxLength(32_768)),
|
|
226
232
|
}),
|
|
227
233
|
"agent.cancel": v.object({ id: SessionId }),
|
|
228
234
|
"agent.comment": v.object({ id: SessionId, comment: AgentCommentSchema }),
|
|
@@ -322,6 +328,7 @@ export const Params = {
|
|
|
322
328
|
outcome: v.picklist(MESSAGE_OUTCOMES),
|
|
323
329
|
summary: v.optional(v.string(), ""),
|
|
324
330
|
actionBodies: v.optional(v.record(v.string(), v.string())),
|
|
331
|
+
operationId: v.optional(v.pipe(NonEmpty, v.maxLength(128))),
|
|
325
332
|
}),
|
|
326
333
|
"harness.bind": v.object({
|
|
327
334
|
threadId: SessionId,
|
|
@@ -497,6 +504,9 @@ export const ThreadRecordSchema = v.pipe(
|
|
|
497
504
|
workingCopy: v.optional(v.string()),
|
|
498
505
|
textCuts: v.optional(TextCutsSchema),
|
|
499
506
|
viewedPaths: v.optional(v.array(v.string())),
|
|
507
|
+
messageOperations: v.optional(
|
|
508
|
+
v.array(v.object({ operationId: NonEmpty, fingerprint: NonEmpty, result: MessageSchema })),
|
|
509
|
+
),
|
|
500
510
|
message: v.nullable(MessageSchema),
|
|
501
511
|
status: v.picklist(["pending", "resolved"]),
|
|
502
512
|
createdAt: v.string(),
|
package/src/vcs-source.ts
CHANGED
|
@@ -114,6 +114,7 @@ export class VcsSourceManager {
|
|
|
114
114
|
|
|
115
115
|
for (const path of paths) {
|
|
116
116
|
const absolute = isAbsolute(path) ? path : resolve(dirname(this.userConfigPath), path);
|
|
117
|
+
|
|
117
118
|
await this.loadAdapterExtension(absolute);
|
|
118
119
|
}
|
|
119
120
|
const discovery = discoverInstalledExtensionPackages(this.installRoot);
|
package/src/working-tree.ts
CHANGED
|
@@ -15,6 +15,7 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
15
15
|
/** Map a `git diff --name-status` letter to a diff status; renames/copies read as modified. */
|
|
16
16
|
function statusFromCode(code: string): DiffFileStatus {
|
|
17
17
|
const letter = code.charAt(0);
|
|
18
|
+
|
|
18
19
|
if (letter === "D") return "deleted";
|
|
19
20
|
if (letter === "A") return "added";
|
|
20
21
|
|
|
@@ -35,6 +36,7 @@ export async function workingChangeList(
|
|
|
35
36
|
for (const line of tracked.split("\n")) {
|
|
36
37
|
if (line.trim().length === 0) continue;
|
|
37
38
|
const parts = line.split("\t");
|
|
39
|
+
|
|
38
40
|
// a rename is "R100<tab>old<tab>new"; the new path (last field) is what the reviewer opens
|
|
39
41
|
changes.push({ path: parts[parts.length - 1]!, status: statusFromCode(parts[0]!) });
|
|
40
42
|
}
|