@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/package.json +4 -3
- package/src/api.test.ts +57 -38
- package/src/api.ts +138 -33
- package/src/capabilities.test.ts +3 -3
- package/src/capabilities.ts +6 -1
- package/src/client.ts +73 -18
- package/src/diff-hot-reload.test.ts +2 -2
- package/src/harness-delivery.test.ts +193 -0
- package/src/harness-state-store.test.ts +65 -0
- package/src/harness-state-store.ts +182 -0
- package/src/herdr-pane.test.ts +1 -1
- package/src/index.ts +1 -1
- package/src/paths.ts +5 -0
- package/src/retention.test.ts +10 -1
- package/src/retention.ts +2 -1
- package/src/server.test.ts +53 -7
- package/src/server.ts +34 -4
- package/src/single-instance.test.ts +34 -0
- package/src/testing/store-conformance.ts +1 -1
- package/src/{review.test.ts → thread-review.test.ts} +48 -48
- package/src/{review.ts → thread-review.ts} +91 -43
- package/src/validate.test.ts +16 -14
- package/src/validate.ts +53 -10
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
|
}
|
package/src/server.test.ts
CHANGED
|
@@ -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.
|
|
49
|
+
await client.sessionSendMessage(session.id, "changes_requested", "Expand it.");
|
|
50
50
|
const resolved = (await wait)!;
|
|
51
51
|
|
|
52
52
|
// Assert
|
|
53
|
-
expect(resolved.
|
|
54
|
-
expect(resolved.
|
|
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.
|
|
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("
|
|
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.
|
|
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.
|
|
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.
|
|
453
|
-
const params = parseParams("session.
|
|
452
|
+
"session.sendMessage": (_connection, request) => {
|
|
453
|
+
const params = parseParams("session.sendMessage", request.params);
|
|
454
454
|
|
|
455
|
-
return this.core.
|
|
455
|
+
return this.core.sessionSendMessage(
|
|
456
456
|
params.id,
|
|
457
|
-
params.
|
|
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
|
-
|
|
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
|
|
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("
|
|
209
|
-
test("times out to pending; a later wait collects the stored
|
|
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.
|
|
214
|
+
expect(await review.awaitMessage({ timeoutMs: 50 })).toBe("pending");
|
|
215
215
|
|
|
216
216
|
// Act
|
|
217
|
-
await client.
|
|
218
|
-
const
|
|
217
|
+
await client.sessionSendMessage(review.id, "approved", "Fine.");
|
|
218
|
+
const message = await review.awaitMessage({ timeoutMs: 1_000 });
|
|
219
219
|
|
|
220
220
|
// Assert
|
|
221
|
-
expect(
|
|
222
|
-
if (
|
|
223
|
-
expect(
|
|
224
|
-
expect(
|
|
225
|
-
expect(
|
|
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("
|
|
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.
|
|
231
|
+
const waiting = review.awaitMessage({ timeoutMs: 5_000 });
|
|
232
232
|
|
|
233
233
|
// Act
|
|
234
|
-
await client.
|
|
235
|
-
const
|
|
234
|
+
await client.sessionSendMessage(review.id, "changes_requested", "One stage only.");
|
|
235
|
+
const message = await waiting;
|
|
236
236
|
|
|
237
237
|
// Assert
|
|
238
|
-
if (
|
|
239
|
-
expect(
|
|
240
|
-
expect(
|
|
241
|
-
expect(
|
|
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("
|
|
246
|
-
test("onProgress sees fresh sessions between chunks; the
|
|
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.
|
|
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.
|
|
272
|
-
const
|
|
271
|
+
await client.sessionSendMessage(review.id, "changes_requested", "Too vague.");
|
|
272
|
+
const message = await waiting;
|
|
273
273
|
|
|
274
274
|
// Assert
|
|
275
|
-
if (
|
|
276
|
-
expect(
|
|
277
|
-
expect(
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
315
|
-
const
|
|
314
|
+
await client.sessionSendMessage(review.id, "approved", "Ship it.");
|
|
315
|
+
const message = await waiting;
|
|
316
316
|
|
|
317
317
|
// Assert
|
|
318
|
-
expect(
|
|
319
|
-
expect(
|
|
320
|
-
expect(
|
|
321
|
-
expect(
|
|
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
|
|
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.
|
|
328
|
+
await client.sessionSendMessage(review.id, "changes_requested", "One stage only.");
|
|
329
329
|
|
|
330
330
|
// Act
|
|
331
|
-
const
|
|
331
|
+
const message = await awaitResolve(client, review.id);
|
|
332
332
|
|
|
333
333
|
// Assert
|
|
334
|
-
expect(
|
|
335
|
-
expect(
|
|
336
|
-
expect(
|
|
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
|
|
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
|
|
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.
|
|
382
|
+
await resolver.sessionSendMessage(review.id, "approved", "Good to go.");
|
|
383
383
|
resolver.close();
|
|
384
|
-
const
|
|
384
|
+
const message = await waiting;
|
|
385
385
|
|
|
386
386
|
// Assert
|
|
387
|
-
expect(
|
|
387
|
+
expect(message!.allow).toBe(true);
|
|
388
388
|
} finally {
|
|
389
389
|
waiterClient.close();
|
|
390
390
|
idleServer.stop();
|