@cueloop/daemon 0.1.0-alpha.79 → 0.1.0-alpha.80

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/src/retention.ts CHANGED
@@ -71,12 +71,13 @@ export function pruneExpiredSessions(
71
71
  store: ThreadStore,
72
72
  periodDays: number,
73
73
  nowMs: number,
74
+ protectedThreadIds: ReadonlySet<string> = new Set(),
74
75
  ): string[] {
75
76
  if (periodDays <= 0) return [];
76
77
  const pruned: string[] = [];
77
78
 
78
79
  for (const session of store.list()) {
79
- if (session.status !== "resolved") continue;
80
+ if (session.status !== "resolved" || protectedThreadIds.has(session.id)) continue;
80
81
  if (isExpired(session.createdAt, periodDays, nowMs) && store.delete(session.id)) {
81
82
  pruned.push(session.id);
82
83
  }
@@ -46,12 +46,58 @@ describe("socket round-trip", () => {
46
46
  });
47
47
  const wait = client.sessionWait(session.id, 5_000);
48
48
 
49
- await client.sessionResolve(session.id, "request_changes", "Expand it.");
49
+ await client.sessionSendMessage(session.id, "changes_requested", "Expand it.");
50
50
  const resolved = (await wait)!;
51
51
 
52
52
  // Assert
53
- expect(resolved.verdict!.kind).toBe("request_changes");
54
- expect(resolved.verdict!.feedback).toContain("More detail please.");
53
+ expect(resolved.message!.outcome).toBe("changes_requested");
54
+ expect(resolved.message!.body).toContain("More detail please.");
55
+ });
56
+
57
+ test("harness binding and Message delivery round-trip over the wire", async () => {
58
+ const thread = await client.sessionCreate(WS, PLAN);
59
+ const binding = await client.harnessBind(thread.id, "fake", "fake_1");
60
+
61
+ expect(await client.harnessGetBinding(binding.id)).toEqual(binding);
62
+
63
+ const sent = await client.sessionSendMessage(thread.id, "approved", "Ready.");
64
+ const pending = await client.deliveryPending(binding.id);
65
+
66
+ expect(pending).toHaveLength(1);
67
+ expect(pending[0]!.message).toEqual(sent.message!);
68
+ expect(pending[0]!.delivery.status).toBe("pending");
69
+
70
+ const acknowledged = await client.deliveryAcknowledge(pending[0]!.delivery.id);
71
+
72
+ expect(acknowledged.status).toBe("acknowledged");
73
+ expect(await client.deliveryPending(binding.id)).toEqual([]);
74
+ expect(
75
+ await client.harnessConsumeApprovedRetry(binding.id, sent.message!.id, PLAN.content),
76
+ ).toBe(true);
77
+ expect(
78
+ await client.harnessConsumeApprovedRetry(binding.id, sent.message!.id, PLAN.content),
79
+ ).toBe(false);
80
+ });
81
+
82
+ test("diff revision replaces full changed-file contents over the wire", async () => {
83
+ const files = [
84
+ { path: "a.ts", status: "modified" as const, oldContents: "old", newContents: "first" },
85
+ ];
86
+ const thread = await client.sessionCreate(WS, {
87
+ type: "diff",
88
+ content: "first patch",
89
+ files,
90
+ meta: {},
91
+ });
92
+ const revised = await client.sessionSubmitRevision(
93
+ thread.id,
94
+ "second patch",
95
+ [],
96
+ [{ ...files[0]!, newContents: "second" }],
97
+ );
98
+
99
+ expect(revised.artifact.files?.[0]?.newContents).toBe("second");
100
+ expect((await client.sessionGet(thread.id)).artifact.files?.[0]?.newContents).toBe("second");
55
101
  });
56
102
 
57
103
  test("errors carry codes across the wire", async () => {
@@ -70,13 +116,13 @@ describe("socket round-trip", () => {
70
116
  // Act
71
117
  const session = await client.sessionCreate(WS, PLAN);
72
118
 
73
- await client.sessionResolve(session.id, "approve", "");
119
+ await client.sessionSendMessage(session.id, "approved", "");
74
120
 
75
121
  // Assert
76
122
  // events are pushed async over the socket; give the loop a beat
77
123
  await Bun.sleep(50);
78
124
  expect(seen).toContain("session.created");
79
- expect(seen).toContain("session.resolved");
125
+ expect(seen).toContain("message.sent");
80
126
  observer.close();
81
127
  });
82
128
 
@@ -344,7 +390,7 @@ describe("ownership is proven, never declared", () => {
344
390
  ).error,
345
391
  ).toBeUndefined();
346
392
  expect(
347
- (await raw.call("session.resolve", { id: session.id, verdictKind: "approve", summary: "" }))
393
+ (await raw.call("session.sendMessage", { id: session.id, outcome: "approved", summary: "" }))
348
394
  .error?.code,
349
395
  ).toBe("forbidden");
350
396
  expect((await raw.call("session.delete", { id: session.id })).error?.code).toBe("forbidden");
@@ -422,7 +468,7 @@ describe("ownership is proven, never declared", () => {
422
468
  // Assert: the client in beforeEach connected as the owner through the token
423
469
  const session = await client.sessionCreate(WS, PLAN);
424
470
 
425
- expect((await client.sessionResolve(session.id, "approve", "")).status).toBe("resolved");
471
+ expect((await client.sessionSendMessage(session.id, "approved", "")).status).toBe("resolved");
426
472
  expect(statSync(join(home, "owner.token")).mode & 0o777).toBe(0o600);
427
473
  });
428
474
  });
