@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.
@@ -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
+ }
@@ -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);
@@ -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
  }