@cueloop/daemon 0.1.0-alpha.34 → 0.1.0-alpha.36
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 +2 -2
- package/src/review.test.ts +86 -1
- package/src/review.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.36",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
31
|
+
"@cueloop/schema": "0.1.0-alpha.36",
|
|
32
32
|
"valibot": "^1.4.2"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
package/src/review.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import type { ReviewSession } from "@cueloop/schema";
|
|
8
8
|
import { DaemonServer } from "./server";
|
|
9
9
|
import { DaemonClient } from "./client";
|
|
10
|
-
import { openReview, resolveWorkspace } from "./review";
|
|
10
|
+
import { awaitResolve, openReview, resolveWorkspace } from "./review";
|
|
11
11
|
|
|
12
12
|
const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
|
|
13
13
|
|
|
@@ -275,3 +275,88 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
|
275
275
|
expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
|
|
276
276
|
});
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
describe("awaitResolve: the adapter wake seam (session id only)", () => {
|
|
280
|
+
test("resolves to the outcome when the verdict lands during the wait", async () => {
|
|
281
|
+
// Arrange
|
|
282
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
283
|
+
const waiting = awaitResolve(client, review.id, { pollMs: 100 });
|
|
284
|
+
|
|
285
|
+
// Act
|
|
286
|
+
await client.sessionResolve(review.id, "approve", "Ship it.");
|
|
287
|
+
const verdict = await waiting;
|
|
288
|
+
|
|
289
|
+
// Assert
|
|
290
|
+
expect(verdict).not.toBeNull();
|
|
291
|
+
expect(verdict!.allow).toBe(true);
|
|
292
|
+
expect(verdict!.feedback).toContain("Ship it.");
|
|
293
|
+
expect(verdict!.session.verdict!.kind).toBe("approve");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("returns the stored verdict immediately when the session already resolved", async () => {
|
|
297
|
+
// Arrange - a detached waiter that only attaches after the human decided
|
|
298
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
299
|
+
await client.sessionResolve(review.id, "request_changes", "One stage only.");
|
|
300
|
+
|
|
301
|
+
// Act
|
|
302
|
+
const verdict = await awaitResolve(client, review.id);
|
|
303
|
+
|
|
304
|
+
// Assert
|
|
305
|
+
expect(verdict).not.toBeNull();
|
|
306
|
+
expect(verdict!.allow).toBe(false);
|
|
307
|
+
expect(verdict!.feedback).toContain("One stage only.");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("returns null when the signal aborts first", async () => {
|
|
311
|
+
// Arrange
|
|
312
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
313
|
+
const controller = new AbortController();
|
|
314
|
+
const waiting = awaitResolve(client, review.id, { pollMs: 100, signal: controller.signal });
|
|
315
|
+
|
|
316
|
+
// Act
|
|
317
|
+
controller.abort();
|
|
318
|
+
|
|
319
|
+
// Assert
|
|
320
|
+
expect(await waiting).toBeNull();
|
|
321
|
+
expect((await client.sessionGet(review.id)).status).toBe("pending");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("awaitResolve: the held wait keeps the daemon alive", () => {
|
|
326
|
+
test("a pending session does not idle-exit while a waiter is parked, then the verdict lands", async () => {
|
|
327
|
+
// Arrange - a daemon with an aggressive idle timer and its own home
|
|
328
|
+
const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
|
|
329
|
+
let idleExits = 0;
|
|
330
|
+
const idleServer = new DaemonServer({
|
|
331
|
+
home: idleHome,
|
|
332
|
+
idleExitMs: 30,
|
|
333
|
+
onIdleExit: () => idleExits++,
|
|
334
|
+
});
|
|
335
|
+
idleServer.start();
|
|
336
|
+
const waiterClient = await DaemonClient.connect({ home: idleHome });
|
|
337
|
+
try {
|
|
338
|
+
const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
|
|
339
|
+
const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
|
|
340
|
+
|
|
341
|
+
// Act - wait well past the idle window while the review is still pending
|
|
342
|
+
await Bun.sleep(120);
|
|
343
|
+
|
|
344
|
+
// Assert - the parked waiter and the pending session both keep it alive
|
|
345
|
+
expect(idleExits).toBe(0);
|
|
346
|
+
expect((await waiterClient.ping()).pid).toBe(process.pid);
|
|
347
|
+
|
|
348
|
+
// Act - the human returns a verdict; the parked wait collects it
|
|
349
|
+
const resolver = await DaemonClient.connect({ home: idleHome });
|
|
350
|
+
await resolver.sessionResolve(review.id, "approve", "Good to go.");
|
|
351
|
+
resolver.close();
|
|
352
|
+
const verdict = await waiting;
|
|
353
|
+
|
|
354
|
+
// Assert
|
|
355
|
+
expect(verdict!.allow).toBe(true);
|
|
356
|
+
} finally {
|
|
357
|
+
waiterClient.close();
|
|
358
|
+
idleServer.stop();
|
|
359
|
+
rmSync(idleHome, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
}, 15_000);
|
|
362
|
+
});
|
package/src/review.ts
CHANGED
|
@@ -187,6 +187,39 @@ function outcome(session: ReviewSession): VerdictOutcome {
|
|
|
187
187
|
return { ...verdictResponse(session), session };
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
export interface AwaitResolveOptions {
|
|
191
|
+
/** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
|
|
192
|
+
pollMs?: number;
|
|
193
|
+
/** Abort the wait (the harness session shut down); resolves to null. */
|
|
194
|
+
signal?: AbortSignal;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Park until a review session resolves, then return the verdict outcome; null
|
|
199
|
+
* when the signal aborts first. Where ReviewHandle.awaitVerdict needs the handle
|
|
200
|
+
* that opened the review, this needs only a session id - so a background waiter
|
|
201
|
+
* that woke on a session it did not open (a detached Claude Code / Codex waiter,
|
|
202
|
+
* or pi's session_start listener) can collect the same verdict. This is the
|
|
203
|
+
* wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
|
|
204
|
+
* a verdict that lands between chunks is never missed and a session already
|
|
205
|
+
* resolved returns on the first chunk. The held connection also keeps the daemon
|
|
206
|
+
* off its idle-exit path for the whole wait.
|
|
207
|
+
*/
|
|
208
|
+
export async function awaitResolve(
|
|
209
|
+
client: DaemonClient,
|
|
210
|
+
sessionId: string,
|
|
211
|
+
options: AwaitResolveOptions = {},
|
|
212
|
+
): Promise<VerdictOutcome | null> {
|
|
213
|
+
const chunkMs = options.pollMs ?? 30_000;
|
|
214
|
+
const { signal } = options;
|
|
215
|
+
for (;;) {
|
|
216
|
+
if (signal?.aborted) return null;
|
|
217
|
+
const resolved = await raceAbort(client.sessionWait(sessionId, chunkMs), signal);
|
|
218
|
+
if (resolved === ABORTED) return null;
|
|
219
|
+
if (resolved !== null) return outcome(resolved);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
190
223
|
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
|
191
224
|
export async function openReview(
|
|
192
225
|
client: DaemonClient,
|