package/src/server.ts CHANGED
@@ -449,16 +449,45 @@ export class DaemonServer {
449
449
  removals: params.removals,
450
450
  });
451
451
  },
452
- "session.resolve": (_connection, request) => {
453
- const params = parseParams("session.resolve", request.params);
452
+ "session.sendMessage": (_connection, request) => {
453
+ const params = parseParams("session.sendMessage", request.params);
454
454
 
455
- return this.core.sessionResolve(
455
+ return this.core.sessionSendMessage(
456
456
  params.id,
457
- params.verdictKind,
457
+ params.outcome,
458
458
  params.summary,
459
459
  params.actionBodies,
460
460
  );
461
461
  },
462
+ "harness.bind": (_connection, request) => {
463
+ const params = parseParams("harness.bind", request.params);
464
+
465
+ return this.core.harnessBind(params);
466
+ },
467
+ "harness.getBinding": (_connection, request) => {
468
+ const params = parseParams("harness.getBinding", request.params);
469
+
470
+ return this.core.harnessGetBinding(params.bindingId);
471
+ },
472
+ "harness.consumeApprovedRetry": (_connection, request) => {
473
+ const params = parseParams("harness.consumeApprovedRetry", request.params);
474
+
475
+ return this.core.harnessConsumeApprovedRetry(
476
+ params.bindingId,
477
+ params.messageId,
478
+ params.content,
479
+ );
480
+ },
481
+ "delivery.pending": (_connection, request) => {
482
+ const params = parseParams("delivery.pending", request.params);
483
+
484
+ return this.core.deliveryPending(params.bindingId);
485
+ },
486
+ "delivery.acknowledge": (_connection, request) => {
487
+ const params = parseParams("delivery.acknowledge", request.params);
488
+
489
+ return this.core.deliveryAcknowledge(params.deliveryId);
490
+ },
462
491
  "session.submitRevision": (_connection, request) => {
463
492
  const params = parseParams("session.submitRevision", request.params);
464
493
 
@@ -466,6 +495,7 @@ export class DaemonServer {
466
495
  params.id,
467
496
  params.content,
468
497
  params.addressedAnnotationIds,
498
+ params.files,
469
499
  );
470
500
  },
471
501
  "herdr.getTab": (_connection, request) =>
@@ -81,6 +81,18 @@ describe("one daemon per home", () => {
81
81
  expect(daemonServer.start()).toBe(socketPath(home));
82
82
  });
83
83
 
84
+ test("an autostarted daemon reclaims a stale socket after taking the lock", async () => {
85
+ writeFileSync(socketPath(home), "stale socket");
86
+ const client = await DaemonClient.connect({ home, autostart: true });
87
+
88
+ try {
89
+ expect((await client.ping()).pid).toBeGreaterThan(0);
90
+ } finally {
91
+ await client.shutdown();
92
+ client.close();
93
+ }
94
+ });
95
+
84
96
  test("stopping releases the lock so a restart works", () => {
85
97
  // Arrange
86
98
  const first = server();
@@ -125,4 +137,26 @@ describe("one daemon per home", () => {
125
137
  admin.close();
126
138
  }
127
139
  }, 60_000);
140
+
141
+ test("autostart joins a daemon that bound its socket after the first dial", async () => {
142
+ const running = server();
143
+ const path = running.start();
144
+ const created = running.core.sessionCreate({ workspace: WS, artifact: PLAN });
145
+ const client = new DaemonClient();
146
+ const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
147
+
148
+ client["home"] = home;
149
+ process.env.CUELOOP_START_TIMEOUT_MS = "250";
150
+
151
+ try {
152
+ await client["attachFreshDaemon"](home, path!);
153
+
154
+ expect((await client.sessionGet(created.id)).id).toBe(created.id);
155
+ expect(existsSync(path!)).toBe(true);
156
+ } finally {
157
+ client.close();
158
+ if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
159
+ else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
160
+ }
161
+ });
128
162
  });
@@ -30,7 +30,7 @@ export function createTestThreadRecord(
30
30
  artifact: { type: "plan", content: `# ${id}`, meta: {} },
31
31
  revisions: [{ revision: 1, content: `# ${id}`, submittedAt: createdAt }],
32
32
  annotations: [],
33
- verdict: null,
33
+ message: null,
34
34
  status: "pending",
35
35
  createdAt,
36
36
  ...overrides,
@@ -1,4 +1,4 @@
1
- /** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitVerdict shapes (one long-poll and the chunked loop with progress and abort). */
1
+ /** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitMessage shapes (one long-poll and the chunked loop with progress and abort). */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -7,7 +7,7 @@ import { join } from "node:path";
7
7
  import type { Thread } from "@cueloop/schema";
8
8
  import { DaemonServer } from "./server";
9
9
  import { DaemonClient } from "./client";
10
- import { awaitResolve, openReview, resolveWorkspace } from "./review";
10
+ import { awaitResolve, openReview, resolveWorkspace } from "./thread-review";
11
11
 
12
12
  const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
13
13
 
@@ -205,49 +205,49 @@ describe("openReview", () => {
205
205
  });
206
206
  });
207
207
 
208
- describe("awaitVerdict: one long-poll (the hook shape)", () => {
209
- test("times out to pending; a later wait collects the stored verdict", async () => {
208
+ describe("awaitMessage: one long-poll (the hook shape)", () => {
209
+ test("times out to pending; a later wait collects the stored message", async () => {
210
210
  // Arrange
211
211
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
212
212
 
213
213
  // Assert
214
- expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
214
+ expect(await review.awaitMessage({ timeoutMs: 50 })).toBe("pending");
215
215
 
216
216
  // Act
217
- await client.sessionResolve(review.id, "approve", "Fine.");
218
- const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
217
+ await client.sessionSendMessage(review.id, "approved", "Fine.");
218
+ const message = await review.awaitMessage({ timeoutMs: 1_000 });
219
219
 
220
220
  // Assert
221
- expect(verdict).not.toBe("pending");
222
- if (verdict === "pending") throw new Error("unreachable");
223
- expect(verdict.allow).toBe(true);
224
- expect(verdict.feedback).toContain("Fine.");
225
- expect(verdict.session.verdict!.kind).toBe("approve");
221
+ expect(message).not.toBe("pending");
222
+ if (message === "pending") throw new Error("unreachable");
223
+ expect(message.allow).toBe(true);
224
+ expect(message.message.body).toContain("Fine.");
225
+ expect(message.session.message!.outcome).toBe("approved");
226
226
  });
227
227
 
228
- test("request_changes maps to allow=false with feedback.md attached", async () => {
228
+ test("changes_requested maps to allow=false with feedback.md attached", async () => {
229
229
  // Arrange
230
230
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
231
- const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
231
+ const waiting = review.awaitMessage({ timeoutMs: 5_000 });
232
232
 
233
233
  // Act
234
- await client.sessionResolve(review.id, "request_changes", "One stage only.");
235
- const verdict = await waiting;
234
+ await client.sessionSendMessage(review.id, "changes_requested", "One stage only.");
235
+ const message = await waiting;
236
236
 
237
237
  // Assert
238
- if (verdict === "pending") throw new Error("expected a verdict");
239
- expect(verdict.allow).toBe(false);
240
- expect(verdict.feedback).toContain("# Review: request changes");
241
- expect(verdict.feedback).toContain("One stage only.");
238
+ if (message === "pending") throw new Error("expected a message");
239
+ expect(message.allow).toBe(false);
240
+ expect(message.message.body).toContain("# Review: changes requested");
241
+ expect(message.message.body).toContain("One stage only.");
242
242
  });
243
243
  });
244
244
 
245
- describe("awaitVerdict: chunked loop (the pi shape)", () => {
246
- test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
245
+ describe("awaitMessage: chunked loop (the pi shape)", () => {
246
+ test("onProgress sees fresh sessions between chunks; the message ends the loop", async () => {
247
247
  // Arrange
248
248
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
249
249
  const seen: Thread[] = [];
250
- const waiting = review.awaitVerdict({
250
+ const waiting = review.awaitMessage({
251
251
  timeoutMs: Infinity,
252
252
  pollMs: 100,
253
253
  onProgress: (progress) => seen.push(progress),
@@ -268,20 +268,20 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
268
268
  expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
269
269
 
270
270
  // Act
271
- await client.sessionResolve(review.id, "request_changes", "Too vague.");
272
- const verdict = await waiting;
271
+ await client.sessionSendMessage(review.id, "changes_requested", "Too vague.");
272
+ const message = await waiting;
273
273
 
274
274
  // Assert
275
- if (verdict === "pending") throw new Error("expected a verdict");
276
- expect(verdict.allow).toBe(false);
277
- expect(verdict.session.annotations.length).toBe(1);
275
+ if (message === "pending") throw new Error("expected a message");
276
+ expect(message.allow).toBe(false);
277
+ expect(message.session.annotations.length).toBe(1);
278
278
  }, 15_000);
279
279
 
280
280
  test("abort surfaces as pending and leaves the session collectable", async () => {
281
281
  // Arrange
282
282
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
283
283
  const controller = new AbortController();
284
- const waiting = review.awaitVerdict({
284
+ const waiting = review.awaitMessage({
285
285
  timeoutMs: Infinity,
286
286
  pollMs: 100,
287
287
  signal: controller.signal,
@@ -300,40 +300,40 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
300
300
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
301
301
 
302
302
  // Assert
303
- expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
303
+ expect(await review.awaitMessage({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
304
304
  });
305
305
  });
306
306
 
307
307
  describe("awaitResolve: the adapter wake seam (session id only)", () => {
308
- test("resolves to the outcome when the verdict lands during the wait", async () => {
308
+ test("resolves to the outcome when the message lands during the wait", async () => {
309
309
  // Arrange
310
310
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
311
311
  const waiting = awaitResolve(client, review.id, { pollMs: 100 });
312
312
 
313
313
  // Act
314
- await client.sessionResolve(review.id, "approve", "Ship it.");
315
- const verdict = await waiting;
314
+ await client.sessionSendMessage(review.id, "approved", "Ship it.");
315
+ const message = await waiting;
316
316
 
317
317
  // Assert
318
- expect(verdict).not.toBeNull();
319
- expect(verdict!.allow).toBe(true);
320
- expect(verdict!.feedback).toContain("Ship it.");
321
- expect(verdict!.session.verdict!.kind).toBe("approve");
318
+ expect(message).not.toBeNull();
319
+ expect(message!.allow).toBe(true);
320
+ expect(message!.message.body).toContain("Ship it.");
321
+ expect(message!.session.message!.outcome).toBe("approved");
322
322
  });
323
323
 
324
- test("returns the stored verdict immediately when the session already resolved", async () => {
324
+ test("returns the stored message immediately when the session already resolved", async () => {
325
325
  // Arrange - a detached waiter that only attaches after the human decided
326
326
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
327
327
 
328
- await client.sessionResolve(review.id, "request_changes", "One stage only.");
328
+ await client.sessionSendMessage(review.id, "changes_requested", "One stage only.");
329
329
 
330
330
  // Act
331
- const verdict = await awaitResolve(client, review.id);
331
+ const message = await awaitResolve(client, review.id);
332
332
 
333
333
  // Assert
334
- expect(verdict).not.toBeNull();
335
- expect(verdict!.allow).toBe(false);
336
- expect(verdict!.feedback).toContain("One stage only.");
334
+ expect(message).not.toBeNull();
335
+ expect(message!.allow).toBe(false);
336
+ expect(message!.message.body).toContain("One stage only.");
337
337
  });
338
338
 
339
339
  test("returns null when the signal aborts first", async () => {
@@ -352,7 +352,7 @@ describe("awaitResolve: the adapter wake seam (session id only)", () => {
352
352
  });
353
353
 
354
354
  describe("awaitResolve: the held wait keeps the daemon alive", () => {
355
- test("a pending session does not idle-exit while a waiter is parked, then the verdict lands", async () => {
355
+ test("a pending session does not idle-exit while a waiter is parked, then the message lands", async () => {
356
356
  // Arrange - a daemon with an aggressive idle timer and its own home
357
357
  const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
358
358
  let idleExits = 0;
@@ -376,15 +376,15 @@ describe("awaitResolve: the held wait keeps the daemon alive", () => {
376
376
  expect(idleExits).toBe(0);
377
377
  expect((await waiterClient.ping()).pid).toBe(process.pid);
378
378
 
379
- // Act - the human returns a verdict; the parked wait collects it
379
+ // Act - the human returns a message; the parked wait collects it
380
380
  const resolver = await DaemonClient.connect({ home: idleHome });
381
381
 
382
- await resolver.sessionResolve(review.id, "approve", "Good to go.");
382
+ await resolver.sessionSendMessage(review.id, "approved", "Good to go.");
383
383
  resolver.close();
384
- const verdict = await waiting;
384
+ const message = await waiting;
385
385
 
386
386
  // Assert
387
- expect(verdict!.allow).toBe(true);
387
+ expect(message!.allow).toBe(true);
388
388
  } finally {
389
389
  waiterClient.close();
390
390
  idleServer.stop();